@skyphusion/search-mcp 0.1.3 → 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 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
- If you name a target `public`, optional boundary checks refuse overlap with `restrictedRepos` and can verify GitHub repo visibility before upload.
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.1.3",
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>",
@@ -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
  };
@@ -109,40 +112,209 @@ function syncRepoTree(org, repo, root, authArgs) {
109
112
  return mode;
110
113
  }
111
114
 
112
- function reindexInstance(instance) {
113
- execFileSync("npx", ["wrangler", "ai-search", "jobs", "create", instance, "--json"], {
114
- stdio: "inherit",
115
- cwd: REPO,
116
- });
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);
117
147
  }
118
148
 
119
- export function run(plan, deps) {
120
- const { repos, targets, instances, reindex } = plan;
121
- const { cloneTree, runSync, runReindex, log = console.log, error = console.error } = deps;
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;
122
170
 
123
- log(`Refreshing ${repos.length} repo tree(s) ...`);
124
- const failedClones = [];
125
- for (const repo of repos) {
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 (;;) {
126
241
  try {
127
- const mode = cloneTree(repo);
128
- log(` ok ${repo} (${mode})`);
242
+ runReindexOnce(instance);
243
+ return { retried: announced };
129
244
  } catch (err) {
130
- failedClones.push(repo);
131
- error(` !! ${repo}: git failed (${describeExit(err)})`);
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);
132
259
  }
133
260
  }
261
+ }
134
262
 
135
- if (failedClones.length) {
136
- error(
137
- `BLOCKER: ${failedClones.length} clone/fetch failure(s) [${failedClones.join(", ")}]; ` +
138
- "aborting before sync so no repo corpus is pruned or synced stale.",
139
- );
140
- return { exitCode: 1, synced: false };
141
- }
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
+ }
142
279
 
143
- for (const target of targets) {
144
- log(`\n=== sync ${target} ===`);
145
- runSync(target);
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.");
146
318
  }
147
319
 
148
320
  const failedReindex = [];
@@ -150,7 +322,8 @@ export function run(plan, deps) {
150
322
  for (const instance of instances) {
151
323
  log(`\n=== reindex ${instance} ===`);
152
324
  try {
153
- runReindex(instance);
325
+ if (awaitSlot) await awaitSlot(instance);
326
+ await runReindex(instance);
154
327
  } catch (err) {
155
328
  failedReindex.push(instance);
156
329
  error(` !! reindex failed for ${instance} (${describeExit(err)})`);
@@ -162,10 +335,10 @@ export function run(plan, deps) {
162
335
 
163
336
  if (failedReindex.length) {
164
337
  error(`BLOCKER: ${failedReindex.length} reindex failure(s) [${failedReindex.join(", ")}].`);
165
- return { exitCode: 1, synced: true };
338
+ return { exitCode: 1, synced: sync };
166
339
  }
167
340
  log("\nCorpus sync complete.");
168
- return { exitCode: 0, synced: true };
341
+ return { exitCode: 0, synced: sync };
169
342
  }
170
343
 
171
344
  function loadConfig() {
@@ -177,7 +350,7 @@ function loadConfig() {
177
350
  return JSON.parse(readFileSync(path, "utf8"));
178
351
  }
179
352
 
180
- function main() {
353
+ async function main() {
181
354
  const cfg = loadConfig();
182
355
  const allTargets = Object.keys(cfg.targets || {});
183
356
  const opts = parseArgs(process.argv.slice(2), allTargets);
@@ -196,8 +369,8 @@ function main() {
196
369
  console.log(`Corpus root: ${root}`);
197
370
  console.log(`Org: ${org}`);
198
371
 
199
- const result = run(
200
- { 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 },
201
374
  {
202
375
  cloneTree: (repo) => syncRepoTree(org, repo, root, authArgs),
203
376
  runSync: (target) =>
@@ -206,11 +379,12 @@ function main() {
206
379
  cwd: REPO,
207
380
  env: { ...process.env, SYNC_REPO_ROOT: root },
208
381
  }),
209
- runReindex: reindexInstance,
382
+ awaitSlot: (instance) => awaitReindexSlot(instance, productionReindexDeps()),
383
+ runReindex: (instance) => dispatchWithCooldownRetry(instance, productionReindexDeps()),
210
384
  },
211
385
  );
212
386
  process.exit(result.exitCode);
213
387
  }
214
388
 
215
389
  const invokedDirectly = process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url);
216
- if (invokedDirectly) main();
390
+ if (invokedDirectly) await main();