@skyphusion/search-mcp 0.1.2 → 0.2.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 +25 -1
- package/package.json +4 -4
- package/scripts/sync-runner.mjs +210 -33
package/README.md
CHANGED
|
@@ -108,7 +108,31 @@ npm run sync:run # isolated clone root, sync all targets, optional reindex
|
|
|
108
108
|
|
|
109
109
|
The sync remaps non-native extensions (`.ts`, `.tsx`, extensionless `Dockerfile`, `.service`, etc.) to `.txt` keys so AI Search indexes them. See `scripts/sync-ingest.mjs`.
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
### Reindex dispatch
|
|
112
|
+
|
|
113
|
+
AI Search rejects a new reindex job for two distinct reasons, and `sync-runner` clears both
|
|
114
|
+
before dispatching:
|
|
115
|
+
|
|
116
|
+
1. **A job is in flight.** Firing anyway does not queue behind it; Cloudflare ends the running
|
|
117
|
+
job with `end_reason: "new_job_has_started"` and restarts. So we wait for `ended_at`.
|
|
118
|
+
2. **The post-job cooldown.** Even once a job ends, a new one is refused for a cooldown window
|
|
119
|
+
with `sync_in_cooldown [code: 7020]`. Waiting for the job to end is necessary but not
|
|
120
|
+
sufficient, so we retry until it clears.
|
|
121
|
+
|
|
122
|
+
Waiting (rather than skipping) means the job we start always lands strictly after our own
|
|
123
|
+
upload, so it sees every object this run wrote. Merge bursts still coalesce: a waiting run holds
|
|
124
|
+
the workflow concurrency group, and GitHub keeps only the newest queued run, so the runs behind
|
|
125
|
+
it collapse instead of each firing their own reindex.
|
|
126
|
+
|
|
127
|
+
Each wait has its own budget (10 min in-flight, 10 min cooldown) rather than one shared
|
|
128
|
+
deadline, since the two are additive on a perfectly healthy path: a run can wait minutes for an
|
|
129
|
+
in-flight reindex and then still owe a cooldown wait.
|
|
130
|
+
|
|
131
|
+
The measured cooldown is short (rejected at 10s after a job ends, accepted at 32s), so the
|
|
132
|
+
budgets are far larger than they need to be today. That is deliberate. The measurement is an
|
|
133
|
+
observation, not a contract, and a budget sized to it would turn ordinary upstream variance into
|
|
134
|
+
red builds. If a budget is exhausted the run fails loudly and says what it means: the R2 corpus
|
|
135
|
+
uploaded fine, nothing is lost, the index lags until the next sync or the daily backstop.
|
|
112
136
|
|
|
113
137
|
## Ask widget
|
|
114
138
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyphusion/search-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Bearer-gated MCP server and streaming query Worker over Cloudflare AI Search, plus a git-to-R2 corpus sync toolkit.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Conrad Rockenhaus <conrad@skyphusion.org>",
|
|
@@ -66,10 +66,10 @@
|
|
|
66
66
|
"@aws-sdk/client-s3": "^3.888.0"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@cloudflare/workers-types": "^
|
|
69
|
+
"@cloudflare/workers-types": "^5.20260713.1",
|
|
70
70
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
71
|
-
"typescript": "^
|
|
71
|
+
"typescript": "^7.0.2",
|
|
72
72
|
"vitest": "^4.1.10",
|
|
73
|
-
"wrangler": "^4.
|
|
73
|
+
"wrangler": "^4.110.0"
|
|
74
74
|
}
|
|
75
75
|
}
|
package/scripts/sync-runner.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// node scripts/sync-runner.mjs corpus # one target
|
|
7
7
|
// node scripts/sync-runner.mjs --dry-run
|
|
8
8
|
// node scripts/sync-runner.mjs --no-reindex
|
|
9
|
+
// node scripts/sync-runner.mjs --reindex-only # skip clone+sync, dispatch reindex only
|
|
9
10
|
// node scripts/sync-runner.mjs --no-github-verify
|
|
10
11
|
//
|
|
11
12
|
// Env:
|
|
@@ -31,9 +32,11 @@ export function parseArgs(argv, allTargetNames = []) {
|
|
|
31
32
|
const flags = new Set(argv.filter((a) => a.startsWith("--")));
|
|
32
33
|
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
33
34
|
const requested = positional[0];
|
|
35
|
+
const reindexOnly = flags.has("--reindex-only");
|
|
34
36
|
return {
|
|
35
37
|
targets: requested ? [requested] : [...allTargetNames],
|
|
36
38
|
dryRun: flags.has("--dry-run"),
|
|
39
|
+
sync: !reindexOnly,
|
|
37
40
|
reindex: !flags.has("--no-reindex") && !flags.has("--dry-run"),
|
|
38
41
|
passThrough: [...flags].filter((f) => f === "--dry-run" || f === "--no-github-verify"),
|
|
39
42
|
};
|
|
@@ -89,8 +92,10 @@ function git(args, opts = {}) {
|
|
|
89
92
|
function syncRepoTree(org, repo, root, authArgs) {
|
|
90
93
|
const dir = join(root, repo);
|
|
91
94
|
const url = repoUrl(org, repo);
|
|
95
|
+
let mode = "fetch";
|
|
92
96
|
if (!existsSync(join(dir, ".git"))) {
|
|
93
97
|
git([...authArgs, "clone", "--quiet", url, dir]);
|
|
98
|
+
mode = "clone";
|
|
94
99
|
} else {
|
|
95
100
|
git([...authArgs, "-C", dir, "remote", "set-url", "origin", url]);
|
|
96
101
|
git([...authArgs, "-C", dir, "fetch", "--prune", "--quiet", "origin"]);
|
|
@@ -104,42 +109,212 @@ function syncRepoTree(org, repo, root, authArgs) {
|
|
|
104
109
|
}
|
|
105
110
|
git(["-C", dir, "reset", "--hard", "--quiet", head]);
|
|
106
111
|
git(["-C", dir, "clean", "-fdq"]);
|
|
112
|
+
return mode;
|
|
107
113
|
}
|
|
108
114
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
/**
|
|
116
|
+
* One dispatch attempt. stderr is PIPED rather than inherited: wrangler reports
|
|
117
|
+
* `sync_in_cooldown [code: 7020]` on stderr, and with stdio "inherit" that text never reaches
|
|
118
|
+
* the thrown error, so the retry logic could not tell a cooldown from a real failure. We echo
|
|
119
|
+
* the captured output so the run log is unchanged.
|
|
120
|
+
*/
|
|
121
|
+
function reindexOnce(instance) {
|
|
122
|
+
try {
|
|
123
|
+
const out = execFileSync(
|
|
124
|
+
"npx",
|
|
125
|
+
["wrangler", "ai-search", "jobs", "create", instance, "--json"],
|
|
126
|
+
{ cwd: REPO, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
127
|
+
);
|
|
128
|
+
if (out) process.stdout.write(out);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (err.stdout) process.stdout.write(err.stdout);
|
|
131
|
+
if (err.stderr) process.stderr.write(err.stderr);
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* True when any reindex job for the instance is still running. AI Search reports an
|
|
138
|
+
* unfinished job as `ended_at: null`.
|
|
139
|
+
*
|
|
140
|
+
* Firing a job while one is in flight does not queue it: Cloudflare ends the running job
|
|
141
|
+
* with `end_reason: "new_job_has_started"` and starts over. On the biggest corpus the
|
|
142
|
+
* reindex outlives the sync that triggers it, so an unguarded dispatch during a merge burst
|
|
143
|
+
* restarts the index pass repeatedly and it never settles (#12).
|
|
144
|
+
*/
|
|
145
|
+
export function jobInFlight(jobs) {
|
|
146
|
+
return (jobs || []).some((job) => !job.ended_at);
|
|
114
147
|
}
|
|
115
148
|
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
149
|
+
function listJobs(instance) {
|
|
150
|
+
const out = execFileSync(
|
|
151
|
+
"npx",
|
|
152
|
+
["wrangler", "ai-search", "jobs", "list", instance, "--json"],
|
|
153
|
+
{ cwd: REPO, encoding: "utf8" },
|
|
154
|
+
);
|
|
155
|
+
return JSON.parse(out);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Two independent budgets, deliberately NOT one shared deadline: the phases are additive on a
|
|
159
|
+
// healthy path (wait out an in-flight reindex, ~6 min on the biggest corpus, and THEN still owe
|
|
160
|
+
// a cooldown wait), so one shared bound would fail a run that did nothing wrong.
|
|
161
|
+
//
|
|
162
|
+
// Both budgets are far larger than the measured cooldown (rejected 10s after a job ends,
|
|
163
|
+
// accepted at 32s; 2026-07-16). That is intentional. The measurement is an observation, not a
|
|
164
|
+
// contract Cloudflare owes us, and it may move with corpus size or load. Sizing to today's
|
|
165
|
+
// number would convert ordinary upstream variance into red builds; sizing in minutes costs
|
|
166
|
+
// nothing when the cooldown is short, because the retry clears and the run moves on.
|
|
167
|
+
export const REINDEX_INFLIGHT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
168
|
+
export const REINDEX_COOLDOWN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
169
|
+
export const REINDEX_POLL_MS = 15 * 1000;
|
|
119
170
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
171
|
+
/**
|
|
172
|
+
* AI Search refuses a new job for a cooldown window after the previous one ENDS, distinct from
|
|
173
|
+
* the job being in flight. Measured 2026-07-16: rejected 10s after a job ended, accepted at
|
|
174
|
+
* 32s. Waiting for `ended_at` alone is necessary but not sufficient.
|
|
175
|
+
*/
|
|
176
|
+
export function isCooldownError(err) {
|
|
177
|
+
const text = `${err?.stdout || ""}${err?.stderr || ""}${err?.message || ""}`;
|
|
178
|
+
return /sync_in_cooldown|code:\s*7020/.test(text);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Block until no reindex job is in flight for the instance, so our dispatch lands strictly
|
|
183
|
+
* after the running job finishes instead of superseding it.
|
|
184
|
+
*
|
|
185
|
+
* On timeout we dispatch ANYWAY and say so loudly. A job still running past the bound (well
|
|
186
|
+
* over the ~6 min a full internal reindex takes) is itself anomalous, and superseding it is
|
|
187
|
+
* the correct recovery: the replacement job sees every object currently in R2. The failure
|
|
188
|
+
* mode we want is "one supersession in a pathological case", never "the corpus quietly goes
|
|
189
|
+
* unindexed", which is the class of bug that a trailing-pass design introduced.
|
|
190
|
+
*/
|
|
191
|
+
export async function awaitReindexSlot(instance, deps) {
|
|
192
|
+
const {
|
|
193
|
+
listJobs,
|
|
194
|
+
sleep,
|
|
195
|
+
now = () => Date.now(),
|
|
196
|
+
timeoutMs = REINDEX_INFLIGHT_TIMEOUT_MS,
|
|
197
|
+
pollMs = REINDEX_POLL_MS,
|
|
198
|
+
log = console.log,
|
|
199
|
+
} = deps;
|
|
200
|
+
const deadline = now() + timeoutMs;
|
|
201
|
+
let waited = false;
|
|
202
|
+
while (jobInFlight(listJobs(instance))) {
|
|
203
|
+
if (now() >= deadline) {
|
|
204
|
+
log(
|
|
205
|
+
` !! reindex for ${instance} still in flight after ${Math.round(timeoutMs / 1000)}s; ` +
|
|
206
|
+
"dispatching anyway and superseding it. A job running this long is anomalous; " +
|
|
207
|
+
"the replacement job reindexes the full corpus, so this is safe but worth a look.",
|
|
208
|
+
);
|
|
209
|
+
return { waited, timedOut: true };
|
|
210
|
+
}
|
|
211
|
+
if (!waited) {
|
|
212
|
+
log(` reindex in flight for ${instance}; waiting for it to finish before dispatching.`);
|
|
213
|
+
}
|
|
214
|
+
waited = true;
|
|
215
|
+
await sleep(pollMs);
|
|
216
|
+
}
|
|
217
|
+
return { waited, timedOut: false };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Dispatch a reindex, retrying while AI Search reports its post-job cooldown.
|
|
222
|
+
*
|
|
223
|
+
* Cooldown is transient and short (measured well under the budget), so the routine burst case
|
|
224
|
+
* clears here and never goes red. Exhausting the budget therefore means something genuinely
|
|
225
|
+
* anomalous upstream, and we fail loud rather than exit green: a green run while indexing is
|
|
226
|
+
* actually stalled is the work-blind failure mode this whole issue exists to remove. The
|
|
227
|
+
* message carries the honest blast radius so the red is actionable, not alarming.
|
|
228
|
+
*/
|
|
229
|
+
export async function dispatchWithCooldownRetry(instance, deps) {
|
|
230
|
+
const {
|
|
231
|
+
runReindexOnce,
|
|
232
|
+
sleep,
|
|
233
|
+
now = () => Date.now(),
|
|
234
|
+
timeoutMs = REINDEX_COOLDOWN_TIMEOUT_MS,
|
|
235
|
+
pollMs = REINDEX_POLL_MS,
|
|
236
|
+
log = console.log,
|
|
237
|
+
} = deps;
|
|
238
|
+
const deadline = now() + timeoutMs;
|
|
239
|
+
let announced = false;
|
|
240
|
+
for (;;) {
|
|
123
241
|
try {
|
|
124
|
-
|
|
125
|
-
|
|
242
|
+
runReindexOnce(instance);
|
|
243
|
+
return { retried: announced };
|
|
126
244
|
} catch (err) {
|
|
127
|
-
|
|
128
|
-
|
|
245
|
+
if (!isCooldownError(err)) throw err;
|
|
246
|
+
if (now() >= deadline) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`reindex for ${instance} blocked by AI Search cooldown for longer than ` +
|
|
249
|
+
`${Math.round(timeoutMs / 1000)}s. The R2 corpus uploaded OK and no data is lost; ` +
|
|
250
|
+
"the index lags until the next sync or the daily backstop. A cooldown this " +
|
|
251
|
+
"persistent is upstream and anomalous, so this run fails loudly on purpose.",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (!announced) {
|
|
255
|
+
log(` ${instance} is in AI Search cooldown; retrying until it clears.`);
|
|
256
|
+
announced = true;
|
|
257
|
+
}
|
|
258
|
+
await sleep(pollMs);
|
|
129
259
|
}
|
|
130
260
|
}
|
|
261
|
+
}
|
|
131
262
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
263
|
+
/**
|
|
264
|
+
* The real dependencies main() hands to awaitReindexSlot.
|
|
265
|
+
*
|
|
266
|
+
* Exported and built here on purpose. Every other seam in this file is injected, so a unit
|
|
267
|
+
* suite full of stubs proves nothing about the production wiring: an undefined symbol here
|
|
268
|
+
* rides a green suite and a clean `node --check` all the way to prod, then throws
|
|
269
|
+
* ReferenceError on the first real dispatch. Constructing this object evaluates each binding,
|
|
270
|
+
* so the wiring test fails loudly if one of them stops existing.
|
|
271
|
+
*/
|
|
272
|
+
export function productionReindexDeps() {
|
|
273
|
+
return {
|
|
274
|
+
listJobs,
|
|
275
|
+
runReindexOnce: reindexOnce,
|
|
276
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
139
279
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
280
|
+
export async function run(plan, deps) {
|
|
281
|
+
const { repos, targets, instances, reindex, sync = true } = plan;
|
|
282
|
+
const {
|
|
283
|
+
cloneTree,
|
|
284
|
+
runSync,
|
|
285
|
+
runReindex,
|
|
286
|
+
awaitSlot,
|
|
287
|
+
log = console.log,
|
|
288
|
+
error = console.error,
|
|
289
|
+
} = deps;
|
|
290
|
+
|
|
291
|
+
if (sync) {
|
|
292
|
+
log(`Refreshing ${repos.length} repo tree(s) ...`);
|
|
293
|
+
const failedClones = [];
|
|
294
|
+
for (const repo of repos) {
|
|
295
|
+
try {
|
|
296
|
+
const mode = cloneTree(repo);
|
|
297
|
+
log(` ok ${repo} (${mode})`);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
failedClones.push(repo);
|
|
300
|
+
error(` !! ${repo}: git failed (${describeExit(err)})`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (failedClones.length) {
|
|
305
|
+
error(
|
|
306
|
+
`BLOCKER: ${failedClones.length} clone/fetch failure(s) [${failedClones.join(", ")}]; ` +
|
|
307
|
+
"aborting before sync so no repo corpus is pruned or synced stale.",
|
|
308
|
+
);
|
|
309
|
+
return { exitCode: 1, synced: false };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const target of targets) {
|
|
313
|
+
log(`\n=== sync ${target} ===`);
|
|
314
|
+
runSync(target);
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
log("Reindex-only: skipping clone and sync.");
|
|
143
318
|
}
|
|
144
319
|
|
|
145
320
|
const failedReindex = [];
|
|
@@ -147,7 +322,8 @@ export function run(plan, deps) {
|
|
|
147
322
|
for (const instance of instances) {
|
|
148
323
|
log(`\n=== reindex ${instance} ===`);
|
|
149
324
|
try {
|
|
150
|
-
|
|
325
|
+
if (awaitSlot) await awaitSlot(instance);
|
|
326
|
+
await runReindex(instance);
|
|
151
327
|
} catch (err) {
|
|
152
328
|
failedReindex.push(instance);
|
|
153
329
|
error(` !! reindex failed for ${instance} (${describeExit(err)})`);
|
|
@@ -159,10 +335,10 @@ export function run(plan, deps) {
|
|
|
159
335
|
|
|
160
336
|
if (failedReindex.length) {
|
|
161
337
|
error(`BLOCKER: ${failedReindex.length} reindex failure(s) [${failedReindex.join(", ")}].`);
|
|
162
|
-
return { exitCode: 1, synced:
|
|
338
|
+
return { exitCode: 1, synced: sync };
|
|
163
339
|
}
|
|
164
340
|
log("\nCorpus sync complete.");
|
|
165
|
-
return { exitCode: 0, synced:
|
|
341
|
+
return { exitCode: 0, synced: sync };
|
|
166
342
|
}
|
|
167
343
|
|
|
168
344
|
function loadConfig() {
|
|
@@ -174,7 +350,7 @@ function loadConfig() {
|
|
|
174
350
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
175
351
|
}
|
|
176
352
|
|
|
177
|
-
function main() {
|
|
353
|
+
async function main() {
|
|
178
354
|
const cfg = loadConfig();
|
|
179
355
|
const allTargets = Object.keys(cfg.targets || {});
|
|
180
356
|
const opts = parseArgs(process.argv.slice(2), allTargets);
|
|
@@ -193,8 +369,8 @@ function main() {
|
|
|
193
369
|
console.log(`Corpus root: ${root}`);
|
|
194
370
|
console.log(`Org: ${org}`);
|
|
195
371
|
|
|
196
|
-
const result = run(
|
|
197
|
-
{ repos, targets: opts.targets, instances, reindex: opts.reindex },
|
|
372
|
+
const result = await run(
|
|
373
|
+
{ repos, targets: opts.targets, instances, reindex: opts.reindex, sync: opts.sync },
|
|
198
374
|
{
|
|
199
375
|
cloneTree: (repo) => syncRepoTree(org, repo, root, authArgs),
|
|
200
376
|
runSync: (target) =>
|
|
@@ -203,11 +379,12 @@ function main() {
|
|
|
203
379
|
cwd: REPO,
|
|
204
380
|
env: { ...process.env, SYNC_REPO_ROOT: root },
|
|
205
381
|
}),
|
|
206
|
-
|
|
382
|
+
awaitSlot: (instance) => awaitReindexSlot(instance, productionReindexDeps()),
|
|
383
|
+
runReindex: (instance) => dispatchWithCooldownRetry(instance, productionReindexDeps()),
|
|
207
384
|
},
|
|
208
385
|
);
|
|
209
386
|
process.exit(result.exitCode);
|
|
210
387
|
}
|
|
211
388
|
|
|
212
389
|
const invokedDirectly = process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url);
|
|
213
|
-
if (invokedDirectly) main();
|
|
390
|
+
if (invokedDirectly) await main();
|