@gmickel/gno 2.12.1 → 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.1.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 +97 -2
- 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 +264 -47
- 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.1.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 {
|
|
@@ -287,20 +432,12 @@ export class JobManager {
|
|
|
287
432
|
lock: { release: () => Promise<void> }
|
|
288
433
|
): Promise<void> {
|
|
289
434
|
try {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
job.status = "completed";
|
|
297
|
-
job.result = result;
|
|
298
|
-
} catch (e) {
|
|
299
|
-
job.status = "failed";
|
|
300
|
-
job.error = e instanceof Error ? e.message : String(e);
|
|
301
|
-
} finally {
|
|
302
|
-
release();
|
|
303
|
-
}
|
|
435
|
+
await this.#admit(job);
|
|
436
|
+
assertInferenceActive();
|
|
437
|
+
const result = await fn(this.#jobControllers.get(job.id)!.signal);
|
|
438
|
+
assertInferenceActive();
|
|
439
|
+
job.status = "completed";
|
|
440
|
+
job.result = result;
|
|
304
441
|
} catch (e) {
|
|
305
442
|
job.status = "failed";
|
|
306
443
|
job.error = e instanceof Error ? e.message : String(e);
|
|
@@ -311,6 +448,7 @@ export class JobManager {
|
|
|
311
448
|
this.#activeJobId = null;
|
|
312
449
|
await lock.release().catch(() => undefined);
|
|
313
450
|
this.#cleanupExpiredJobs();
|
|
451
|
+
await this.#drainPending();
|
|
314
452
|
}
|
|
315
453
|
}
|
|
316
454
|
|
|
@@ -348,28 +486,71 @@ export class JobManager {
|
|
|
348
486
|
fn: (signal: AbortSignal) => Promise<JobResult>,
|
|
349
487
|
lock: WriteLockHandle
|
|
350
488
|
): string {
|
|
351
|
-
const
|
|
352
|
-
|
|
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 {
|
|
353
496
|
const job: JobRecord = {
|
|
354
|
-
id:
|
|
497
|
+
id: crypto.randomUUID(),
|
|
355
498
|
type,
|
|
356
|
-
status
|
|
499
|
+
status,
|
|
357
500
|
startedAt: Date.now(),
|
|
358
501
|
serverInstanceId: this.#serverInstanceId,
|
|
359
502
|
};
|
|
503
|
+
this.#jobs.set(job.id, job);
|
|
504
|
+
this.#jobAuthorizationEpochs.set(job.id, this.#authorizationEpoch);
|
|
505
|
+
return job;
|
|
506
|
+
}
|
|
360
507
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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;
|
|
365
519
|
|
|
366
520
|
const jobPromise = withOwnedInferenceScope(
|
|
367
|
-
{ signal:
|
|
521
|
+
{ signal: controller.signal },
|
|
368
522
|
() => withBackgroundInference(() => this.#runTypedJob(job, fn, ownedLock))
|
|
369
523
|
);
|
|
370
524
|
this.#track(jobPromise);
|
|
525
|
+
}
|
|
371
526
|
|
|
372
|
-
|
|
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
|
+
}
|
|
373
554
|
}
|
|
374
555
|
|
|
375
556
|
async #runTypedJob(
|
|
@@ -378,20 +559,13 @@ export class JobManager {
|
|
|
378
559
|
lock: { release: () => Promise<void> }
|
|
379
560
|
): Promise<void> {
|
|
380
561
|
try {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
job.typedResult = result;
|
|
389
|
-
} catch (e) {
|
|
390
|
-
job.status = "failed";
|
|
391
|
-
job.error = e instanceof Error ? e.message : String(e);
|
|
392
|
-
} finally {
|
|
393
|
-
release();
|
|
394
|
-
}
|
|
562
|
+
await this.#admit(job);
|
|
563
|
+
assertInferenceActive();
|
|
564
|
+
const result = await fn(this.#jobControllers.get(job.id)!.signal);
|
|
565
|
+
assertInferenceActive();
|
|
566
|
+
job.status = "completed";
|
|
567
|
+
job.typedResult = result;
|
|
568
|
+
if (result.kind === "sync") job.result = result.value;
|
|
395
569
|
} catch (e) {
|
|
396
570
|
job.status = "failed";
|
|
397
571
|
job.error = e instanceof Error ? e.message : String(e);
|
|
@@ -402,12 +576,13 @@ export class JobManager {
|
|
|
402
576
|
this.#activeJobId = null;
|
|
403
577
|
await lock.release().catch(() => undefined);
|
|
404
578
|
this.#cleanupExpiredJobs();
|
|
579
|
+
await this.#drainPending();
|
|
405
580
|
}
|
|
406
581
|
}
|
|
407
582
|
|
|
408
583
|
#cleanupExpiredJobs(now: number = Date.now()): void {
|
|
409
584
|
for (const [id, job] of this.#jobs) {
|
|
410
|
-
if (job
|
|
585
|
+
if (isUnfinished(job)) {
|
|
411
586
|
continue;
|
|
412
587
|
}
|
|
413
588
|
const completedAt = job.completedAt ?? job.startedAt;
|
|
@@ -418,7 +593,7 @@ export class JobManager {
|
|
|
418
593
|
}
|
|
419
594
|
|
|
420
595
|
const completed = Array.from(this.#jobs.values())
|
|
421
|
-
.filter((job) => job
|
|
596
|
+
.filter((job) => !isUnfinished(job))
|
|
422
597
|
.sort((a, b) => (a.completedAt ?? 0) - (b.completedAt ?? 0));
|
|
423
598
|
|
|
424
599
|
if (completed.length <= JOB_MAX_RECENT) {
|
|
@@ -435,6 +610,20 @@ export class JobManager {
|
|
|
435
610
|
}
|
|
436
611
|
}
|
|
437
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Wait for the tool call in progress, then re-check authorization. The
|
|
615
|
+
* mutex is released before the job's work runs, so reads are not held
|
|
616
|
+
* behind a long sync or embed; writers stay serialized by the write lease.
|
|
617
|
+
*/
|
|
618
|
+
async #admit(job: JobRecord): Promise<void> {
|
|
619
|
+
const release = await this.#toolMutex.acquire();
|
|
620
|
+
try {
|
|
621
|
+
this.#assertAuthorizationEpoch(job);
|
|
622
|
+
} finally {
|
|
623
|
+
release();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
438
627
|
#assertAuthorizationEpoch(job: JobRecord): void {
|
|
439
628
|
if (this.#jobAuthorizationEpochs.get(job.id) === this.#authorizationEpoch) {
|
|
440
629
|
return;
|
|
@@ -444,3 +633,31 @@ export class JobManager {
|
|
|
444
633
|
);
|
|
445
634
|
}
|
|
446
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,
|