@skyphusion/search-mcp 0.3.0 → 0.5.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 +64 -18
- package/package.json +9 -3
- package/scripts/sync-ingest.mjs +105 -35
- package/scripts/sync-runner.mjs +32 -14
- package/scripts/sync.mjs +32 -5
- package/scripts/targets.json.example +12 -3
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
Open-source toolkit for [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/):
|
|
8
8
|
|
|
9
|
-
1. **MCP Worker** (`src/mcp.ts`) -- bearer-gated Streamable-HTTP MCP
|
|
9
|
+
1. **MCP Worker** (`src/mcp.ts`) -- bearer-gated Streamable-HTTP MCP for agents:
|
|
10
|
+
`search`, `list_repos`, `get_file`, `ask`, `corpus_status`, plus `corpus://` resources.
|
|
10
11
|
2. **Query Worker** (`src/index.ts`) -- CORS + Turnstile + rate-limited `POST /ask` that streams answers for a browser widget.
|
|
11
12
|
3. **Corpus sync** (`scripts/sync.mjs`, `scripts/sync-runner.mjs`) -- git-tracked sources to R2, with extension remapping so TypeScript, Dockerfiles, and other text AI Search would otherwise skip get indexed.
|
|
12
13
|
|
|
@@ -48,7 +49,7 @@ cp wrangler.mcp.toml.example wrangler.mcp.toml
|
|
|
48
49
|
cp scripts/targets.json.example scripts/targets.json
|
|
49
50
|
# edit the three files for your account, instance, bucket, and repos
|
|
50
51
|
|
|
51
|
-
npm run typecheck
|
|
52
|
+
npm run typecheck # src + index.test.ts (Workers types) AND scripts/**/*.test.ts (Node types)
|
|
52
53
|
npm test
|
|
53
54
|
```
|
|
54
55
|
|
|
@@ -56,25 +57,34 @@ Provision R2 + AI Search, sync your corpus, deploy both Workers. Step-by-step: [
|
|
|
56
57
|
|
|
57
58
|
## Skyphusion production
|
|
58
59
|
|
|
59
|
-
This repo is the production home for
|
|
60
|
-
`search-internal.vivijure.com`). Config is materialized from GitHub Actions secrets at deploy/sync
|
|
61
|
-
time; do not commit `wrangler.toml`, `wrangler.mcp.toml`, or `scripts/targets.json`.
|
|
60
|
+
This repo is the production home for three AI Search surfaces:
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
| Surface | Host | Worker config |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| Public docs / marketing | `search.vivijure.com` | secret `SKYPHUSION_WRANGLER_TOML` → `wrangler.toml` at CI |
|
|
65
|
+
| Internal MCP | `search-internal.vivijure.com` | secret `SKYPHUSION_WRANGLER_MCP_TOML` |
|
|
66
|
+
| Rockenhaus court records | `search.rockenhaus.net` | committed `wrangler.rockenhaus.toml` |
|
|
67
|
+
|
|
68
|
+
Do not commit `wrangler.toml`, `wrangler.mcp.toml`, or `scripts/targets.json` (gitignored).
|
|
69
|
+
Rockenhaus is the exception: its wrangler file is public and tracked.
|
|
70
|
+
|
|
71
|
+
- [Operator runbook](docs/skyphusion/OPERATOR.md) -- secrets, topology shape, escrow, reindex
|
|
72
|
+
- [Cutover record (2026-07-08)](docs/skyphusion/CUTOVER.md) -- migration history (historical)
|
|
65
73
|
|
|
66
74
|
## Workers
|
|
67
75
|
|
|
68
76
|
| Worker | Entry | Endpoint | Auth |
|
|
69
77
|
| --- | --- | --- | --- |
|
|
70
|
-
| Query | `wrangler.toml` | `POST /ask`, `GET /health` | Turnstile (optional) + CORS allowlist |
|
|
71
|
-
| MCP | `wrangler.mcp.toml` | `POST /mcp`, `GET /health` | `Authorization: Bearer` (fail closed) |
|
|
78
|
+
| Query (public) | `wrangler.toml` | `POST /ask`, `GET /health` | Turnstile (optional) + CORS allowlist |
|
|
79
|
+
| MCP (internal) | `wrangler.mcp.toml` | `POST /mcp`, `GET /health` | `Authorization: Bearer` (fail closed) |
|
|
80
|
+
| Query (rockenhaus) | `wrangler.rockenhaus.toml` | `POST /ask`, `GET /health` | CORS allowlist (court-record origins) |
|
|
72
81
|
|
|
73
|
-
Deploy separately so browser traffic and agent traffic can bind different AI Search instances
|
|
82
|
+
Deploy separately so browser traffic and agent traffic can bind different AI Search instances.
|
|
74
83
|
|
|
75
84
|
```sh
|
|
76
|
-
npm run deploy
|
|
77
|
-
npm run deploy:mcp
|
|
85
|
+
npm run deploy # public query Worker
|
|
86
|
+
npm run deploy:mcp # MCP Worker
|
|
87
|
+
npm run deploy:rockenhaus # rockenhaus query Worker (uses committed wrangler file)
|
|
78
88
|
wrangler secret put MCP_TOKEN -c wrangler.mcp.toml
|
|
79
89
|
wrangler secret put TURNSTILE_SECRET # optional; skips verification when unset
|
|
80
90
|
```
|
|
@@ -95,17 +105,44 @@ wrangler secret put TURNSTILE_SECRET # optional; skips verification when unset
|
|
|
95
105
|
|
|
96
106
|
`MCP_TOKEN` accepts a single token or comma-separated `name=token` pairs for per-consumer attribution in logs.
|
|
97
107
|
|
|
108
|
+
### MCP tools (agents)
|
|
109
|
+
|
|
110
|
+
| Tool | Role |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `list_repos` | Exact repo names for filters (`CORPUS_REPOS` or R2 prefixes) |
|
|
113
|
+
| `search` | Hybrid/keyword/vector chunks; optional `repos`, `path_prefix`, `min_score`, `rewrite` |
|
|
114
|
+
| `get_file` | Capped R2 read by `repo` + `path` (needs `CORPUS` binding) |
|
|
115
|
+
| `ask` | One grounded answer via chatCompletions + source list |
|
|
116
|
+
| `corpus_status` | Last sync metadata (`_meta/corpus-status.json` from `sync.mjs`) |
|
|
117
|
+
|
|
118
|
+
Resources: `corpus://catalog`, `corpus://skill`. Prefer **list_repos → search → get_file**; use **ask** when you want prose rather than raw chunks.
|
|
119
|
+
|
|
120
|
+
Bind the same R2 bucket the sync uploads to as `CORPUS` on the MCP Worker so `get_file` and status work. Optional `CORPUS_REPOS` JSON pins the catalog without listing R2.
|
|
121
|
+
|
|
98
122
|
## Corpus sync
|
|
99
123
|
|
|
100
124
|
```sh
|
|
101
125
|
export R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... CLOUDFLARE_ACCOUNT_ID=...
|
|
102
126
|
export CORPUS_GIT_ORG=your-org GITHUB_TOKEN=... # for sync-runner clone auth
|
|
103
127
|
|
|
104
|
-
npm run sync:dry
|
|
105
|
-
npm run sync
|
|
106
|
-
npm run sync:
|
|
128
|
+
npm run sync:dry # plan upload for the default `corpus` target
|
|
129
|
+
npm run sync # upload + prune
|
|
130
|
+
npm run sync:public # skyphusion public target (when targets.json has it)
|
|
131
|
+
npm run sync:internal # skyphusion internal target
|
|
132
|
+
npm run sync:rockenhaus # rockenhaus target
|
|
133
|
+
npm run sync:run # isolated clone root, sync all targets, optional reindex
|
|
107
134
|
```
|
|
108
135
|
|
|
136
|
+
Useful npm scripts (also `npx` CLIs for the first two after install):
|
|
137
|
+
|
|
138
|
+
| Script | Role |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| `search-mcp-sync` / `npm run sync` | One-target R2 sync |
|
|
141
|
+
| `search-mcp-sync-run` / `npm run sync:run` | Clone/fetch + multi-target + reindex |
|
|
142
|
+
| `npm run guard:targets` | Additive-only targets.json check |
|
|
143
|
+
| `npm run escrow` | Age-escrow + restore proof for targets secret |
|
|
144
|
+
| `npm run materialize-config` | CI: write wrangler + targets from env secrets |
|
|
145
|
+
|
|
109
146
|
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
147
|
|
|
111
148
|
### Bounding a corpus: `includePaths` vs `excludePaths`
|
|
@@ -115,6 +152,11 @@ repo and it silently joins the corpus. That is fine for a docs site. It is wrong
|
|
|
115
152
|
whenever the corpus boundary actually matters, because "we forgot to exclude it"
|
|
116
153
|
becomes a real incident.
|
|
117
154
|
|
|
155
|
+
`includePaths` / `excludePaths` may sit at the **top level** (same rule for every
|
|
156
|
+
target that lists the repo) or **nested under a target** (search-mcp#62). When
|
|
157
|
+
both set the same repo, the per-target map wins for that target only -- so a
|
|
158
|
+
repo can be full-tree on internal and `_corpus/`-only on a public court surface.
|
|
159
|
+
|
|
118
160
|
`includePaths` is an allowlist and is **fail-closed**: when a repo has an entry,
|
|
119
161
|
only paths under those prefixes are eligible and everything else is refused.
|
|
120
162
|
|
|
@@ -201,13 +243,16 @@ before dispatching:
|
|
|
201
243
|
2. **The post-job cooldown.** Even once a job ends, a new one is refused for a cooldown window
|
|
202
244
|
with `sync_in_cooldown [code: 7020]`. Waiting for the job to end is necessary but not
|
|
203
245
|
sufficient, so we retry until it clears.
|
|
246
|
+
3. **Transient connect failures.** `unable_to_connect_to_ai_search [code: 7017]` on jobs create
|
|
247
|
+
is retried under the same budget as cooldown (search-mcp#73). Treating it as terminal made
|
|
248
|
+
the whole sync red while R2 already had the new objects.
|
|
204
249
|
|
|
205
250
|
Waiting (rather than skipping) means the job we start always lands strictly after our own
|
|
206
251
|
upload, so it sees every object this run wrote. Merge bursts still coalesce: a waiting run holds
|
|
207
252
|
the workflow concurrency group, and GitHub keeps only the newest queued run, so the runs behind
|
|
208
253
|
it collapse instead of each firing their own reindex.
|
|
209
254
|
|
|
210
|
-
Each wait has its own budget (10 min in-flight, 10 min cooldown) rather than one shared
|
|
255
|
+
Each wait has its own budget (10 min in-flight, 10 min cooldown/connect) rather than one shared
|
|
211
256
|
deadline, since the two are additive on a perfectly healthy path: a run can wait minutes for an
|
|
212
257
|
in-flight reindex and then still owe a cooldown wait.
|
|
213
258
|
|
|
@@ -258,8 +303,9 @@ Operators building documentation search, agent tooling, or internal knowledge ba
|
|
|
258
303
|
## Links
|
|
259
304
|
|
|
260
305
|
- **Deploy guide:** [docs/DEPLOY.md](docs/DEPLOY.md)
|
|
261
|
-
- **
|
|
262
|
-
- **
|
|
306
|
+
- **Live ask UIs:** [skyphusion.net/search](https://skyphusion.net/search/) · [vivijure.com](https://vivijure.com) (ask widget)
|
|
307
|
+
- **Skyphusion Labs:** https://skyphusion.org · **Blog:** https://skyphusion.net · **Org:** https://github.com/skyphusion-labs
|
|
308
|
+
- **Related:** [vivijure](https://github.com/skyphusion-labs/vivijure), [postern](https://github.com/skyphusion-labs/postern), [security-audit](https://github.com/skyphusion-labs/security-audit), [prism](https://github.com/skyphusion-labs/prism)
|
|
263
309
|
|
|
264
310
|
## License
|
|
265
311
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyphusion/search-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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>",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"dev:mcp": "wrangler dev -c wrangler.mcp.toml",
|
|
53
53
|
"deploy": "wrangler deploy",
|
|
54
54
|
"deploy:mcp": "wrangler deploy -c wrangler.mcp.toml",
|
|
55
|
-
"typecheck": "tsc --noEmit",
|
|
55
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json",
|
|
56
56
|
"test": "vitest run",
|
|
57
57
|
"prepack": "npm run typecheck && npm test",
|
|
58
58
|
"sync": "node scripts/sync.mjs corpus",
|
|
@@ -62,16 +62,22 @@
|
|
|
62
62
|
"sync:run": "node scripts/sync-runner.mjs",
|
|
63
63
|
"materialize-config": "node scripts/materialize-config.mjs",
|
|
64
64
|
"escrow": "node scripts/escrow-targets.mjs",
|
|
65
|
-
"guard:targets": "node scripts/guard-targets-additive.mjs"
|
|
65
|
+
"guard:targets": "node scripts/guard-targets-additive.mjs",
|
|
66
|
+
"deploy:rockenhaus": "wrangler deploy -c wrangler.rockenhaus.toml",
|
|
67
|
+
"sync:rockenhaus": "node scripts/sync.mjs rockenhaus"
|
|
66
68
|
},
|
|
67
69
|
"dependencies": {
|
|
68
70
|
"@aws-sdk/client-s3": "^3.888.0"
|
|
69
71
|
},
|
|
70
72
|
"devDependencies": {
|
|
71
73
|
"@cloudflare/workers-types": "^5.20260713.1",
|
|
74
|
+
"@types/node": "^22.15.0",
|
|
72
75
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
73
76
|
"typescript": "^7.0.2",
|
|
74
77
|
"vitest": "^4.1.10",
|
|
75
78
|
"wrangler": "^4.110.0"
|
|
79
|
+
},
|
|
80
|
+
"overrides": {
|
|
81
|
+
"undici": "7.29.0"
|
|
76
82
|
}
|
|
77
83
|
}
|
package/scripts/sync-ingest.mjs
CHANGED
|
@@ -207,6 +207,75 @@ export function knownTargetRepos(cfg) {
|
|
|
207
207
|
return repos;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Resolve include/exclude path maps for ONE target (search-mcp#62).
|
|
212
|
+
*
|
|
213
|
+
* Precedence: per-target maps win per-repo over top-level maps. Top-level alone
|
|
214
|
+
* keeps the pre-#62 shape (the same rule for every target that lists the repo).
|
|
215
|
+
* A repo listed only under another target's nested map is not visible here.
|
|
216
|
+
*
|
|
217
|
+
* Why both layers: the original top-level key was fine while every exclusion
|
|
218
|
+
* applied to exactly one target; the moment a repo is deliberately indexed at
|
|
219
|
+
* two granularities (public vs internal), a single map cannot express it.
|
|
220
|
+
*/
|
|
221
|
+
export function pathMapsForTarget(cfg, targetName) {
|
|
222
|
+
const target = cfg?.targets?.[targetName] || {};
|
|
223
|
+
const topInclude =
|
|
224
|
+
cfg?.includePaths && typeof cfg.includePaths === "object" && !Array.isArray(cfg.includePaths)
|
|
225
|
+
? cfg.includePaths
|
|
226
|
+
: {};
|
|
227
|
+
const topExclude =
|
|
228
|
+
cfg?.excludePaths && typeof cfg.excludePaths === "object" && !Array.isArray(cfg.excludePaths)
|
|
229
|
+
? cfg.excludePaths
|
|
230
|
+
: {};
|
|
231
|
+
const tInclude =
|
|
232
|
+
target.includePaths && typeof target.includePaths === "object" && !Array.isArray(target.includePaths)
|
|
233
|
+
? target.includePaths
|
|
234
|
+
: {};
|
|
235
|
+
const tExclude =
|
|
236
|
+
target.excludePaths && typeof target.excludePaths === "object" && !Array.isArray(target.excludePaths)
|
|
237
|
+
? target.excludePaths
|
|
238
|
+
: {};
|
|
239
|
+
return {
|
|
240
|
+
includePaths: { ...topInclude, ...tInclude },
|
|
241
|
+
excludePaths: { ...topExclude, ...tExclude },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isPathMapObject(v) {
|
|
246
|
+
return v !== undefined && v !== null && typeof v === "object" && !Array.isArray(v);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Validate one path-map object against a known-repo set. Returns error objects. */
|
|
250
|
+
function validateIncludePathsMap(includePaths, known, label) {
|
|
251
|
+
const errors = [];
|
|
252
|
+
if (includePaths === undefined || includePaths === null) return errors;
|
|
253
|
+
if (!isPathMapObject(includePaths)) {
|
|
254
|
+
errors.push({
|
|
255
|
+
code: "include_paths_not_object",
|
|
256
|
+
message:
|
|
257
|
+
`${label} must be an object keyed by repo, got ${describeType(includePaths)}. ` +
|
|
258
|
+
`Shape: "includePaths": { "my-repo": ["_corpus/"] }.`,
|
|
259
|
+
});
|
|
260
|
+
return errors;
|
|
261
|
+
}
|
|
262
|
+
for (const [repo, prefixes] of Object.entries(includePaths)) {
|
|
263
|
+
if (repo.startsWith("_")) continue; // documentation keys in the example file
|
|
264
|
+
if (!known.has(repo)) {
|
|
265
|
+
errors.push({
|
|
266
|
+
code: "include_paths_unknown_repo",
|
|
267
|
+
message:
|
|
268
|
+
`${label} names repo ${q(repo)}, which is not in the applicable target repo list. ` +
|
|
269
|
+
`Nothing reads that rule, and a rule nothing reads is indistinguishable from a rule ` +
|
|
270
|
+
`that works. Add the repo to the target or delete the entry. Applicable repos: ` +
|
|
271
|
+
`${[...known].join(", ") || "(none)"}.`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
errors.push(...validateIncludePathsEntry(repo, prefixes));
|
|
275
|
+
}
|
|
276
|
+
return errors;
|
|
277
|
+
}
|
|
278
|
+
|
|
210
279
|
/** Shape check for one repo entry. Returns {code, message} objects, never throws. */
|
|
211
280
|
export function validateIncludePathsEntry(repo, prefixes) {
|
|
212
281
|
const errors = [];
|
|
@@ -263,38 +332,24 @@ export function validateIncludePathsEntry(repo, prefixes) {
|
|
|
263
332
|
}
|
|
264
333
|
|
|
265
334
|
/**
|
|
266
|
-
* Whole-config check for targets.json `includePaths
|
|
267
|
-
* objects, never throws, so a caller can
|
|
335
|
+
* Whole-config check for targets.json `includePaths` (top-level AND per-target,
|
|
336
|
+
* search-mcp#62). Returns {code, message} objects, never throws, so a caller can
|
|
337
|
+
* report all of them at once.
|
|
268
338
|
*
|
|
269
|
-
* Rejects an entry naming a repo no target lists: a rule nothing
|
|
270
|
-
* indistinguishable from a rule that works, which is how an allowlist
|
|
339
|
+
* Rejects an entry naming a repo no applicable target lists: a rule nothing
|
|
340
|
+
* reads is indistinguishable from a rule that works, which is how an allowlist
|
|
341
|
+
* rots. Per-target maps are checked against that target's repos only.
|
|
271
342
|
*/
|
|
272
343
|
export function validateIncludePathsConfig(cfg) {
|
|
273
344
|
const errors = [];
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
});
|
|
283
|
-
return errors;
|
|
284
|
-
}
|
|
285
|
-
const known = knownTargetRepos(cfg);
|
|
286
|
-
for (const [repo, prefixes] of Object.entries(includePaths)) {
|
|
287
|
-
if (!known.has(repo)) {
|
|
288
|
-
errors.push({
|
|
289
|
-
code: "include_paths_unknown_repo",
|
|
290
|
-
message:
|
|
291
|
-
`includePaths names repo ${q(repo)}, which no target lists in its repos. Nothing reads ` +
|
|
292
|
-
`that rule, and a rule nothing reads is indistinguishable from a rule that works. Add ` +
|
|
293
|
-
`the repo to a target or delete the entry. Repos across all targets: ` +
|
|
294
|
-
`${[...known].join(", ") || "(none)"}.`,
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
errors.push(...validateIncludePathsEntry(repo, prefixes));
|
|
345
|
+
const knownAll = knownTargetRepos(cfg);
|
|
346
|
+
errors.push(...validateIncludePathsMap(cfg?.includePaths, knownAll, "targets.json includePaths"));
|
|
347
|
+
for (const [name, target] of Object.entries(cfg?.targets || {})) {
|
|
348
|
+
if (!target || typeof target !== "object") continue;
|
|
349
|
+
const known = new Set(Array.isArray(target.repos) ? target.repos : []);
|
|
350
|
+
errors.push(
|
|
351
|
+
...validateIncludePathsMap(target.includePaths, known, `targets.${name}.includePaths`),
|
|
352
|
+
);
|
|
298
353
|
}
|
|
299
354
|
return errors;
|
|
300
355
|
}
|
|
@@ -307,15 +362,30 @@ export function assertIncludePathsConfig(cfg) {
|
|
|
307
362
|
}
|
|
308
363
|
|
|
309
364
|
/**
|
|
310
|
-
* excludePaths keys
|
|
311
|
-
*
|
|
312
|
-
*
|
|
365
|
+
* excludePaths keys (top-level OR nested under a target) naming a repo no
|
|
366
|
+
* applicable target lists. Denylist rot is quieter than allowlist rot (the
|
|
367
|
+
* corpus grows rather than empties), so this is a warning at the call site,
|
|
368
|
+
* not a refusal. Returns unique repo names.
|
|
313
369
|
*/
|
|
314
370
|
export function unknownExcludePathsRepos(cfg) {
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
371
|
+
const out = new Set();
|
|
372
|
+
const knownAll = knownTargetRepos(cfg);
|
|
373
|
+
const top = cfg?.excludePaths;
|
|
374
|
+
if (isPathMapObject(top)) {
|
|
375
|
+
for (const repo of Object.keys(top)) {
|
|
376
|
+
if (!repo.startsWith("_") && !knownAll.has(repo)) out.add(repo);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
for (const target of Object.values(cfg?.targets || {})) {
|
|
380
|
+
if (!target || typeof target !== "object") continue;
|
|
381
|
+
const nested = target.excludePaths;
|
|
382
|
+
if (!isPathMapObject(nested)) continue;
|
|
383
|
+
const known = new Set(Array.isArray(target.repos) ? target.repos : []);
|
|
384
|
+
for (const repo of Object.keys(nested)) {
|
|
385
|
+
if (!repo.startsWith("_") && !known.has(repo)) out.add(repo);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return [...out];
|
|
319
389
|
}
|
|
320
390
|
|
|
321
391
|
/**
|
package/scripts/sync-runner.mjs
CHANGED
|
@@ -172,10 +172,23 @@ export const REINDEX_POLL_MS = 15 * 1000;
|
|
|
172
172
|
* AI Search refuses a new job for a cooldown window after the previous one ENDS, distinct from
|
|
173
173
|
* the job being in flight. Measured 2026-07-16: rejected 10s after a job ended, accepted at
|
|
174
174
|
* 32s. Waiting for `ended_at` alone is necessary but not sufficient.
|
|
175
|
+
*
|
|
176
|
+
* Also treated as transient (2026-08-04 live corpus-sync): `unable_to_connect_to_ai_search
|
|
177
|
+
* [code: 7017]` on jobs create for skyphusion-public -- the instance was healthy (list jobs
|
|
178
|
+
* worked; a later create succeeded). Retrying under the same budget as cooldown is correct;
|
|
179
|
+
* treating 7017 as terminal made the whole sync red while R2 already had the new objects.
|
|
175
180
|
*/
|
|
176
|
-
export function
|
|
181
|
+
export function isTransientReindexError(err) {
|
|
177
182
|
const text = `${err?.stdout || ""}${err?.stderr || ""}${err?.message || ""}`;
|
|
178
|
-
return
|
|
183
|
+
return (
|
|
184
|
+
/sync_in_cooldown|code:\s*7020/.test(text) ||
|
|
185
|
+
/unable_to_connect_to_ai_search|code:\s*7017/.test(text)
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @deprecated prefer isTransientReindexError; kept so older call sites and tests keep working. */
|
|
190
|
+
export function isCooldownError(err) {
|
|
191
|
+
return isTransientReindexError(err);
|
|
179
192
|
}
|
|
180
193
|
|
|
181
194
|
/**
|
|
@@ -218,13 +231,14 @@ export async function awaitReindexSlot(instance, deps) {
|
|
|
218
231
|
}
|
|
219
232
|
|
|
220
233
|
/**
|
|
221
|
-
* Dispatch a reindex, retrying while AI Search reports
|
|
234
|
+
* Dispatch a reindex, retrying while AI Search reports a TRANSIENT refuse
|
|
235
|
+
* (post-job cooldown 7020, or unable_to_connect_to_ai_search 7017).
|
|
222
236
|
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
237
|
+
* These clears are short relative to the budget, so the routine burst case clears here and
|
|
238
|
+
* never goes red. Exhausting the budget means something genuinely anomalous upstream, and we
|
|
239
|
+
* fail loud rather than exit green: a green run while indexing is actually stalled is the
|
|
240
|
+
* work-blind failure mode this whole issue exists to remove. The message carries the honest
|
|
241
|
+
* blast radius so the red is actionable, not alarming.
|
|
228
242
|
*/
|
|
229
243
|
export async function dispatchWithCooldownRetry(instance, deps) {
|
|
230
244
|
const {
|
|
@@ -242,17 +256,21 @@ export async function dispatchWithCooldownRetry(instance, deps) {
|
|
|
242
256
|
runReindexOnce(instance);
|
|
243
257
|
return { retried: announced };
|
|
244
258
|
} catch (err) {
|
|
245
|
-
if (!
|
|
259
|
+
if (!isTransientReindexError(err)) throw err;
|
|
246
260
|
if (now() >= deadline) {
|
|
247
261
|
throw new Error(
|
|
248
|
-
`reindex for ${instance} blocked by AI Search
|
|
249
|
-
`${Math.round(timeoutMs / 1000)}s
|
|
250
|
-
"
|
|
251
|
-
"
|
|
262
|
+
`reindex for ${instance} blocked by transient AI Search errors for longer than ` +
|
|
263
|
+
`${Math.round(timeoutMs / 1000)}s (cooldown 7020 and/or connect 7017). ` +
|
|
264
|
+
"The R2 corpus uploaded OK and no data is lost; the index lags until the next " +
|
|
265
|
+
"sync or the daily backstop. Persistence this long is upstream and anomalous, " +
|
|
266
|
+
"so this run fails loudly on purpose.",
|
|
252
267
|
);
|
|
253
268
|
}
|
|
254
269
|
if (!announced) {
|
|
255
|
-
log(
|
|
270
|
+
log(
|
|
271
|
+
` ${instance} hit a transient AI Search refuse (cooldown or unable_to_connect); ` +
|
|
272
|
+
"retrying until it clears.",
|
|
273
|
+
);
|
|
256
274
|
announced = true;
|
|
257
275
|
}
|
|
258
276
|
await sleep(pollMs);
|
package/scripts/sync.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
selectRepoPaths,
|
|
40
40
|
assertIncludePathsConfig,
|
|
41
41
|
unknownExcludePathsRepos,
|
|
42
|
+
pathMapsForTarget,
|
|
42
43
|
IncludePathsError,
|
|
43
44
|
} from "./sync-ingest.mjs";
|
|
44
45
|
import {
|
|
@@ -246,13 +247,16 @@ async function main() {
|
|
|
246
247
|
console.log(`Target '${targetName}' -> bucket ${target.bucket} (instance ${target.instance})`);
|
|
247
248
|
console.log(`Repo root: ${REPO_ROOT}${DRY ? " [DRY RUN]" : ""}`);
|
|
248
249
|
|
|
250
|
+
// #62: per-target path maps win over top-level for this target.
|
|
251
|
+
const pathMaps = pathMapsForTarget(cfg, targetName);
|
|
252
|
+
|
|
249
253
|
const plan = [];
|
|
250
254
|
const skipped = [];
|
|
251
255
|
for (const repo of target.repos) {
|
|
252
256
|
const items = planRepo(
|
|
253
257
|
repo,
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
pathMaps.excludePaths?.[repo],
|
|
259
|
+
pathMaps.includePaths?.[repo],
|
|
256
260
|
skipped,
|
|
257
261
|
);
|
|
258
262
|
if (items.length) console.log(` + ${repo}: ${items.length} files`);
|
|
@@ -346,13 +350,36 @@ async function main() {
|
|
|
346
350
|
ContinuationToken = res.IsTruncated ? res.NextContinuationToken : undefined;
|
|
347
351
|
} while (ContinuationToken);
|
|
348
352
|
|
|
349
|
-
|
|
350
|
-
|
|
353
|
+
// Never prune operator/meta keys written for MCP corpus_status.
|
|
354
|
+
const stalePrune = stale.filter((o) => o.Key && !o.Key.startsWith("_meta/"));
|
|
355
|
+
for (let i = 0; i < stalePrune.length; i += 1000) {
|
|
356
|
+
const batch = stalePrune.slice(i, i + 1000);
|
|
351
357
|
await s3.send(
|
|
352
358
|
new DeleteObjectsCommand({ Bucket, Delete: { Objects: batch } }),
|
|
353
359
|
);
|
|
354
360
|
}
|
|
355
|
-
console.log(`Pruned ${
|
|
361
|
+
console.log(`Pruned ${stalePrune.length} stale objects.`);
|
|
362
|
+
|
|
363
|
+
// MCP corpus_status + list_repos freshness (search-mcp agent tooling).
|
|
364
|
+
const status = {
|
|
365
|
+
ts: Date.now(),
|
|
366
|
+
ok: true,
|
|
367
|
+
target: targetName,
|
|
368
|
+
bucket: target.bucket,
|
|
369
|
+
instance: target.instance,
|
|
370
|
+
objectCount: plan.length,
|
|
371
|
+
repos: [...target.repos],
|
|
372
|
+
note: "Written by scripts/sync.mjs after successful upload+prune.",
|
|
373
|
+
};
|
|
374
|
+
await s3.send(
|
|
375
|
+
new PutObjectCommand({
|
|
376
|
+
Bucket,
|
|
377
|
+
Key: "_meta/corpus-status.json",
|
|
378
|
+
Body: Buffer.from(JSON.stringify(status, null, 2), "utf8"),
|
|
379
|
+
ContentType: "application/json",
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
console.log("Wrote _meta/corpus-status.json for MCP corpus_status.");
|
|
356
383
|
console.log("Done. AI Search will re-index on its next sync.");
|
|
357
384
|
}
|
|
358
385
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Copy to targets.json (./targets.json for npm CLI, or scripts/targets.json in a git clone). repos are directory names under SYNC_REPO_ROOT (default: cwd for npm, parent of this repo for dev clone). Only git-tracked files are uploaded.",
|
|
3
|
-
"_includePaths_comment": "ALLOWLIST, optional, per repo. When present, ONLY paths under these prefixes are eligible for the corpus and everything else is refused. Use it when the corpus boundary matters: a denylist is fail-OPEN, so a new top-level file silently joins the corpus, whereas an allowlist is fail-CLOSED. Omit a repo to keep the old behaviour of admitting everything; an entry that is present but EMPTY is an error, not a way to index nothing.",
|
|
4
|
-
"_includePaths_refusals": "A present allowlist must earn its keep: an entry matching zero git-tracked files, an entry naming a repo no target lists, a repo whose clone is missing, or a selection that excludePaths or the ingest filters empty out all refuse the sync (exit 2). An allowlist that quietly matches nothing would prune a healthy corpus and reindex over the emptiness with every status light green.",
|
|
3
|
+
"_includePaths_comment": "ALLOWLIST, optional, per repo. When present, ONLY paths under these prefixes are eligible for the corpus and everything else is refused. Use it when the corpus boundary matters: a denylist is fail-OPEN, so a new top-level file silently joins the corpus, whereas an allowlist is fail-CLOSED. Omit a repo to keep the old behaviour of admitting everything; an entry that is present but EMPTY is an error, not a way to index nothing. May live at the top level (applies to every target that lists the repo) OR nested under a target (search-mcp#62); per-target wins per repo when both set the same key.",
|
|
4
|
+
"_includePaths_refusals": "A present allowlist must earn its keep: an entry matching zero git-tracked files, an entry naming a repo no applicable target lists, a repo whose clone is missing, or a selection that excludePaths or the ingest filters empty out all refuse the sync (exit 2). An allowlist that quietly matches nothing would prune a healthy corpus and reindex over the emptiness with every status light green.",
|
|
5
5
|
"includePaths": {
|
|
6
6
|
"example-repo": ["docs/"]
|
|
7
7
|
},
|
|
8
|
-
"_excludePaths_comment": "DENYLIST, optional, per repo. Applied after includePaths.",
|
|
8
|
+
"_excludePaths_comment": "DENYLIST, optional, per repo. Applied after includePaths. Same top-level vs per-target nesting as includePaths.",
|
|
9
9
|
"excludePaths": {
|
|
10
10
|
"example-repo": ["vendor/private-notes/"]
|
|
11
11
|
},
|
|
@@ -15,6 +15,15 @@
|
|
|
15
15
|
"instance": "my-ai-search-instance",
|
|
16
16
|
"bucket": "my-search-corpus",
|
|
17
17
|
"repos": ["example-repo"]
|
|
18
|
+
},
|
|
19
|
+
"_per_target_paths_example": {
|
|
20
|
+
"_comment": "Optional nested includePaths/excludePaths apply only to this target (search-mcp#62). Use when the same repo is indexed at different granularities across targets.",
|
|
21
|
+
"instance": "other-instance",
|
|
22
|
+
"bucket": "other-bucket",
|
|
23
|
+
"repos": ["example-repo"],
|
|
24
|
+
"includePaths": {
|
|
25
|
+
"example-repo": ["_corpus/"]
|
|
26
|
+
}
|
|
18
27
|
}
|
|
19
28
|
}
|
|
20
29
|
}
|