@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 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.2",
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": "^4.20260606.1",
69
+ "@cloudflare/workers-types": "^5.20260713.1",
70
70
  "@vitest/coverage-istanbul": "^4.1.10",
71
- "typescript": "^6.0.3",
71
+ "typescript": "^7.0.2",
72
72
  "vitest": "^4.1.10",
73
- "wrangler": "^4.103.0"
73
+ "wrangler": "^4.110.0"
74
74
  }
75
75
  }
@@ -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
- function reindexInstance(instance) {
110
- execFileSync("npx", ["wrangler", "ai-search", "jobs", "create", instance, "--json"], {
111
- stdio: "inherit",
112
- cwd: REPO,
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
- export function run(plan, deps) {
117
- const { repos, targets, instances, reindex } = plan;
118
- 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;
119
170
 
120
- log(`Refreshing ${repos.length} repo tree(s) ...`);
121
- const failedClones = [];
122
- 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 (;;) {
123
241
  try {
124
- cloneTree(repo);
125
- log(` ok ${repo}`);
242
+ runReindexOnce(instance);
243
+ return { retried: announced };
126
244
  } catch (err) {
127
- failedClones.push(repo);
128
- 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);
129
259
  }
130
260
  }
261
+ }
131
262
 
132
- if (failedClones.length) {
133
- error(
134
- `BLOCKER: ${failedClones.length} clone/fetch failure(s) [${failedClones.join(", ")}]; ` +
135
- "aborting before sync so no repo corpus is pruned or synced stale.",
136
- );
137
- return { exitCode: 1, synced: false };
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
- for (const target of targets) {
141
- log(`\n=== sync ${target} ===`);
142
- 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.");
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
- runReindex(instance);
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: true };
338
+ return { exitCode: 1, synced: sync };
163
339
  }
164
340
  log("\nCorpus sync complete.");
165
- return { exitCode: 0, synced: true };
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
- runReindex: reindexInstance,
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();