@gmickel/gno 1.34.6 → 1.36.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 (51) hide show
  1. package/README.md +12 -1
  2. package/assets/skill/SKILL.md +26 -3
  3. package/assets/skill/cli-reference.md +9 -0
  4. package/assets/skill/mcp-reference.md +3 -2
  5. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.6.zip → gno-browser-clipper-v1.36.0.zip} +0 -0
  6. package/browser-extension/artifacts/gno-browser-clipper-v1.36.0.zip.sha256 +1 -0
  7. package/browser-extension/dist/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/spec/cli.md +111 -1
  10. package/spec/mcp.md +60 -1
  11. package/spec/output-schemas/mcp-job-status.schema.json +6 -2
  12. package/spec/output-schemas/peek.schema.json +212 -0
  13. package/spec/output-schemas/search-results.schema.json +1 -1
  14. package/src/cli/commands/peek.ts +66 -0
  15. package/src/cli/options.ts +2 -0
  16. package/src/cli/program.ts +20 -0
  17. package/src/config/index.ts +4 -0
  18. package/src/config/types.ts +14 -0
  19. package/src/core/path-rules.ts +34 -0
  20. package/src/core/peek.ts +202 -0
  21. package/src/ingestion/index.ts +21 -0
  22. package/src/ingestion/record-container.ts +23 -1
  23. package/src/ingestion/source-availability/darwin-io.ts +295 -0
  24. package/src/ingestion/source-availability/darwin-path.ts +58 -0
  25. package/src/ingestion/source-availability/directory.ts +402 -0
  26. package/src/ingestion/source-availability/index.ts +74 -0
  27. package/src/ingestion/source-availability/readers.ts +360 -0
  28. package/src/ingestion/source-availability/resolve.ts +28 -0
  29. package/src/ingestion/source-availability/types.ts +170 -0
  30. package/src/ingestion/sync.ts +197 -24
  31. package/src/ingestion/types.ts +45 -3
  32. package/src/ingestion/walker.ts +263 -5
  33. package/src/mcp/http-egress.ts +1 -0
  34. package/src/mcp/tools/index.ts +14 -0
  35. package/src/mcp/tools/peek.ts +78 -0
  36. package/src/serve/public/globals.built.css +1 -1
  37. package/src/serve/watch-reconciliation-fallback-disk.ts +239 -100
  38. package/src/serve/watch-reconciliation-fallback.ts +35 -5
  39. package/src/serve/watch-reconciliation-shared.ts +8 -3
  40. package/src/serve/watch-reconciliation.ts +7 -0
  41. package/src/serve/watch-service-flush.ts +10 -0
  42. package/src/serve/watch-service-lifecycle.ts +2 -0
  43. package/src/serve/watch-service-snapshot.ts +27 -3
  44. package/src/serve/watch-service.ts +1 -0
  45. package/src/serve/watch-snapshot-availability.ts +51 -0
  46. package/src/serve/watch-snapshot-handles.ts +117 -37
  47. package/src/serve/watch-snapshot-libc.ts +141 -22
  48. package/src/serve/watch-snapshot-ops.ts +151 -9
  49. package/src/serve/watch-snapshot-scan.ts +3 -0
  50. package/src/serve/watch-snapshot-types.ts +45 -3
  51. package/browser-extension/artifacts/gno-browser-clipper-v1.34.6.zip.sha256 +0 -1
@@ -6,17 +6,24 @@
6
6
  * @module src/serve/watch-reconciliation-fallback-disk
7
7
  */
8
8
 
9
+ // node:path — Bun has no path join helper.
10
+ import { join } from "node:path";
11
+
9
12
  import type { Collection } from "../config/types";
13
+ import type { DirectoryAvailabilityPort } from "../ingestion/source-availability";
10
14
 
11
- import { matchesCollectionExclusion } from "../core/path-rules";
15
+ import {
16
+ matchesCollectionExclusion,
17
+ matchesCollectionSubtreeExclusion,
18
+ } from "../core/path-rules";
12
19
  import { collectionToWalkConfig, matchesWalkPath } from "../ingestion";
20
+ import { findUnprovenAvailabilityPrefix } from "../ingestion/source-availability";
13
21
  import { defaultFs, openDirByRel } from "./watch-snapshot-scan";
14
22
  import {
15
23
  isMissingFsError,
16
24
  joinWatcherRelPath,
17
25
  parentWatcherDir,
18
26
  type WatcherSnapshotFs,
19
- type WatcherSnapshotStat,
20
27
  } from "./watch-snapshot-types";
21
28
 
22
29
  export interface FallbackBudget {
@@ -47,7 +54,12 @@ export function fallbackFs(): WatcherSnapshotFs {
47
54
  }
48
55
 
49
56
  export type DiskListResult =
50
- | { status: "ok"; paths: string[]; rootDirNames: string[] }
57
+ | {
58
+ status: "ok";
59
+ paths: string[];
60
+ rootDirNames: string[];
61
+ unprovenPrefixes: string[];
62
+ }
51
63
  | { status: "overflow" }
52
64
  | { status: "error"; cause: unknown };
53
65
 
@@ -60,128 +72,207 @@ export async function listEligibleDiskSources(
60
72
  dirRel: string,
61
73
  collection: Collection,
62
74
  fs: WatcherSnapshotFs,
63
- budget: FallbackBudget
75
+ budget: FallbackBudget,
76
+ directoryAvailability?: DirectoryAvailabilityPort
64
77
  ): Promise<DiskListResult> {
65
78
  const walkConfig = collectionToWalkConfig(collection, 0);
66
79
  const paths: string[] = [];
67
80
  const rootDirNames: string[] = [];
81
+ const unprovenPrefixes: string[] = [];
68
82
  const queue: string[] = [dirRel];
69
83
  let head = 0;
70
84
 
71
85
  while (head < queue.length) {
72
86
  const current = queue[head] as string;
73
87
  head += 1;
88
+ if (
89
+ current !== "" &&
90
+ matchesCollectionSubtreeExclusion(current, walkConfig.exclude)
91
+ ) {
92
+ continue;
93
+ }
74
94
  budget.visitedDirs += 1;
75
95
  if (budgetExceeded(budget)) {
76
96
  return { status: "overflow" };
77
97
  }
78
98
 
79
99
  const remaining = Math.max(0, budget.limit - budget.visitedDirs + 1);
80
- const opened = await openDirByRel(rootAbs, current, fs);
81
- if (opened.status === "missing") {
82
- continue;
83
- }
84
- if (opened.status !== "ok") {
85
- return {
86
- status: "error",
87
- cause:
88
- opened.status === "scan_failed"
89
- ? opened.cause
90
- : new Error(`Disk scan failed under ${current || "."}`),
91
- };
92
- }
93
-
94
- let listed;
95
- try {
96
- listed = await fs.readDir(opened.handle, remaining);
97
- } catch (cause) {
98
- await fs.closeDir(opened.handle);
99
- if (isMissingFsError(cause)) {
100
+ if (directoryAvailability?.mode === "local") {
101
+ if (!fs.readDirectChildrenSync) {
102
+ unprovenPrefixes.push(current);
100
103
  continue;
101
104
  }
102
- return { status: "error", cause };
103
- }
104
-
105
- if (listed.status === "overflow") {
106
- await fs.closeDir(opened.handle);
107
- return { status: "overflow" };
108
- }
109
-
110
- const names = [...listed.names].sort((a, b) =>
111
- a < b ? -1 : a > b ? 1 : 0
112
- );
113
- for (const name of names) {
114
- if (name === "" || name === "." || name === "..") {
105
+ const absPath = current === "" ? rootAbs : join(rootAbs, current);
106
+ const guarded = directoryAvailability.readDirectory(absPath, () =>
107
+ fs.readDirectChildrenSync!(rootAbs, current, remaining)
108
+ );
109
+ if (guarded.kind !== "available") {
110
+ unprovenPrefixes.push(current);
115
111
  continue;
116
112
  }
117
- if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
118
- await fs.closeDir(opened.handle);
113
+ const listed = guarded.value;
114
+ if (listed.status === "missing") {
115
+ continue;
116
+ }
117
+ if (listed.status === "overflow") {
118
+ return { status: "overflow" };
119
+ }
120
+ if (listed.status !== "present") {
119
121
  return {
120
122
  status: "error",
121
- cause: new Error(`Invalid directory entry name: ${name}`),
123
+ cause:
124
+ listed.status === "scan_failed"
125
+ ? listed.cause
126
+ : new Error(`Disk scan failed under ${current || "."}`),
122
127
  };
123
128
  }
124
-
125
- let stat: WatcherSnapshotStat;
126
- try {
127
- stat = await fs.lstatChild(opened.handle, name);
128
- } catch (cause) {
129
- await fs.closeDir(opened.handle);
130
- if (isMissingFsError(cause)) {
131
- return { status: "error", cause };
129
+ for (const [name, fingerprint] of listed.entries) {
130
+ const childRel = joinWatcherRelPath(current, name);
131
+ if (fingerprint.kind === "directory") {
132
+ if (matchesCollectionSubtreeExclusion(childRel, walkConfig.exclude)) {
133
+ continue;
134
+ }
135
+ if (current === "") {
136
+ rootDirNames.push(name);
137
+ }
138
+ queue.push(childRel);
139
+ } else if (matchesCollectionExclusion(childRel, walkConfig.exclude)) {
140
+ continue;
141
+ } else if (fingerprint.kind === "symlink") {
142
+ if (matchesWalkPath(childRel, walkConfig)) {
143
+ paths.push(childRel);
144
+ }
145
+ } else if (
146
+ fingerprint.kind === "file" &&
147
+ matchesWalkPath(childRel, walkConfig)
148
+ ) {
149
+ paths.push(childRel);
150
+ }
151
+ if (paths.length > budget.limit) {
152
+ return { status: "overflow" };
132
153
  }
133
- return { status: "error", cause };
134
154
  }
155
+ continue;
156
+ }
135
157
 
136
- const childRel = joinWatcherRelPath(current, name);
137
- if (matchesCollectionExclusion(childRel, walkConfig.exclude)) {
138
- continue;
158
+ const scanCurrent = async (): Promise<
159
+ | { status: "ok" }
160
+ | { status: "overflow" }
161
+ | { status: "error"; cause: unknown }
162
+ > => {
163
+ const opened = await openDirByRel(rootAbs, current, fs);
164
+ if (opened.status === "missing") {
165
+ return { status: "ok" };
166
+ }
167
+ if (opened.status !== "ok") {
168
+ return {
169
+ status: "error",
170
+ cause:
171
+ opened.status === "scan_failed"
172
+ ? opened.cause
173
+ : new Error(`Disk scan failed under ${current || "."}`),
174
+ };
139
175
  }
140
176
 
141
- // Never follow symlinks; eligible link paths stay leaf candidates.
142
- if (stat.isSymbolicLink()) {
143
- if (matchesWalkPath(childRel, walkConfig)) {
177
+ try {
178
+ const listed = await fs.readDir(opened.handle, remaining);
179
+ if (listed.status === "overflow") {
180
+ return { status: "overflow" };
181
+ }
182
+ const names = [...listed.names].sort((a, b) =>
183
+ a < b ? -1 : a > b ? 1 : 0
184
+ );
185
+ for (const name of names) {
186
+ if (name === "" || name === "." || name === "..") {
187
+ continue;
188
+ }
189
+ if (
190
+ name.includes("/") ||
191
+ name.includes("\\") ||
192
+ name.includes("\0")
193
+ ) {
194
+ return {
195
+ status: "error",
196
+ cause: new Error(`Invalid directory entry name: ${name}`),
197
+ };
198
+ }
199
+
200
+ const stat = await fs.lstatChild(opened.handle, name);
201
+ const childRel = joinWatcherRelPath(current, name);
202
+ if (stat.isDirectory()) {
203
+ if (
204
+ matchesCollectionSubtreeExclusion(childRel, walkConfig.exclude)
205
+ ) {
206
+ continue;
207
+ }
208
+ if (current === "") {
209
+ rootDirNames.push(name);
210
+ }
211
+ queue.push(childRel);
212
+ continue;
213
+ }
214
+ if (matchesCollectionExclusion(childRel, walkConfig.exclude)) {
215
+ continue;
216
+ }
217
+ if (stat.isSymbolicLink()) {
218
+ if (matchesWalkPath(childRel, walkConfig)) {
219
+ paths.push(childRel);
220
+ if (paths.length > budget.limit) {
221
+ return { status: "overflow" };
222
+ }
223
+ }
224
+ continue;
225
+ }
226
+ if (!stat.isFile() || !matchesWalkPath(childRel, walkConfig)) {
227
+ continue;
228
+ }
144
229
  paths.push(childRel);
145
230
  if (paths.length > budget.limit) {
146
- await fs.closeDir(opened.handle);
147
231
  return { status: "overflow" };
148
232
  }
149
233
  }
150
- continue;
151
- }
152
-
153
- if (stat.isDirectory()) {
154
- if (current === "") {
155
- rootDirNames.push(name);
156
- }
157
- queue.push(childRel);
158
- continue;
159
- }
160
-
161
- if (!stat.isFile() || !matchesWalkPath(childRel, walkConfig)) {
162
- continue;
163
- }
164
- paths.push(childRel);
165
- if (paths.length > budget.limit) {
234
+ return { status: "ok" };
235
+ } catch (cause) {
236
+ return { status: "error", cause };
237
+ } finally {
166
238
  await fs.closeDir(opened.handle);
167
- return { status: "overflow" };
168
239
  }
240
+ };
241
+
242
+ const scanned = await scanCurrent();
243
+ if (scanned.status !== "ok") {
244
+ return scanned;
169
245
  }
170
- await fs.closeDir(opened.handle);
171
246
  }
172
247
 
173
- return { status: "ok", paths, rootDirNames };
248
+ return { status: "ok", paths, rootDirNames, unprovenPrefixes };
174
249
  }
175
250
 
176
251
  export async function inspectNoFollowPresence(
177
252
  rootAbs: string,
178
253
  relPath: string,
179
- fs: WatcherSnapshotFs
254
+ fs: WatcherSnapshotFs,
255
+ directoryAvailability?: DirectoryAvailabilityPort
180
256
  ): Promise<
181
257
  | { status: "present"; indexable: boolean }
182
258
  | { status: "missing" }
183
259
  | { status: "error"; cause: unknown }
184
260
  > {
261
+ if (directoryAvailability?.mode === "local") {
262
+ const unproven = await findUnprovenAvailabilityPrefix(
263
+ rootAbs,
264
+ relPath,
265
+ directoryAvailability
266
+ );
267
+ if (unproven) {
268
+ return {
269
+ status: "error",
270
+ cause: new Error(
271
+ `Source absence is unproven under ${unproven.relPath || "."}: ${unproven.code}`
272
+ ),
273
+ };
274
+ }
275
+ }
185
276
  const parent = parentWatcherDir(relPath);
186
277
  if (parent === null) {
187
278
  return { status: "missing" };
@@ -190,31 +281,79 @@ export async function inspectNoFollowPresence(
190
281
  if (base === "" || base.includes("/")) {
191
282
  return { status: "error", cause: new Error(`Invalid path: ${relPath}`) };
192
283
  }
193
- const opened = await openDirByRel(rootAbs, parent, fs);
194
- if (opened.status === "missing") {
195
- return { status: "missing" };
196
- }
197
- if (opened.status !== "ok") {
198
- return {
199
- status: "error",
200
- cause:
201
- opened.status === "scan_failed"
202
- ? opened.cause
203
- : new Error("Failed to open parent for presence check"),
204
- };
205
- }
206
- try {
207
- const stat = await fs.lstatChild(opened.handle, base);
208
- // Only regular files / symlinks are indexable sources. Directory, FIFO,
209
- // device, and other specials prove the prior file source is gone.
210
- const indexable = stat.isFile() || stat.isSymbolicLink();
211
- return { status: "present", indexable };
212
- } catch (cause) {
213
- if (isMissingFsError(cause)) {
284
+ const inspectParent = async (): Promise<
285
+ | { status: "present"; indexable: boolean }
286
+ | { status: "missing" }
287
+ | { status: "error"; cause: unknown }
288
+ > => {
289
+ const opened = await openDirByRel(rootAbs, parent, fs);
290
+ if (opened.status === "missing") {
214
291
  return { status: "missing" };
215
292
  }
216
- return { status: "error", cause };
217
- } finally {
218
- await fs.closeDir(opened.handle);
293
+ if (opened.status !== "ok") {
294
+ return {
295
+ status: "error",
296
+ cause:
297
+ opened.status === "scan_failed"
298
+ ? opened.cause
299
+ : new Error("Failed to open parent for presence check"),
300
+ };
301
+ }
302
+ try {
303
+ const stat = await fs.lstatChild(opened.handle, base);
304
+ // Only regular files / symlinks are indexable sources. Directory, FIFO,
305
+ // device, and other specials prove the prior file source is gone.
306
+ const indexable = stat.isFile() || stat.isSymbolicLink();
307
+ return { status: "present", indexable };
308
+ } catch (cause) {
309
+ if (isMissingFsError(cause)) {
310
+ return { status: "missing" };
311
+ }
312
+ return { status: "error", cause };
313
+ } finally {
314
+ await fs.closeDir(opened.handle);
315
+ }
316
+ };
317
+
318
+ if (directoryAvailability?.mode === "local") {
319
+ if (!fs.lstatChildByRelSync) {
320
+ return {
321
+ status: "error",
322
+ cause: new Error(
323
+ "Synchronous anchored child metadata is unavailable in local mode"
324
+ ),
325
+ };
326
+ }
327
+ const parentAbs = parent === "" ? rootAbs : join(rootAbs, parent);
328
+ const guarded = directoryAvailability.readDirectory(parentAbs, () => {
329
+ try {
330
+ return {
331
+ status: "present" as const,
332
+ stat: fs.lstatChildByRelSync!(rootAbs, parent, base),
333
+ };
334
+ } catch (cause) {
335
+ if (isMissingFsError(cause)) {
336
+ return { status: "missing" as const };
337
+ }
338
+ throw cause;
339
+ }
340
+ });
341
+ if (guarded.kind !== "available") {
342
+ return {
343
+ status: "error",
344
+ cause: new Error(
345
+ `Source absence is unproven under ${parent || "."}: ${guarded.code}`
346
+ ),
347
+ };
348
+ }
349
+ if (guarded.value.status === "missing") {
350
+ return { status: "missing" };
351
+ }
352
+ return {
353
+ status: "present",
354
+ indexable:
355
+ guarded.value.stat.isFile() || guarded.value.stat.isSymbolicLink(),
356
+ };
219
357
  }
358
+ return inspectParent();
220
359
  }
@@ -12,11 +12,17 @@ import { stat } from "node:fs/promises";
12
12
  import { normalize } from "node:path";
13
13
 
14
14
  import type { Collection } from "../config/types";
15
+ import type { DirectoryAvailabilityPort } from "../ingestion/source-availability";
15
16
  import type { SqliteAdapter } from "../store/sqlite/adapter";
16
17
  import type { StoreResult } from "../store/types";
17
18
 
18
- import { matchesCollectionExclusion } from "../core/path-rules";
19
- import { collectionToWalkConfig, matchesWalkPath } from "../ingestion";
19
+ import { matchesCollectionSubtreeExclusion } from "../core/path-rules";
20
+ import {
21
+ collectionToWalkConfig,
22
+ findUnprovenAvailabilityPrefix,
23
+ matchesWalkPath,
24
+ relPathUnderAnyPrefix,
25
+ } from "../ingestion";
20
26
  import {
21
27
  budgetExceeded,
22
28
  fallbackFs,
@@ -43,6 +49,7 @@ export async function fallbackClassifyDirtyHints(options: {
43
49
  sourcePathMax: number;
44
50
  /** Test seam for unsupported-platform fail-closed proofs. */
45
51
  fs?: WatcherSnapshotFs;
52
+ directoryAvailability?: DirectoryAvailabilityPort;
46
53
  }): Promise<ClassificationResult> {
47
54
  const { collection, store, rootAbs, dirtyHints } = options;
48
55
  const budgetLimit = Math.min(options.sourcePathMax, WATCHER_FALLBACK_BUDGET);
@@ -60,12 +67,23 @@ export async function fallbackClassifyDirtyHints(options: {
60
67
  const diskSeen = new Set<string>();
61
68
  const fs = options.fs ?? fallbackFs();
62
69
  const root = normalize(rootAbs);
70
+ const directoryAvailability = options.directoryAvailability;
63
71
 
64
72
  // No anchored handles: never path-walk or infer deletions. Caller must use
65
73
  // durable full-collection reconciliation (syncCollection) instead. Prove
66
74
  // the root is currently available first so a missing mount/root cannot be
67
75
  // mistaken for a genuinely empty collection by the full walk.
68
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
+ }
69
87
  try {
70
88
  const rootStat = await stat(root);
71
89
  if (!rootStat.isDirectory()) {
@@ -121,7 +139,10 @@ export async function fallbackClassifyDirtyHints(options: {
121
139
  if (budgetExceeded(budget)) {
122
140
  return overflowResult(dir);
123
141
  }
124
- if (dir !== "" && matchesCollectionExclusion(dir, walkConfig.exclude)) {
142
+ if (
143
+ dir !== "" &&
144
+ matchesCollectionSubtreeExclusion(dir, walkConfig.exclude)
145
+ ) {
125
146
  continue;
126
147
  }
127
148
 
@@ -130,7 +151,8 @@ export async function fallbackClassifyDirtyHints(options: {
130
151
  dir,
131
152
  collection,
132
153
  fs,
133
- budget
154
+ budget,
155
+ directoryAvailability
134
156
  );
135
157
  if (disk.status === "error") {
136
158
  return { status: "error", cause: disk.cause, stage: "scan" };
@@ -169,6 +191,9 @@ export async function fallbackClassifyDirtyHints(options: {
169
191
  if (!matchesWalkPath(path, walkConfig)) {
170
192
  continue;
171
193
  }
194
+ if (relPathUnderAnyPrefix(path, disk.unprovenPrefixes)) {
195
+ continue;
196
+ }
172
197
  if (diskSeen.has(path)) {
173
198
  candidates.add(path);
174
199
  budget.candidates = candidates.size;
@@ -184,7 +209,12 @@ export async function fallbackClassifyDirtyHints(options: {
184
209
 
185
210
  const provenRemovals: string[] = [];
186
211
  for (const path of removals) {
187
- const presence = await inspectNoFollowPresence(root, path, fs);
212
+ const presence = await inspectNoFollowPresence(
213
+ root,
214
+ path,
215
+ fs,
216
+ directoryAvailability
217
+ );
188
218
  if (presence.status === "missing") {
189
219
  provenRemovals.push(path);
190
220
  } else if (presence.status === "present") {
@@ -13,7 +13,11 @@ import type { Collection } from "../config/types";
13
13
  import type { CollectionSyncResult } from "../ingestion";
14
14
 
15
15
  import { matchesCollectionExclusion } from "../core/path-rules";
16
- import { collectionToWalkConfig, matchesWalkPath } from "../ingestion";
16
+ import {
17
+ collectionToWalkConfig,
18
+ isSourceAvailabilitySkip,
19
+ matchesWalkPath,
20
+ } from "../ingestion";
17
21
  import {
18
22
  normalizeWatcherRelPath,
19
23
  parentWatcherDir,
@@ -64,7 +68,8 @@ export type PathPresence =
64
68
  export type ClassificationFullReconcileReason =
65
69
  | "unsupported_fs"
66
70
  | "budget_overflow"
67
- | "snapshot_overflow";
71
+ | "snapshot_overflow"
72
+ | "snapshot_unproven_subtree";
68
73
 
69
74
  export type ClassificationResult =
70
75
  | {
@@ -172,7 +177,7 @@ export function hasFileLevelSyncError(result: CollectionSyncResult): boolean {
172
177
  if (result.files?.some((file) => file.status === "error")) {
173
178
  return true;
174
179
  }
175
- return result.errors.length > 0;
180
+ return result.errors.some((error) => !isSourceAvailabilitySkip(error.code));
176
181
  }
177
182
 
178
183
  /**
@@ -107,6 +107,12 @@ export async function classifyDirtyHints(options: {
107
107
  if (diff.status === "fallback" && diff.reason === "overflow") {
108
108
  return { status: "full_reconcile", reason: "snapshot_overflow" };
109
109
  }
110
+ if (diff.status === "fallback" && diff.reason === "unproven_subtree") {
111
+ return {
112
+ status: "full_reconcile",
113
+ reason: "snapshot_unproven_subtree",
114
+ };
115
+ }
110
116
  // Fall through for scan/metadata failure — previous snapshot uncommitted.
111
117
  }
112
118
 
@@ -118,5 +124,6 @@ export async function classifyDirtyHints(options: {
118
124
  sourcePathMax,
119
125
  // Only anchored FS may walk; unsupported injects fail-closed handles.
120
126
  fs: snapshotOptions?.fs,
127
+ directoryAvailability: snapshotOptions?.directoryAvailability,
121
128
  });
122
129
  }
@@ -16,8 +16,11 @@ import type { WatcherSnapshot, WatcherSnapshotFs } from "./watch-snapshot";
16
16
 
17
17
  import {
18
18
  collectionToWalkConfig,
19
+ createDirectoryAvailability,
19
20
  defaultSyncService,
20
21
  matchesWalkPath,
22
+ memoizeDirectoryAvailability,
23
+ resolveSourceAvailability,
21
24
  } from "../ingestion";
22
25
  import {
23
26
  classifyDirtyHints,
@@ -171,6 +174,10 @@ export async function flushCollectionOnce(
171
174
  let needsFullFromAmbiguous = false;
172
175
 
173
176
  if (dirtyHints.length > 0) {
177
+ const availabilityMode = resolveSourceAvailability(
178
+ input.collection,
179
+ input.getCurrentSyncOptions()
180
+ );
174
181
  const classified = await classifyDirtyHints({
175
182
  collection: input.collection,
176
183
  store: input.store,
@@ -183,6 +190,9 @@ export async function flushCollectionOnce(
183
190
  ...(input.snapshotEntryCeiling !== undefined
184
191
  ? { entryCeiling: input.snapshotEntryCeiling }
185
192
  : {}),
193
+ directoryAvailability: memoizeDirectoryAvailability(
194
+ createDirectoryAvailability(availabilityMode)
195
+ ),
186
196
  },
187
197
  });
188
198
  if (input.disposed()) {
@@ -13,6 +13,7 @@ import type { Collection } from "../config/types";
13
13
  import type { SyncOptions } from "../ingestion";
14
14
  import type { WatcherSnapshot } from "./watch-snapshot";
15
15
 
16
+ import { resolveSourceAvailability } from "../ingestion/source-availability";
16
17
  import { emptyPending, type CollectionPending } from "./watch-service-state";
17
18
 
18
19
  export interface WatchLifecycleHost {
@@ -65,6 +66,7 @@ export function watcherCollectionFingerprint(
65
66
  exclude: collection.exclude,
66
67
  languageHint: collection.languageHint ?? null,
67
68
  recordAdapters: collection.recordAdapters ?? null,
69
+ sourceAvailability: resolveSourceAvailability(collection, syncOptions),
68
70
  limits: syncOptions.limits ?? null,
69
71
  concurrency: syncOptions.concurrency ?? null,
70
72
  contentTypeRules: syncOptions.contentTypeRules ?? null,
@@ -8,12 +8,18 @@
8
8
  import { normalize } from "node:path";
9
9
 
10
10
  import type { Collection } from "../config/types";
11
+ import type { SyncOptions } from "../ingestion";
11
12
  import type {
12
13
  WatcherSnapshot,
13
14
  WatcherSnapshotBuildResult,
14
15
  WatcherSnapshotOptions,
15
16
  } from "./watch-snapshot";
16
17
 
18
+ import {
19
+ createDirectoryAvailability,
20
+ memoizeDirectoryAvailability,
21
+ resolveSourceAvailability,
22
+ } from "../ingestion";
17
23
  import { buildWatcherSnapshot } from "./watch-snapshot";
18
24
 
19
25
  export interface SnapshotInitHost {
@@ -26,6 +32,8 @@ export interface SnapshotInitHost {
26
32
  getInit: (collectionName: string) => Promise<void> | undefined;
27
33
  setInit: (collectionName: string, init: Promise<void> | undefined) => void;
28
34
  onReadyWithPending: (collectionName: string) => void;
35
+ /** Current run-level overrides; omitted by narrow unit-test hosts. */
36
+ getSyncOptions?: () => SyncOptions;
29
37
  /** Optional injectable builder for hung/slow-init tests. */
30
38
  buildSnapshot?: (
31
39
  rootAbs: string,
@@ -44,7 +52,14 @@ export function beginSnapshotInit(
44
52
  const generation = host.getGeneration(collection.name);
45
53
  const root = normalize(collection.path);
46
54
  let init!: Promise<void>;
47
- init = runSnapshotInit(host, collection.name, root, generation, () => init);
55
+ init = runSnapshotInit(
56
+ host,
57
+ collection.name,
58
+ root,
59
+ generation,
60
+ () => init,
61
+ collection
62
+ );
48
63
  host.setInit(collection.name, init);
49
64
  void init.catch(() => undefined);
50
65
  }
@@ -54,11 +69,20 @@ async function runSnapshotInit(
54
69
  collectionName: string,
55
70
  root: string,
56
71
  generation: number,
57
- getInit: () => Promise<void>
72
+ getInit: () => Promise<void>,
73
+ collection: Collection
58
74
  ): Promise<void> {
59
75
  try {
60
76
  const builder = host.buildSnapshot ?? buildWatcherSnapshot;
61
- const built = await builder(root);
77
+ const availabilityMode = resolveSourceAvailability(
78
+ collection,
79
+ host.getSyncOptions?.()
80
+ );
81
+ const built = await builder(root, {
82
+ directoryAvailability: memoizeDirectoryAvailability(
83
+ createDirectoryAvailability(availabilityMode)
84
+ ),
85
+ });
62
86
  if (host.disposed()) {
63
87
  return;
64
88
  }