@gmickel/gno 2.12.2 → 2.13.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 +1 -1
- package/assets/skill/SKILL.md +1 -1
- package/assets/skill/cli-reference.md +23 -10
- package/assets/skill/mcp-reference.md +2 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.12.2.zip → gno-browser-clipper-v2.13.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.13.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +66 -5
- package/spec/mcp.md +96 -1
- package/spec/output-schemas/convert.schema.json +97 -0
- package/spec/output-schemas/mcp-job-status.schema.json +17 -1
- package/spec/output-schemas/mcp-sync-result.schema.json +9 -2
- package/spec/output-schemas/search-results.schema.json +7 -0
- package/src/cli/commands/index-cmd.ts +50 -12
- package/src/cli/commands/query.ts +21 -8
- package/src/cli/commands/search.ts +11 -0
- package/src/cli/commands/vsearch.ts +17 -4
- package/src/cli/options.ts +19 -0
- package/src/cli/program.ts +79 -10
- package/src/core/collection-scope.ts +105 -0
- package/src/core/errors.ts +5 -0
- package/src/core/job-manager.ts +238 -19
- package/src/core/reindex-paths.ts +167 -0
- package/src/core/retrieval-trace-request.ts +1 -0
- package/src/ingestion/convert-file.ts +181 -0
- package/src/llm/registry.ts +20 -0
- package/src/mcp/http-egress.ts +21 -2
- package/src/mcp/tools/collection-scope.ts +25 -0
- package/src/mcp/tools/index-cmd.ts +66 -48
- package/src/mcp/tools/index.ts +74 -10
- package/src/mcp/tools/job-status.ts +7 -0
- package/src/mcp/tools/query.ts +28 -7
- package/src/mcp/tools/search.ts +4 -0
- package/src/mcp/tools/sync.ts +68 -60
- package/src/mcp/tools/vsearch.ts +9 -3
- package/src/pipeline/filters.ts +18 -3
- package/src/pipeline/graph-retrieval.ts +1 -0
- package/src/pipeline/hybrid.ts +15 -2
- package/src/pipeline/hydration.ts +7 -2
- package/src/pipeline/owner-fusion.ts +1 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +7 -0
- package/src/pipeline/vsearch.ts +10 -5
- package/src/sdk/client.ts +39 -11
- package/src/sdk/convert.ts +27 -0
- package/src/sdk/index.ts +2 -0
- package/src/sdk/types.ts +1 -1
- package/src/serve/jobs.ts +4 -1
- package/src/serve/public/components/IndexingProgress.tsx +25 -3
- package/src/serve/routes/api.ts +164 -24
- package/src/store/sqlite/adapter.ts +97 -27
- package/src/store/sqlite/eligibility.ts +4 -0
- package/src/store/types.ts +7 -0
- package/src/store/vector/sqlite-vec.ts +67 -15
- package/src/store/vector/variant-search.ts +41 -4
- package/browser-extension/artifacts/gno-browser-clipper-v2.12.2.zip.sha256 +0 -1
package/src/core/job-manager.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
// node:async_hooks: Bun has no separate async-context snapshot API.
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
/**
|
|
3
4
|
* Background job manager for MCP write operations.
|
|
4
5
|
*
|
|
5
6
|
* @module src/core/job-manager
|
|
6
7
|
*/
|
|
7
8
|
|
|
9
|
+
import type { SyncResult } from "../ingestion";
|
|
10
|
+
|
|
8
11
|
import {
|
|
9
12
|
assertInferenceActive,
|
|
10
13
|
withOwnedInferenceScope,
|
|
@@ -12,14 +15,17 @@ import {
|
|
|
12
15
|
} from "../llm/inference-scope";
|
|
13
16
|
import { MCP_ERRORS } from "./errors";
|
|
14
17
|
import { acquireWriteLock, type WriteLockHandle } from "./file-lock";
|
|
18
|
+
import { MAX_REINDEX_PATHS } from "./reindex-paths";
|
|
15
19
|
|
|
16
20
|
const JOB_EXPIRATION_MS = 60 * 60 * 1000;
|
|
17
21
|
const JOB_MAX_RECENT = 100;
|
|
18
22
|
const DEFAULT_LOCK_TIMEOUT_MS = 5000;
|
|
23
|
+
/** Distinct reindex requests that may wait behind the running job. */
|
|
24
|
+
export const MAX_PENDING_REINDEX_JOBS = 64;
|
|
19
25
|
|
|
20
26
|
export type JobType = "add" | "sync" | "embed" | "index";
|
|
21
27
|
|
|
22
|
-
export type JobStatus = "running" | "completed" | "failed";
|
|
28
|
+
export type JobStatus = "queued" | "running" | "completed" | "failed";
|
|
23
29
|
|
|
24
30
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
31
|
// Discriminated union for job results
|
|
@@ -51,9 +57,55 @@ export interface JobRecord {
|
|
|
51
57
|
typedResult?: JobResult;
|
|
52
58
|
error?: string;
|
|
53
59
|
progress?: { current: number; total: number; currentFile?: string };
|
|
60
|
+
/** Reindex jobs: the collection (null = all) and merged paths (null = whole). */
|
|
61
|
+
scope?: { collection: string | null; paths: string[] | null };
|
|
54
62
|
serverInstanceId: string;
|
|
55
63
|
}
|
|
56
64
|
|
|
65
|
+
export interface ReindexOptions {
|
|
66
|
+
gitPull: boolean;
|
|
67
|
+
runUpdateCmd: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What one reindex pass covers; `paths` null means the whole collection. */
|
|
71
|
+
export interface ReindexScope {
|
|
72
|
+
collection: string | null;
|
|
73
|
+
paths: string[] | null;
|
|
74
|
+
options: ReindexOptions;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A reindex request. `run` must honour the scope it is given: when requests
|
|
79
|
+
* merge, the pass runs the latest request's `run` over the merged scope.
|
|
80
|
+
*/
|
|
81
|
+
export interface ReindexRequest {
|
|
82
|
+
type: "sync" | "index";
|
|
83
|
+
collection: string | null;
|
|
84
|
+
paths: string[] | null;
|
|
85
|
+
options: ReindexOptions;
|
|
86
|
+
run: (scope: ReindexScope, signal: AbortSignal) => Promise<JobResult>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ReindexAccepted {
|
|
90
|
+
jobId: string;
|
|
91
|
+
status: "running" | "queued";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface PendingReindex {
|
|
95
|
+
job: JobRecord;
|
|
96
|
+
type: ReindexRequest["type"];
|
|
97
|
+
collection: string | null;
|
|
98
|
+
paths: Set<string> | null;
|
|
99
|
+
/** null while only path requests joined: options come from a whole request. */
|
|
100
|
+
options: ReindexOptions | null;
|
|
101
|
+
run: ReindexRequest["run"];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const NO_REINDEX_OPTIONS: ReindexOptions = {
|
|
105
|
+
gitPull: false,
|
|
106
|
+
runUpdateCmd: false,
|
|
107
|
+
};
|
|
108
|
+
|
|
57
109
|
export interface JobManagerOptions {
|
|
58
110
|
lockPath: string;
|
|
59
111
|
serverInstanceId: string;
|
|
@@ -63,10 +115,12 @@ export interface JobManagerOptions {
|
|
|
63
115
|
lockTimeoutMs?: number;
|
|
64
116
|
}
|
|
65
117
|
|
|
118
|
+
export type JobErrorCode = "LOCKED" | "JOB_CONFLICT" | "QUEUE_FULL";
|
|
119
|
+
|
|
66
120
|
export class JobError extends Error {
|
|
67
|
-
code:
|
|
121
|
+
code: JobErrorCode;
|
|
68
122
|
|
|
69
|
-
constructor(code:
|
|
123
|
+
constructor(code: JobErrorCode, message: string) {
|
|
70
124
|
super(message);
|
|
71
125
|
this.code = code;
|
|
72
126
|
this.name = "JobError";
|
|
@@ -86,6 +140,10 @@ export class JobManager {
|
|
|
86
140
|
#authorizationEpoch = "egress-epoch-uninitialized";
|
|
87
141
|
#accepting = true;
|
|
88
142
|
#releaseJobLocks = new Map<string, () => Promise<void>>();
|
|
143
|
+
/** Pending reindex jobs in arrival order. */
|
|
144
|
+
#pending: PendingReindex[] = [];
|
|
145
|
+
/** Context captured at construction; queued jobs start here, not in the previous job's. */
|
|
146
|
+
#rootContext = AsyncLocalStorage.snapshot();
|
|
89
147
|
|
|
90
148
|
constructor(options: JobManagerOptions) {
|
|
91
149
|
this.#lockPath = options.lockPath;
|
|
@@ -164,6 +222,89 @@ export class JobManager {
|
|
|
164
222
|
return this.#startTypedJobWithLock(type, fn, lock);
|
|
165
223
|
}
|
|
166
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Start a reindex now when nothing else is running or waiting; otherwise
|
|
227
|
+
* queue it. A request joins a waiting job for the same type and collection
|
|
228
|
+
* when that cannot change what the job is allowed to do: a path request
|
|
229
|
+
* joins any of them, and a whole-collection request joins a path-only job
|
|
230
|
+
* (setting its options) or a whole job with the same options. Joined
|
|
231
|
+
* requests share one job id, so any number of them cause at most one more
|
|
232
|
+
* pass. Throws `LOCKED` when another process holds the write lease at an
|
|
233
|
+
* immediate start, and `QUEUE_FULL` at the pending bound.
|
|
234
|
+
*/
|
|
235
|
+
async submitReindex(request: ReindexRequest): Promise<ReindexAccepted> {
|
|
236
|
+
assertInferenceActive();
|
|
237
|
+
this.#assertAccepting();
|
|
238
|
+
this.#cleanupExpiredJobs();
|
|
239
|
+
|
|
240
|
+
// Only join work accepted under the current egress policy: a stale entry
|
|
241
|
+
// fails at admission and must not swallow a freshly authorized request.
|
|
242
|
+
const pending = this.#pending.find(
|
|
243
|
+
(entry) =>
|
|
244
|
+
joins(entry, request) &&
|
|
245
|
+
this.#jobAuthorizationEpochs.get(entry.job.id) ===
|
|
246
|
+
this.#authorizationEpoch
|
|
247
|
+
);
|
|
248
|
+
if (pending) {
|
|
249
|
+
if (request.paths === null) {
|
|
250
|
+
pending.paths = null;
|
|
251
|
+
pending.options = request.options;
|
|
252
|
+
} else if (pending.paths) {
|
|
253
|
+
for (const path of request.paths) pending.paths.add(path);
|
|
254
|
+
// Too many files for one path pass: rescan the collection instead,
|
|
255
|
+
// with no git pull or update command, which no path request asked for.
|
|
256
|
+
if (pending.paths.size > MAX_REINDEX_PATHS) {
|
|
257
|
+
pending.paths = null;
|
|
258
|
+
pending.options = { gitPull: false, runUpdateCmd: false };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
pending.run = request.run;
|
|
262
|
+
pending.job.scope = scopeRecord(pending.collection, pending.paths);
|
|
263
|
+
return { jobId: pending.job.id, status: "queued" };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!this.#activeJobId && this.#pending.length === 0) {
|
|
267
|
+
const job = this.#newJob(request.type, "running");
|
|
268
|
+
job.scope = scopeRecord(request.collection, request.paths);
|
|
269
|
+
this.#activeJobId = job.id;
|
|
270
|
+
const lock = await acquireWriteLock(this.#lockPath, this.#lockTimeoutMs);
|
|
271
|
+
if (!lock || !this.#accepting) {
|
|
272
|
+
await lock?.release();
|
|
273
|
+
this.#jobs.delete(job.id);
|
|
274
|
+
this.#jobAuthorizationEpochs.delete(job.id);
|
|
275
|
+
this.#activeJobId = null;
|
|
276
|
+
this.#track(this.#drainPending());
|
|
277
|
+
this.#assertAccepting();
|
|
278
|
+
throw new JobError("LOCKED", MCP_ERRORS.LOCKED.message);
|
|
279
|
+
}
|
|
280
|
+
const scope: ReindexScope = {
|
|
281
|
+
collection: request.collection,
|
|
282
|
+
paths: request.paths,
|
|
283
|
+
options: request.options,
|
|
284
|
+
};
|
|
285
|
+
this.#launchTyped(job, (signal) => request.run(scope, signal), lock);
|
|
286
|
+
return { jobId: job.id, status: "running" };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (this.#pending.length >= MAX_PENDING_REINDEX_JOBS) {
|
|
290
|
+
throw new JobError(
|
|
291
|
+
"QUEUE_FULL",
|
|
292
|
+
`${MCP_ERRORS.QUEUE_FULL.message} (${MAX_PENDING_REINDEX_JOBS} waiting)`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const job = this.#newJob(request.type, "queued");
|
|
296
|
+
job.scope = scopeRecord(request.collection, request.paths);
|
|
297
|
+
this.#pending.push({
|
|
298
|
+
job,
|
|
299
|
+
type: request.type,
|
|
300
|
+
collection: request.collection,
|
|
301
|
+
paths: request.paths ? new Set(request.paths) : null,
|
|
302
|
+
options: request.paths ? null : request.options,
|
|
303
|
+
run: request.run,
|
|
304
|
+
});
|
|
305
|
+
return { jobId: job.id, status: "queued" };
|
|
306
|
+
}
|
|
307
|
+
|
|
167
308
|
getJob(jobId: string): JobRecord | undefined {
|
|
168
309
|
this.#cleanupExpiredJobs();
|
|
169
310
|
return this.#jobs.get(jobId);
|
|
@@ -184,7 +325,7 @@ export class JobManager {
|
|
|
184
325
|
let invalidated = 0;
|
|
185
326
|
for (const job of this.#jobs.values()) {
|
|
186
327
|
if (
|
|
187
|
-
job.status === "running" &&
|
|
328
|
+
(job.status === "running" || job.status === "queued") &&
|
|
188
329
|
this.#jobAuthorizationEpochs.get(job.id) !== epoch
|
|
189
330
|
) {
|
|
190
331
|
invalidated += 1;
|
|
@@ -202,6 +343,7 @@ export class JobManager {
|
|
|
202
343
|
}
|
|
203
344
|
|
|
204
345
|
clear(): void {
|
|
346
|
+
this.#pending = [];
|
|
205
347
|
this.#jobs.clear();
|
|
206
348
|
this.#jobAuthorizationEpochs.clear();
|
|
207
349
|
this.#activeJobId = null;
|
|
@@ -212,11 +354,11 @@ export class JobManager {
|
|
|
212
354
|
|
|
213
355
|
const jobs = Array.from(this.#jobs.values());
|
|
214
356
|
const active = jobs
|
|
215
|
-
.filter((job) => job
|
|
357
|
+
.filter((job) => isUnfinished(job))
|
|
216
358
|
.sort((a, b) => a.startedAt - b.startedAt);
|
|
217
359
|
|
|
218
360
|
const recent = jobs
|
|
219
|
-
.filter((job) => job
|
|
361
|
+
.filter((job) => !isUnfinished(job))
|
|
220
362
|
.sort((a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0))
|
|
221
363
|
.slice(0, limit);
|
|
222
364
|
|
|
@@ -238,6 +380,9 @@ export class JobManager {
|
|
|
238
380
|
|
|
239
381
|
stop(): void {
|
|
240
382
|
this.#accepting = false;
|
|
383
|
+
for (const { job } of this.#pending)
|
|
384
|
+
failJob(job, "Resident runtime is shutting down");
|
|
385
|
+
this.#pending = [];
|
|
241
386
|
}
|
|
242
387
|
|
|
243
388
|
cancel(): void {
|
|
@@ -303,6 +448,7 @@ export class JobManager {
|
|
|
303
448
|
this.#activeJobId = null;
|
|
304
449
|
await lock.release().catch(() => undefined);
|
|
305
450
|
this.#cleanupExpiredJobs();
|
|
451
|
+
await this.#drainPending();
|
|
306
452
|
}
|
|
307
453
|
}
|
|
308
454
|
|
|
@@ -340,28 +486,71 @@ export class JobManager {
|
|
|
340
486
|
fn: (signal: AbortSignal) => Promise<JobResult>,
|
|
341
487
|
lock: WriteLockHandle
|
|
342
488
|
): string {
|
|
343
|
-
const
|
|
344
|
-
|
|
489
|
+
const job = this.#newJob(type, "running");
|
|
490
|
+
this.#launchTyped(job, fn, lock);
|
|
491
|
+
return job.id;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Record a job and the authorization epoch it was accepted under. */
|
|
495
|
+
#newJob(type: JobType, status: "queued" | "running"): JobRecord {
|
|
345
496
|
const job: JobRecord = {
|
|
346
|
-
id:
|
|
497
|
+
id: crypto.randomUUID(),
|
|
347
498
|
type,
|
|
348
|
-
status
|
|
499
|
+
status,
|
|
349
500
|
startedAt: Date.now(),
|
|
350
501
|
serverInstanceId: this.#serverInstanceId,
|
|
351
502
|
};
|
|
503
|
+
this.#jobs.set(job.id, job);
|
|
504
|
+
this.#jobAuthorizationEpochs.set(job.id, this.#authorizationEpoch);
|
|
505
|
+
return job;
|
|
506
|
+
}
|
|
352
507
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
508
|
+
#launchTyped(
|
|
509
|
+
job: JobRecord,
|
|
510
|
+
fn: (signal: AbortSignal) => Promise<JobResult>,
|
|
511
|
+
lock: WriteLockHandle
|
|
512
|
+
): void {
|
|
513
|
+
const ownedLock = this.#ownLock(job.id, lock);
|
|
514
|
+
const controller = new AbortController();
|
|
515
|
+
job.status = "running";
|
|
516
|
+
job.startedAt = Date.now();
|
|
517
|
+
this.#jobControllers.set(job.id, controller);
|
|
518
|
+
this.#activeJobId = job.id;
|
|
357
519
|
|
|
358
520
|
const jobPromise = withOwnedInferenceScope(
|
|
359
|
-
{ signal:
|
|
521
|
+
{ signal: controller.signal },
|
|
360
522
|
() => withBackgroundInference(() => this.#runTypedJob(job, fn, ownedLock))
|
|
361
523
|
);
|
|
362
524
|
this.#track(jobPromise);
|
|
525
|
+
}
|
|
363
526
|
|
|
364
|
-
|
|
527
|
+
/** Start waiting reindex jobs, in arrival order, while nothing is running. */
|
|
528
|
+
async #drainPending(): Promise<void> {
|
|
529
|
+
while (!this.#activeJobId) {
|
|
530
|
+
const entry = this.#pending.shift();
|
|
531
|
+
if (!entry) return;
|
|
532
|
+
this.#activeJobId = entry.job.id;
|
|
533
|
+
const lock = await acquireWriteLock(this.#lockPath, this.#lockTimeoutMs);
|
|
534
|
+
if (!lock || !this.#accepting) {
|
|
535
|
+
await lock?.release();
|
|
536
|
+
this.#activeJobId = null;
|
|
537
|
+
failJob(
|
|
538
|
+
entry.job,
|
|
539
|
+
this.#accepting
|
|
540
|
+
? `${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`
|
|
541
|
+
: "Resident runtime is shutting down"
|
|
542
|
+
);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const scope: ReindexScope = {
|
|
546
|
+
collection: entry.collection,
|
|
547
|
+
paths: entry.paths ? [...entry.paths].sort() : null,
|
|
548
|
+
options: entry.options ?? NO_REINDEX_OPTIONS,
|
|
549
|
+
};
|
|
550
|
+
this.#rootContext(() =>
|
|
551
|
+
this.#launchTyped(entry.job, (signal) => entry.run(scope, signal), lock)
|
|
552
|
+
);
|
|
553
|
+
}
|
|
365
554
|
}
|
|
366
555
|
|
|
367
556
|
async #runTypedJob(
|
|
@@ -376,6 +565,7 @@ export class JobManager {
|
|
|
376
565
|
assertInferenceActive();
|
|
377
566
|
job.status = "completed";
|
|
378
567
|
job.typedResult = result;
|
|
568
|
+
if (result.kind === "sync") job.result = result.value;
|
|
379
569
|
} catch (e) {
|
|
380
570
|
job.status = "failed";
|
|
381
571
|
job.error = e instanceof Error ? e.message : String(e);
|
|
@@ -386,12 +576,13 @@ export class JobManager {
|
|
|
386
576
|
this.#activeJobId = null;
|
|
387
577
|
await lock.release().catch(() => undefined);
|
|
388
578
|
this.#cleanupExpiredJobs();
|
|
579
|
+
await this.#drainPending();
|
|
389
580
|
}
|
|
390
581
|
}
|
|
391
582
|
|
|
392
583
|
#cleanupExpiredJobs(now: number = Date.now()): void {
|
|
393
584
|
for (const [id, job] of this.#jobs) {
|
|
394
|
-
if (job
|
|
585
|
+
if (isUnfinished(job)) {
|
|
395
586
|
continue;
|
|
396
587
|
}
|
|
397
588
|
const completedAt = job.completedAt ?? job.startedAt;
|
|
@@ -402,7 +593,7 @@ export class JobManager {
|
|
|
402
593
|
}
|
|
403
594
|
|
|
404
595
|
const completed = Array.from(this.#jobs.values())
|
|
405
|
-
.filter((job) => job
|
|
596
|
+
.filter((job) => !isUnfinished(job))
|
|
406
597
|
.sort((a, b) => (a.completedAt ?? 0) - (b.completedAt ?? 0));
|
|
407
598
|
|
|
408
599
|
if (completed.length <= JOB_MAX_RECENT) {
|
|
@@ -442,3 +633,31 @@ export class JobManager {
|
|
|
442
633
|
);
|
|
443
634
|
}
|
|
444
635
|
}
|
|
636
|
+
|
|
637
|
+
function joins(entry: PendingReindex, request: ReindexRequest): boolean {
|
|
638
|
+
if (entry.type !== request.type || entry.collection !== request.collection)
|
|
639
|
+
return false;
|
|
640
|
+
if (request.paths !== null) return true;
|
|
641
|
+
if (entry.options === null) return true;
|
|
642
|
+
return (
|
|
643
|
+
entry.paths === null &&
|
|
644
|
+
entry.options.gitPull === request.options.gitPull &&
|
|
645
|
+
entry.options.runUpdateCmd === request.options.runUpdateCmd
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function scopeRecord(
|
|
650
|
+
collection: string | null,
|
|
651
|
+
paths: Iterable<string> | null
|
|
652
|
+
): NonNullable<JobRecord["scope"]> {
|
|
653
|
+
return { collection, paths: paths ? [...paths].sort() : null };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const isUnfinished = (job: JobRecord): boolean =>
|
|
657
|
+
job.status === "running" || job.status === "queued";
|
|
658
|
+
|
|
659
|
+
function failJob(job: JobRecord, error: string): void {
|
|
660
|
+
job.status = "failed";
|
|
661
|
+
job.error = error;
|
|
662
|
+
job.completedAt = Date.now();
|
|
663
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate caller-supplied paths for a path-level reindex.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/reindex-paths
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:fs/promises realpath, lstat/stat (symlinks, directories) and node:path
|
|
8
|
+
// have no Bun equivalent.
|
|
9
|
+
import { lstat, readlink, realpath, stat } from "node:fs/promises";
|
|
10
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type { Collection } from "../config/types";
|
|
13
|
+
|
|
14
|
+
import { collectionToWalkConfig } from "../ingestion/types";
|
|
15
|
+
import { matchesWalkPath } from "../ingestion/walker";
|
|
16
|
+
import { MCP_ERRORS } from "./errors";
|
|
17
|
+
|
|
18
|
+
const escapes = (rel: string): boolean =>
|
|
19
|
+
rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
20
|
+
|
|
21
|
+
const toPosix = (rel: string): string => rel.split(sep).join("/");
|
|
22
|
+
|
|
23
|
+
const missing = (error: unknown): boolean =>
|
|
24
|
+
error !== null &&
|
|
25
|
+
typeof error === "object" &&
|
|
26
|
+
"code" in error &&
|
|
27
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
28
|
+
|
|
29
|
+
const MAX_SYMLINK_HOPS = 32;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Most paths one request may list, and most a queued job keeps after merging.
|
|
33
|
+
* Past it, reindexing the whole collection is the better call.
|
|
34
|
+
*/
|
|
35
|
+
export const MAX_REINDEX_PATHS = 256;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Realpath of `path`, or of its nearest existing ancestor below `root`. A
|
|
39
|
+
* dangling symlink on the way resolves to where it points, so a missing file
|
|
40
|
+
* cannot leave the root through a link whose target does not exist.
|
|
41
|
+
*/
|
|
42
|
+
async function nearestRealpath(
|
|
43
|
+
path: string,
|
|
44
|
+
root: string,
|
|
45
|
+
hops = 0
|
|
46
|
+
): Promise<string | null> {
|
|
47
|
+
let current = path;
|
|
48
|
+
for (;;) {
|
|
49
|
+
try {
|
|
50
|
+
return await realpath(current);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (!missing(error)) throw error;
|
|
53
|
+
}
|
|
54
|
+
const isLink = await lstat(current).then(
|
|
55
|
+
(stats) => stats.isSymbolicLink(),
|
|
56
|
+
() => false
|
|
57
|
+
);
|
|
58
|
+
if (isLink) {
|
|
59
|
+
const target = resolve(dirname(current), await readlink(current));
|
|
60
|
+
if (hops >= MAX_SYMLINK_HOPS) return target;
|
|
61
|
+
return (await nearestRealpath(target, root, hops + 1)) ?? target;
|
|
62
|
+
}
|
|
63
|
+
const parent = dirname(current);
|
|
64
|
+
if (parent === current || relative(root, parent) === "") return null;
|
|
65
|
+
current = parent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve paths (relative to the collection root, or absolute inside it) to
|
|
71
|
+
* deduplicated collection-relative POSIX paths. A path that no longer exists
|
|
72
|
+
* is kept so the sync can mark it inactive. Throws `INVALID_INPUT: ...` for an
|
|
73
|
+
* empty list or more than `MAX_REINDEX_PATHS` paths, and `INVALID_PATH: ...`
|
|
74
|
+
* for a directory or a path outside the root, including one that leaves it
|
|
75
|
+
* through a symlink.
|
|
76
|
+
*/
|
|
77
|
+
export async function resolveReindexPaths(
|
|
78
|
+
collection: Collection,
|
|
79
|
+
paths: readonly unknown[]
|
|
80
|
+
): Promise<string[]> {
|
|
81
|
+
if (paths.length === 0) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: paths must list at least one path`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (paths.length > MAX_REINDEX_PATHS) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: paths lists ${paths.length} paths (at most ${MAX_REINDEX_PATHS}); reindex the whole collection instead`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const root = resolve(collection.path);
|
|
92
|
+
let realRoot: string;
|
|
93
|
+
try {
|
|
94
|
+
realRoot = await realpath(root);
|
|
95
|
+
} catch {
|
|
96
|
+
realRoot = root;
|
|
97
|
+
}
|
|
98
|
+
const resolved = new Set<string>();
|
|
99
|
+
for (const path of paths) {
|
|
100
|
+
if (typeof path !== "string" || path.trim() === "") {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: paths must be non-empty strings`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const absolute = resolve(root, path);
|
|
106
|
+
const rel = relative(root, absolute);
|
|
107
|
+
if (escapes(rel)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`${MCP_ERRORS.INVALID_PATH.code}: ${path} is outside collection ${collection.name}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
// A missing file is checked through its nearest existing folder, so a
|
|
113
|
+
// deletion path cannot leave the root through a symlinked parent.
|
|
114
|
+
const real = await nearestRealpath(absolute, root);
|
|
115
|
+
if (
|
|
116
|
+
real !== null &&
|
|
117
|
+
escapes(relative(realRoot, real)) &&
|
|
118
|
+
real !== realRoot
|
|
119
|
+
) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`${MCP_ERRORS.INVALID_PATH.code}: ${path} resolves outside collection ${collection.name}`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const isDirectory = await stat(absolute).then(
|
|
125
|
+
(stats) => stats.isDirectory(),
|
|
126
|
+
() => false
|
|
127
|
+
);
|
|
128
|
+
if (isDirectory) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${MCP_ERRORS.INVALID_PATH.code}: ${path} is a directory; list the files in it, or reindex the whole collection`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
// A listed path obeys the collection's own pattern, include and exclude
|
|
134
|
+
// rules: an excluded file must not become searchable by naming it.
|
|
135
|
+
if (!matchesWalkPath(toPosix(rel), collectionToWalkConfig(collection, 0))) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`${MCP_ERRORS.INVALID_PATH.code}: ${path} is not part of collection ${collection.name} (its pattern, include or exclude rules leave it out)`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
resolved.add(toPosix(rel));
|
|
141
|
+
}
|
|
142
|
+
return [...resolved];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve an optional path list for a reindex request. Paths need a single
|
|
147
|
+
* collection and apply to a plain sync: git pull and update commands act on
|
|
148
|
+
* the whole collection, so they cannot be combined with paths.
|
|
149
|
+
*/
|
|
150
|
+
export async function resolveReindexScope(
|
|
151
|
+
collection: Collection | null,
|
|
152
|
+
paths: readonly unknown[] | undefined,
|
|
153
|
+
options: { gitPull?: boolean; runUpdateCmd?: boolean } = {}
|
|
154
|
+
): Promise<string[] | null> {
|
|
155
|
+
if (paths === undefined) return null;
|
|
156
|
+
if (!collection) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: paths require a collection`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (options.gitPull || options.runUpdateCmd) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: paths cannot be combined with gitPull or an update command`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return resolveReindexPaths(collection, paths);
|
|
167
|
+
}
|
|
@@ -117,6 +117,7 @@ export const retrievalTraceFilters = (
|
|
|
117
117
|
limit: options.limit,
|
|
118
118
|
minScore: options.minScore,
|
|
119
119
|
collection: options.collection,
|
|
120
|
+
collections: options.collections,
|
|
120
121
|
lang: options.lang,
|
|
121
122
|
full: options.full,
|
|
122
123
|
lineNumbers: options.lineNumbers,
|