@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,434 @@
1
+ /**
2
+ * Bounded store + disk fallback when snapshot classification cannot prove work.
3
+ * Failed queries never imply inactivation. Disk walks use no-follow handles.
4
+ *
5
+ * @module src/serve/watch-reconciliation-fallback
6
+ */
7
+
8
+ // node:fs/promises — Bun.file().exists() reports false for directories, so it
9
+ // cannot prove collection-root availability on unsupported watcher platforms.
10
+ import { stat } from "node:fs/promises";
11
+ // node:path — Bun has no path utilities
12
+ import { normalize } from "node:path";
13
+
14
+ import type { Collection } from "../config/types";
15
+ import type { DirectoryAvailabilityPort } from "../ingestion/source-availability";
16
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
17
+ import type { StoreResult } from "../store/types";
18
+
19
+ import { matchesCollectionSubtreeExclusion } from "../core/path-rules";
20
+ import {
21
+ collectionToWalkConfig,
22
+ findUnprovenAvailabilityPrefix,
23
+ matchesWalkPath,
24
+ relPathUnderAnyPrefix,
25
+ } from "../ingestion";
26
+ import {
27
+ budgetExceeded,
28
+ fallbackFs,
29
+ inspectNoFollowPresence,
30
+ listEligibleDiskSources,
31
+ type FallbackBudget,
32
+ } from "./watch-reconciliation-fallback-disk";
33
+ import {
34
+ WATCHER_FALLBACK_BUDGET,
35
+ type ClassificationResult,
36
+ } from "./watch-reconciliation-shared";
37
+ import { openDirByRel } from "./watch-snapshot-scan";
38
+ import {
39
+ normalizeWatcherRelPath,
40
+ parentWatcherDir,
41
+ type WatcherSnapshotFs,
42
+ } from "./watch-snapshot-types";
43
+
44
+ export async function fallbackClassifyDirtyHints(options: {
45
+ collection: Collection;
46
+ store: SqliteAdapter;
47
+ rootAbs: string;
48
+ dirtyHints: readonly string[];
49
+ sourcePathMax: number;
50
+ /** Test seam for unsupported-platform fail-closed proofs. */
51
+ fs?: WatcherSnapshotFs;
52
+ directoryAvailability?: DirectoryAvailabilityPort;
53
+ }): Promise<ClassificationResult> {
54
+ const { collection, store, rootAbs, dirtyHints } = options;
55
+ const budgetLimit = Math.min(options.sourcePathMax, WATCHER_FALLBACK_BUDGET);
56
+ const budget: FallbackBudget = {
57
+ limit: budgetLimit,
58
+ visitedDirs: 0,
59
+ candidates: 0,
60
+ removals: 0,
61
+ dirtyDirs: 0,
62
+ storeRows: 0,
63
+ };
64
+ const candidates = new Set<string>();
65
+ const removals = new Set<string>();
66
+ const walkConfig = collectionToWalkConfig(collection, 0);
67
+ const diskSeen = new Set<string>();
68
+ const fs = options.fs ?? fallbackFs();
69
+ const root = normalize(rootAbs);
70
+ const directoryAvailability = options.directoryAvailability;
71
+
72
+ // No anchored handles: never path-walk or infer deletions. Caller must use
73
+ // durable full-collection reconciliation (syncCollection) instead. Prove
74
+ // the root is currently available first so a missing mount/root cannot be
75
+ // mistaken for a genuinely empty collection by the full walk.
76
+ if (!fs.supportsAnchoredHandles) {
77
+ if (directoryAvailability?.mode === "local") {
78
+ const unproven = await findUnprovenAvailabilityPrefix(
79
+ root,
80
+ ".gno-probe",
81
+ directoryAvailability
82
+ );
83
+ if (unproven) {
84
+ return { status: "full_reconcile", reason: "unsupported_fs" };
85
+ }
86
+ }
87
+ try {
88
+ const rootStat = await stat(root);
89
+ if (!rootStat.isDirectory()) {
90
+ throw new Error("Collection root is not a directory");
91
+ }
92
+ } catch (cause) {
93
+ return { status: "error", cause, stage: "scan" };
94
+ }
95
+ return { status: "full_reconcile", reason: "unsupported_fs" };
96
+ }
97
+
98
+ const dirs = new Set<string>();
99
+ for (const hint of dirtyHints) {
100
+ const normalized =
101
+ hint === "" ? "" : normalizeWatcherRelPath(hint.replaceAll("\\", "/"));
102
+ if (normalized === null) {
103
+ continue;
104
+ }
105
+ dirs.add(normalized);
106
+ const parent = parentWatcherDir(normalized);
107
+ if (parent !== null) {
108
+ dirs.add(parent);
109
+ }
110
+ }
111
+ // Ancestors cover descendants — charge each source path once globally.
112
+ const collapsedDirs = collapseOverlappingDirtyDirs(dirs);
113
+ // Global unique store sources across all dirty dirs (no double-charge).
114
+ const storeSeen = new Set<string>();
115
+
116
+ // Prove collection root is available before any deletion comparison.
117
+ const rootOpen = await openDirByRel(root, "", fs);
118
+ if (rootOpen.status === "missing") {
119
+ return {
120
+ status: "error",
121
+ cause: new Error("Collection root is missing"),
122
+ stage: "scan",
123
+ };
124
+ }
125
+ if (rootOpen.status !== "ok") {
126
+ return {
127
+ status: "error",
128
+ cause:
129
+ rootOpen.status === "scan_failed"
130
+ ? rootOpen.cause
131
+ : new Error("Collection root unavailable"),
132
+ stage: "scan",
133
+ };
134
+ }
135
+ await fs.closeDir(rootOpen.handle);
136
+
137
+ for (const dir of collapsedDirs) {
138
+ budget.dirtyDirs += 1;
139
+ if (budgetExceeded(budget)) {
140
+ return overflowResult(dir);
141
+ }
142
+ if (
143
+ dir !== "" &&
144
+ matchesCollectionSubtreeExclusion(dir, walkConfig.exclude)
145
+ ) {
146
+ continue;
147
+ }
148
+
149
+ const disk = await listEligibleDiskSources(
150
+ root,
151
+ dir,
152
+ collection,
153
+ fs,
154
+ budget,
155
+ directoryAvailability
156
+ );
157
+ if (disk.status === "error") {
158
+ return { status: "error", cause: disk.cause, stage: "scan" };
159
+ }
160
+ if (disk.status === "overflow") {
161
+ return overflowResult(dir);
162
+ }
163
+ for (const path of disk.paths) {
164
+ candidates.add(path);
165
+ diskSeen.add(path);
166
+ budget.candidates = candidates.size;
167
+ if (budgetExceeded(budget)) {
168
+ return overflowResult(dir);
169
+ }
170
+ }
171
+
172
+ const storePaths = await collectStorePathsForDir(
173
+ store,
174
+ collection.name,
175
+ dir,
176
+ disk.rootDirNames,
177
+ budget,
178
+ storeSeen
179
+ );
180
+ if (!storePaths.ok) {
181
+ return {
182
+ status: "error",
183
+ cause: new Error(storePaths.error.message),
184
+ stage: "store",
185
+ };
186
+ }
187
+ if (storePaths.overflow) {
188
+ return overflowResult(dir);
189
+ }
190
+ for (const path of storePaths.value) {
191
+ if (!matchesWalkPath(path, walkConfig)) {
192
+ continue;
193
+ }
194
+ if (relPathUnderAnyPrefix(path, disk.unprovenPrefixes)) {
195
+ continue;
196
+ }
197
+ if (diskSeen.has(path)) {
198
+ candidates.add(path);
199
+ budget.candidates = candidates.size;
200
+ } else {
201
+ removals.add(path);
202
+ budget.removals = removals.size;
203
+ }
204
+ if (budgetExceeded(budget)) {
205
+ return overflowResult(dir);
206
+ }
207
+ }
208
+ }
209
+
210
+ const provenRemovals: string[] = [];
211
+ for (const path of removals) {
212
+ const presence = await inspectNoFollowPresence(
213
+ root,
214
+ path,
215
+ fs,
216
+ directoryAvailability
217
+ );
218
+ if (presence.status === "missing") {
219
+ provenRemovals.push(path);
220
+ } else if (presence.status === "present") {
221
+ if (presence.indexable) {
222
+ candidates.add(path);
223
+ } else {
224
+ // Path exists as directory/FIFO/device: still a proven-absent source.
225
+ provenRemovals.push(path);
226
+ }
227
+ } else if (presence.status === "error") {
228
+ return { status: "error", cause: presence.cause, stage: "scan" };
229
+ }
230
+ }
231
+
232
+ return {
233
+ status: "ok",
234
+ candidates: [...candidates].sort(),
235
+ removals: provenRemovals.sort(),
236
+ nextSnapshot: null,
237
+ usedFallback: true,
238
+ };
239
+ }
240
+
241
+ function overflowResult(_dir: string): ClassificationResult {
242
+ // Identical dirty-scan retry cannot clear a budget ceiling — escalate to
243
+ // the same durable full-collection path used for unsupported platforms.
244
+ return { status: "full_reconcile", reason: "budget_overflow" };
245
+ }
246
+
247
+ /**
248
+ * Drop dirty dirs covered by an ancestor so overlapping hints charge once.
249
+ * Root (`""`) absorbs every other dir.
250
+ */
251
+ export function collapseOverlappingDirtyDirs(dirs: Iterable<string>): string[] {
252
+ const list = [...new Set(dirs)];
253
+ if (list.includes("")) {
254
+ return [""];
255
+ }
256
+ list.sort((a, b) => a.length - b.length || a.localeCompare(b));
257
+ const kept: string[] = [];
258
+ for (const dir of list) {
259
+ const covered = kept.some(
260
+ (parent) => parent === dir || dir.startsWith(`${parent}/`)
261
+ );
262
+ if (!covered) {
263
+ kept.push(dir);
264
+ }
265
+ }
266
+ return kept;
267
+ }
268
+
269
+ async function collectStorePathsForDir(
270
+ store: SqliteAdapter,
271
+ collection: string,
272
+ dir: string,
273
+ rootDirNames: readonly string[],
274
+ budget: FallbackBudget,
275
+ storeSeen: Set<string>
276
+ ): Promise<StoreResult<string[]> & { overflow?: boolean }> {
277
+ const out: string[] = [];
278
+
279
+ const takeRows = (
280
+ rows: string[]
281
+ ): { ok: true } | { ok: false; overflow: true } => {
282
+ for (const path of rows) {
283
+ if (storeSeen.has(path)) {
284
+ continue;
285
+ }
286
+ storeSeen.add(path);
287
+ out.push(path);
288
+ // Count unique store sources only (record-container logical dups collapse).
289
+ budget.storeRows += 1;
290
+ if (budgetExceeded(budget)) {
291
+ return { ok: false, overflow: true };
292
+ }
293
+ }
294
+ return { ok: true };
295
+ };
296
+
297
+ const remaining = (): number =>
298
+ Math.max(1, budget.limit - budget.storeRows + 1);
299
+
300
+ // Root: sole bounded DISTINCT inventory — never direct-child then root-wide
301
+ // (that double-counted unique sources and falsely overflowed at scale).
302
+ if (dir === "") {
303
+ const inventory = await listRootActiveSourcePaths(
304
+ store,
305
+ collection,
306
+ remaining()
307
+ );
308
+ if (inventory) {
309
+ if (!inventory.ok) {
310
+ return inventory;
311
+ }
312
+ if (inventory.overflow) {
313
+ return { ok: true, value: [], overflow: true };
314
+ }
315
+ if (!takeRows(inventory.value).ok) {
316
+ return { ok: true, value: [], overflow: true };
317
+ }
318
+ return { ok: true, value: out };
319
+ }
320
+
321
+ // Seam unavailable (stubs): first-level disk-name probes only, no direct-child.
322
+ const firstLevel = new Set<string>(rootDirNames);
323
+ for (const name of firstLevel) {
324
+ if (name === "" || name.includes("/")) {
325
+ continue;
326
+ }
327
+ budget.dirtyDirs += 1;
328
+ if (budgetExceeded(budget)) {
329
+ return { ok: true, value: [], overflow: true };
330
+ }
331
+ const descendants = await safeListDescendants(
332
+ store,
333
+ collection,
334
+ name,
335
+ remaining()
336
+ );
337
+ if (!descendants.ok) {
338
+ if (descendants.error.code === "OVERFLOW") {
339
+ return { ok: true, value: [], overflow: true };
340
+ }
341
+ if (descendants.error.code === "INVALID_INPUT") {
342
+ continue;
343
+ }
344
+ return descendants;
345
+ }
346
+ if (!takeRows(descendants.value).ok) {
347
+ return { ok: true, value: [], overflow: true };
348
+ }
349
+ }
350
+ return { ok: true, value: out };
351
+ }
352
+
353
+ // Non-root: descendants alone (includes direct children). Direct-child +
354
+ // descendant double-counted unique sources and falsely overflowed at scale.
355
+ const descendants = await safeListDescendants(
356
+ store,
357
+ collection,
358
+ dir,
359
+ remaining()
360
+ );
361
+ if (!descendants.ok) {
362
+ if (descendants.error.code === "OVERFLOW") {
363
+ return { ok: true, value: [], overflow: true };
364
+ }
365
+ return descendants;
366
+ }
367
+ if (!takeRows(descendants.value).ok) {
368
+ return { ok: true, value: [], overflow: true };
369
+ }
370
+ return { ok: true, value: out };
371
+ }
372
+
373
+ /**
374
+ * Bounded root-wide DISTINCT active source inventory.
375
+ * Returns null when the seam is unavailable (tests/stubs use disk probes).
376
+ */
377
+ async function listRootActiveSourcePaths(
378
+ store: SqliteAdapter,
379
+ collection: string,
380
+ max: number
381
+ ): Promise<(StoreResult<string[]> & { overflow?: boolean }) | null> {
382
+ if (typeof store.listActiveSourcePaths !== "function") {
383
+ return null;
384
+ }
385
+ try {
386
+ const result = await store.listActiveSourcePaths(collection, max);
387
+ if (!result.ok) {
388
+ if (result.error.code === "OVERFLOW") {
389
+ return { ok: true, value: [], overflow: true };
390
+ }
391
+ return result;
392
+ }
393
+ return { ok: true, value: result.value };
394
+ } catch (cause) {
395
+ return {
396
+ ok: false,
397
+ error: {
398
+ code: "QUERY_FAILED",
399
+ message:
400
+ cause instanceof Error
401
+ ? cause.message
402
+ : "Root store inventory failed",
403
+ cause,
404
+ },
405
+ };
406
+ }
407
+ }
408
+
409
+ async function safeListDescendants(
410
+ store: SqliteAdapter,
411
+ storeCollection: string,
412
+ dir: string,
413
+ max: number
414
+ ): Promise<StoreResult<string[]>> {
415
+ try {
416
+ return await store.listActiveDescendantSourcePaths(
417
+ storeCollection,
418
+ dir,
419
+ max
420
+ );
421
+ } catch (cause) {
422
+ return {
423
+ ok: false,
424
+ error: {
425
+ code: "QUERY_FAILED",
426
+ message:
427
+ cause instanceof Error
428
+ ? cause.message
429
+ : "Descendant store query failed",
430
+ cause,
431
+ },
432
+ };
433
+ }
434
+ }