@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,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
|
+
}
|