@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.
- package/README.md +12 -1
- package/assets/skill/SKILL.md +26 -3
- package/assets/skill/cli-reference.md +9 -0
- package/assets/skill/mcp-reference.md +3 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v1.34.6.zip → gno-browser-clipper-v1.36.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.36.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +111 -1
- package/spec/mcp.md +60 -1
- package/spec/output-schemas/mcp-job-status.schema.json +6 -2
- package/spec/output-schemas/peek.schema.json +212 -0
- package/spec/output-schemas/search-results.schema.json +1 -1
- package/src/cli/commands/peek.ts +66 -0
- package/src/cli/options.ts +2 -0
- package/src/cli/program.ts +20 -0
- package/src/config/index.ts +4 -0
- package/src/config/types.ts +14 -0
- package/src/core/path-rules.ts +34 -0
- package/src/core/peek.ts +202 -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 +197 -24
- package/src/ingestion/types.ts +45 -3
- package/src/ingestion/walker.ts +263 -5
- package/src/mcp/http-egress.ts +1 -0
- package/src/mcp/tools/index.ts +14 -0
- package/src/mcp/tools/peek.ts +78 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/watch-reconciliation-fallback-disk.ts +239 -100
- package/src/serve/watch-reconciliation-fallback.ts +35 -5
- package/src/serve/watch-reconciliation-shared.ts +8 -3
- package/src/serve/watch-reconciliation.ts +7 -0
- package/src/serve/watch-service-flush.ts +10 -0
- package/src/serve/watch-service-lifecycle.ts +2 -0
- package/src/serve/watch-service-snapshot.ts +27 -3
- package/src/serve/watch-service.ts +1 -0
- package/src/serve/watch-snapshot-availability.ts +51 -0
- package/src/serve/watch-snapshot-handles.ts +117 -37
- package/src/serve/watch-snapshot-libc.ts +141 -22
- package/src/serve/watch-snapshot-ops.ts +151 -9
- package/src/serve/watch-snapshot-scan.ts +3 -0
- package/src/serve/watch-snapshot-types.ts +45 -3
- 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 {
|
|
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
|
-
| {
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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:
|
|
123
|
+
cause:
|
|
124
|
+
listed.status === "scan_failed"
|
|
125
|
+
? listed.cause
|
|
126
|
+
: new Error(`Disk scan failed under ${current || "."}`),
|
|
122
127
|
};
|
|
123
128
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
if (
|
|
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
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 {
|
|
19
|
-
import {
|
|
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 (
|
|
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(
|
|
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 {
|
|
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.
|
|
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(
|
|
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
|
|
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
|
}
|