@skyphusion/search-mcp 0.2.1 → 0.4.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 +152 -17
- package/package.json +8 -3
- package/public/ask-widget.js +121 -7
- package/scripts/sync-ingest.mjs +313 -9
- package/scripts/sync-runner.mjs +32 -14
- package/scripts/sync.mjs +92 -8
- package/scripts/targets.json.example +15 -0
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ cp wrangler.mcp.toml.example wrangler.mcp.toml
|
|
|
48
48
|
cp scripts/targets.json.example scripts/targets.json
|
|
49
49
|
# edit the three files for your account, instance, bucket, and repos
|
|
50
50
|
|
|
51
|
-
npm run typecheck
|
|
51
|
+
npm run typecheck # src + index.test.ts (Workers types) AND scripts/**/*.test.ts (Node types)
|
|
52
52
|
npm test
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -56,25 +56,34 @@ Provision R2 + AI Search, sync your corpus, deploy both Workers. Step-by-step: [
|
|
|
56
56
|
|
|
57
57
|
## Skyphusion production
|
|
58
58
|
|
|
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`.
|
|
59
|
+
This repo is the production home for three AI Search surfaces:
|
|
62
60
|
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
| Surface | Host | Worker config |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| Public docs / marketing | `search.vivijure.com` | secret `SKYPHUSION_WRANGLER_TOML` → `wrangler.toml` at CI |
|
|
64
|
+
| Internal MCP | `search-internal.vivijure.com` | secret `SKYPHUSION_WRANGLER_MCP_TOML` |
|
|
65
|
+
| Rockenhaus court records | `search.rockenhaus.net` | committed `wrangler.rockenhaus.toml` |
|
|
66
|
+
|
|
67
|
+
Do not commit `wrangler.toml`, `wrangler.mcp.toml`, or `scripts/targets.json` (gitignored).
|
|
68
|
+
Rockenhaus is the exception: its wrangler file is public and tracked.
|
|
69
|
+
|
|
70
|
+
- [Operator runbook](docs/skyphusion/OPERATOR.md) -- secrets, topology shape, escrow, reindex
|
|
71
|
+
- [Cutover record (2026-07-08)](docs/skyphusion/CUTOVER.md) -- migration history (historical)
|
|
65
72
|
|
|
66
73
|
## Workers
|
|
67
74
|
|
|
68
75
|
| Worker | Entry | Endpoint | Auth |
|
|
69
76
|
| --- | --- | --- | --- |
|
|
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) |
|
|
77
|
+
| Query (public) | `wrangler.toml` | `POST /ask`, `GET /health` | Turnstile (optional) + CORS allowlist |
|
|
78
|
+
| MCP (internal) | `wrangler.mcp.toml` | `POST /mcp`, `GET /health` | `Authorization: Bearer` (fail closed) |
|
|
79
|
+
| Query (rockenhaus) | `wrangler.rockenhaus.toml` | `POST /ask`, `GET /health` | CORS allowlist (court-record origins) |
|
|
72
80
|
|
|
73
|
-
Deploy separately so browser traffic and agent traffic can bind different AI Search instances
|
|
81
|
+
Deploy separately so browser traffic and agent traffic can bind different AI Search instances.
|
|
74
82
|
|
|
75
83
|
```sh
|
|
76
|
-
npm run deploy
|
|
77
|
-
npm run deploy:mcp
|
|
84
|
+
npm run deploy # public query Worker
|
|
85
|
+
npm run deploy:mcp # MCP Worker
|
|
86
|
+
npm run deploy:rockenhaus # rockenhaus query Worker (uses committed wrangler file)
|
|
78
87
|
wrangler secret put MCP_TOKEN -c wrangler.mcp.toml
|
|
79
88
|
wrangler secret put TURNSTILE_SECRET # optional; skips verification when unset
|
|
80
89
|
```
|
|
@@ -101,13 +110,114 @@ wrangler secret put TURNSTILE_SECRET # optional; skips verification when unset
|
|
|
101
110
|
export R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... CLOUDFLARE_ACCOUNT_ID=...
|
|
102
111
|
export CORPUS_GIT_ORG=your-org GITHUB_TOKEN=... # for sync-runner clone auth
|
|
103
112
|
|
|
104
|
-
npm run sync:dry
|
|
105
|
-
npm run sync
|
|
106
|
-
npm run sync:
|
|
113
|
+
npm run sync:dry # plan upload for the default `corpus` target
|
|
114
|
+
npm run sync # upload + prune
|
|
115
|
+
npm run sync:public # skyphusion public target (when targets.json has it)
|
|
116
|
+
npm run sync:internal # skyphusion internal target
|
|
117
|
+
npm run sync:rockenhaus # rockenhaus target
|
|
118
|
+
npm run sync:run # isolated clone root, sync all targets, optional reindex
|
|
107
119
|
```
|
|
108
120
|
|
|
121
|
+
Useful npm scripts (also `npx` CLIs for the first two after install):
|
|
122
|
+
|
|
123
|
+
| Script | Role |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `search-mcp-sync` / `npm run sync` | One-target R2 sync |
|
|
126
|
+
| `search-mcp-sync-run` / `npm run sync:run` | Clone/fetch + multi-target + reindex |
|
|
127
|
+
| `npm run guard:targets` | Additive-only targets.json check |
|
|
128
|
+
| `npm run escrow` | Age-escrow + restore proof for targets secret |
|
|
129
|
+
| `npm run materialize-config` | CI: write wrangler + targets from env secrets |
|
|
130
|
+
|
|
109
131
|
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
132
|
|
|
133
|
+
### Bounding a corpus: `includePaths` vs `excludePaths`
|
|
134
|
+
|
|
135
|
+
`excludePaths` is a denylist and is **fail-open**: add a new top-level file to a
|
|
136
|
+
repo and it silently joins the corpus. That is fine for a docs site. It is wrong
|
|
137
|
+
whenever the corpus boundary actually matters, because "we forgot to exclude it"
|
|
138
|
+
becomes a real incident.
|
|
139
|
+
|
|
140
|
+
`includePaths` / `excludePaths` may sit at the **top level** (same rule for every
|
|
141
|
+
target that lists the repo) or **nested under a target** (search-mcp#62). When
|
|
142
|
+
both set the same repo, the per-target map wins for that target only -- so a
|
|
143
|
+
repo can be full-tree on internal and `_corpus/`-only on a public court surface.
|
|
144
|
+
|
|
145
|
+
`includePaths` is an allowlist and is **fail-closed**: when a repo has an entry,
|
|
146
|
+
only paths under those prefixes are eligible and everything else is refused.
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"includePaths": { "my-repo": ["docs/", "_corpus/"] },
|
|
151
|
+
"excludePaths": { "my-repo": ["docs/internal/"] }
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The two compose subtractively: `includePaths` decides what is eligible, then
|
|
156
|
+
`excludePaths` subtracts from that. A denylist entry can never add a path back.
|
|
157
|
+
|
|
158
|
+
Omit a repo from `includePaths` to keep the previous behaviour. An entry that is
|
|
159
|
+
present but **empty** (`[]`) is a configuration error, not a way to say "index
|
|
160
|
+
nothing": those are different states and collapsing them into the permissive one
|
|
161
|
+
is how a whole repo joins a corpus by accident.
|
|
162
|
+
|
|
163
|
+
#### A present allowlist must earn its keep
|
|
164
|
+
|
|
165
|
+
An allowlist that quietly matches nothing is worse than no allowlist. The sync
|
|
166
|
+
plans zero objects, the mirror prune deletes the corpus that was there, the
|
|
167
|
+
reindex succeeds over nothing, and the answer surface returns a confident nothing
|
|
168
|
+
with every status light green. So each of these refuses the run (exit 2) before
|
|
169
|
+
anything uploads or is pruned:
|
|
170
|
+
|
|
171
|
+
| Refusal | Meaning |
|
|
172
|
+
| --- | --- |
|
|
173
|
+
| `include_paths_no_match` | An entry matched zero git-tracked files (a typo, or a directory that moved) |
|
|
174
|
+
| `include_paths_entry_empty` | The entry is `[]` |
|
|
175
|
+
| `include_paths_entry_invalid` | An entry cannot match a git path (absolute, `..`, backslash, non-string) |
|
|
176
|
+
| `include_paths_entry_not_array` / `include_paths_not_object` | Wrong config shape |
|
|
177
|
+
| `include_paths_unknown_repo` | The entry names a repo no target lists, so nothing reads it |
|
|
178
|
+
| `include_paths_repo_not_cloned` | An allowlisted repo is missing from the clone root |
|
|
179
|
+
| `include_paths_all_excluded` | `excludePaths` removed everything the allowlist selected |
|
|
180
|
+
| `include_paths_all_filtered` | Nothing the allowlist selected survived the ingest filters |
|
|
181
|
+
|
|
182
|
+
Shape and repo-name checks run for **every** target on every sync, not just the
|
|
183
|
+
one being synced, so a rule that has rotted is caught by the next run of any
|
|
184
|
+
target. A stale `excludePaths` repo name warns rather than refuses: denylist rot
|
|
185
|
+
grows a corpus, allowlist rot empties one.
|
|
186
|
+
|
|
187
|
+
Verify what a target will actually upload before you trust it:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
node scripts/sync.mjs my-target --dry-run
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Size cap
|
|
194
|
+
|
|
195
|
+
Objects over `SYNC_MAX_BYTES` (default 4 MB) are skipped. Skips are summarised at
|
|
196
|
+
the end of the run, not only warned inline, because a file silently missing from
|
|
197
|
+
the corpus looks exactly like a file the corpus does not contain -- the worst
|
|
198
|
+
failure mode for something that answers questions. Pass `--fail-on-skip` to turn
|
|
199
|
+
an incomplete corpus into a failed run.
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
SYNC_MAX_BYTES=$((16 * 1024 * 1024)) node scripts/sync.mjs corpus --fail-on-skip
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Corpus manifest (optional)
|
|
206
|
+
|
|
207
|
+
A corpus producer can publish a `manifest.json` next to its objects so the widget
|
|
208
|
+
renders citations instead of raw R2 keys. Entries need a `key`; `title`, `url`,
|
|
209
|
+
`page`, and `total_pages` are used when present:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{ "pages": [
|
|
213
|
+
{ "key": "my-doc/p003.txt", "title": "Deploy guide", "url": "/docs/deploy/", "page": 3, "total_pages": 12 }
|
|
214
|
+
] }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Keys are matched exactly first, then by **suffix**, because the sync namespaces
|
|
218
|
+
every object under its repo name (`<repo>/<path>`) while a producer naturally
|
|
219
|
+
writes its manifest in terms of its own paths.
|
|
220
|
+
|
|
111
221
|
### Reindex dispatch
|
|
112
222
|
|
|
113
223
|
AI Search rejects a new reindex job for two distinct reasons, and `sync-runner` clears both
|
|
@@ -118,13 +228,16 @@ before dispatching:
|
|
|
118
228
|
2. **The post-job cooldown.** Even once a job ends, a new one is refused for a cooldown window
|
|
119
229
|
with `sync_in_cooldown [code: 7020]`. Waiting for the job to end is necessary but not
|
|
120
230
|
sufficient, so we retry until it clears.
|
|
231
|
+
3. **Transient connect failures.** `unable_to_connect_to_ai_search [code: 7017]` on jobs create
|
|
232
|
+
is retried under the same budget as cooldown (search-mcp#73). Treating it as terminal made
|
|
233
|
+
the whole sync red while R2 already had the new objects.
|
|
121
234
|
|
|
122
235
|
Waiting (rather than skipping) means the job we start always lands strictly after our own
|
|
123
236
|
upload, so it sees every object this run wrote. Merge bursts still coalesce: a waiting run holds
|
|
124
237
|
the workflow concurrency group, and GitHub keeps only the newest queued run, so the runs behind
|
|
125
238
|
it collapse instead of each firing their own reindex.
|
|
126
239
|
|
|
127
|
-
Each wait has its own budget (10 min in-flight, 10 min cooldown) rather than one shared
|
|
240
|
+
Each wait has its own budget (10 min in-flight, 10 min cooldown/connect) rather than one shared
|
|
128
241
|
deadline, since the two are additive on a perfectly healthy path: a run can wait minutes for an
|
|
129
242
|
in-flight reindex and then still owe a cooldown wait.
|
|
130
243
|
|
|
@@ -144,9 +257,30 @@ Copy `public/ask-widget.js` and `public/ask-widget.css` to your docs site:
|
|
|
144
257
|
data-endpoint="https://search.example.com/ask"
|
|
145
258
|
data-target="#docs-ask"
|
|
146
259
|
data-label="Ask the docs"
|
|
260
|
+
data-manifest="/corpus-manifest.json"
|
|
261
|
+
data-empty-text="Nothing in the indexed corpus addresses that."
|
|
147
262
|
data-sitekey="YOUR_TURNSTILE_SITEKEY"></script>
|
|
148
263
|
```
|
|
149
264
|
|
|
265
|
+
`data-manifest` is optional; without it sources render as raw object keys. A
|
|
266
|
+
missing or malformed manifest degrades to raw keys rather than breaking answers.
|
|
267
|
+
|
|
268
|
+
`data-empty-text` is shown when a query returns no retrieved sources, so an
|
|
269
|
+
unsourced answer is never left on screen looking authoritative.
|
|
270
|
+
|
|
271
|
+
### Per-site system prompts
|
|
272
|
+
|
|
273
|
+
One deployment can serve several sites. `ORIGIN_PROFILES` is a JSON object in
|
|
274
|
+
`[vars]` mapping an exact request `Origin` to that site's system prompt:
|
|
275
|
+
|
|
276
|
+
```toml
|
|
277
|
+
ORIGIN_PROFILES = '{"https://docs.example.com":"You are the docs assistant...","https://other.example":"You are..."}'
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Precedence is `ORIGIN_PROFILES`, then the legacy blog special-case, then
|
|
281
|
+
`ASSISTANT_SYSTEM_PROMPT`. A malformed value is logged and ignored so a bad var
|
|
282
|
+
cannot take `/ask` down.
|
|
283
|
+
|
|
150
284
|
## Who this is for
|
|
151
285
|
|
|
152
286
|
Operators building documentation search, agent tooling, or internal knowledge bases on [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/) with MCP and a browser widget.
|
|
@@ -154,8 +288,9 @@ Operators building documentation search, agent tooling, or internal knowledge ba
|
|
|
154
288
|
## Links
|
|
155
289
|
|
|
156
290
|
- **Deploy guide:** [docs/DEPLOY.md](docs/DEPLOY.md)
|
|
157
|
-
- **
|
|
158
|
-
- **
|
|
291
|
+
- **Live ask UIs:** [skyphusion.net/search](https://skyphusion.net/search/) · [vivijure.com](https://vivijure.com) (ask widget)
|
|
292
|
+
- **Skyphusion Labs:** https://skyphusion.org · **Blog:** https://skyphusion.net · **Org:** https://github.com/skyphusion-labs
|
|
293
|
+
- **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)
|
|
159
294
|
|
|
160
295
|
## License
|
|
161
296
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyphusion/search-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -60,13 +60,18 @@
|
|
|
60
60
|
"sync:internal": "node scripts/sync.mjs internal",
|
|
61
61
|
"sync:dry": "node scripts/sync.mjs corpus --dry-run",
|
|
62
62
|
"sync:run": "node scripts/sync-runner.mjs",
|
|
63
|
-
"materialize-config": "node scripts/materialize-config.mjs"
|
|
63
|
+
"materialize-config": "node scripts/materialize-config.mjs",
|
|
64
|
+
"escrow": "node scripts/escrow-targets.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"
|
|
64
68
|
},
|
|
65
69
|
"dependencies": {
|
|
66
70
|
"@aws-sdk/client-s3": "^3.888.0"
|
|
67
71
|
},
|
|
68
72
|
"devDependencies": {
|
|
69
73
|
"@cloudflare/workers-types": "^5.20260713.1",
|
|
74
|
+
"@types/node": "^22.15.0",
|
|
70
75
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
71
76
|
"typescript": "^7.0.2",
|
|
72
77
|
"vitest": "^4.1.10",
|
package/public/ask-widget.js
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
var label = script.getAttribute("data-label") || "Ask";
|
|
17
17
|
var placeholder = script.getAttribute("data-placeholder") || "Ask a question about the docs";
|
|
18
18
|
var sitekey = script.getAttribute("data-sitekey") || "";
|
|
19
|
+
// Optional corpus manifest: maps an R2 object key to a human title, a URL,
|
|
20
|
+
// and a page number, so sources render as citations instead of raw keys.
|
|
21
|
+
var manifestUrl = script.getAttribute("data-manifest") || "";
|
|
22
|
+
var emptyText =
|
|
23
|
+
script.getAttribute("data-empty-text") ||
|
|
24
|
+
"Nothing in the indexed corpus addresses that.";
|
|
19
25
|
|
|
20
26
|
function ready(fn) {
|
|
21
27
|
if (document.readyState !== "loading") fn();
|
|
@@ -69,6 +75,97 @@
|
|
|
69
75
|
}
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
|
|
79
|
+
// ---- corpus manifest -------------------------------------------------
|
|
80
|
+
//
|
|
81
|
+
// Without a manifest the widget can only show the R2 object key, which is
|
|
82
|
+
// an implementation detail ("repo/docs/DEPLOY.md"). With one it can show
|
|
83
|
+
// what a reader actually needs: the document title, a link, and a page.
|
|
84
|
+
//
|
|
85
|
+
// Entries are matched by exact key first, then by SUFFIX. Suffix matching
|
|
86
|
+
// matters because the sync prefixes every object with its repo name, so the
|
|
87
|
+
// key in R2 is "<repo>/<path>" while a corpus producer naturally writes its
|
|
88
|
+
// manifest in terms of its own paths. Suffix matching lets the producer stay
|
|
89
|
+
// ignorant of how the sync namespaces things.
|
|
90
|
+
var manifest = null;
|
|
91
|
+
var manifestIndex = null;
|
|
92
|
+
|
|
93
|
+
function indexManifest(data) {
|
|
94
|
+
var entries = [];
|
|
95
|
+
if (Array.isArray(data)) entries = data;
|
|
96
|
+
else if (data && Array.isArray(data.pages)) entries = data.pages;
|
|
97
|
+
else if (data && Array.isArray(data.objects)) entries = data.objects;
|
|
98
|
+
var byKey = {};
|
|
99
|
+
entries.forEach(function (e) {
|
|
100
|
+
if (e && typeof e.key === "string") byKey[e.key] = e;
|
|
101
|
+
});
|
|
102
|
+
return { byKey: byKey, entries: entries };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loadManifest() {
|
|
106
|
+
if (!manifestUrl) return Promise.resolve(null);
|
|
107
|
+
if (manifest) return Promise.resolve(manifest);
|
|
108
|
+
return fetch(manifestUrl)
|
|
109
|
+
.then(function (r) {
|
|
110
|
+
return r.ok ? r.json() : null;
|
|
111
|
+
})
|
|
112
|
+
.then(function (data) {
|
|
113
|
+
if (!data) return null;
|
|
114
|
+
manifest = data;
|
|
115
|
+
manifestIndex = indexManifest(data);
|
|
116
|
+
return data;
|
|
117
|
+
})
|
|
118
|
+
.catch(function () {
|
|
119
|
+
// A missing or broken manifest must not break answers. Degrade to
|
|
120
|
+
// showing raw keys rather than failing the request.
|
|
121
|
+
return null;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function lookup(key) {
|
|
126
|
+
if (!manifestIndex) return null;
|
|
127
|
+
if (manifestIndex.byKey[key]) return manifestIndex.byKey[key];
|
|
128
|
+
for (var i = 0; i < manifestIndex.entries.length; i++) {
|
|
129
|
+
var e = manifestIndex.entries[i];
|
|
130
|
+
if (e && typeof e.key === "string" && key.indexOf(e.key) >= 0) {
|
|
131
|
+
var at = key.length - e.key.length;
|
|
132
|
+
if (at >= 0 && key.slice(at) === e.key) return e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function citationNode(key) {
|
|
139
|
+
var entry = lookup(key);
|
|
140
|
+
var li = document.createElement("li");
|
|
141
|
+
if (!entry) {
|
|
142
|
+
li.textContent = key;
|
|
143
|
+
return li;
|
|
144
|
+
}
|
|
145
|
+
var label = entry.title || key;
|
|
146
|
+
if (entry.url) {
|
|
147
|
+
var a = document.createElement("a");
|
|
148
|
+
a.setAttribute("href", entry.url);
|
|
149
|
+
a.textContent = label;
|
|
150
|
+
li.appendChild(a);
|
|
151
|
+
} else {
|
|
152
|
+
li.appendChild(document.createTextNode(label));
|
|
153
|
+
}
|
|
154
|
+
var bits = [];
|
|
155
|
+
if (entry.page) {
|
|
156
|
+
bits.push("page " + entry.page + (entry.total_pages ? " of " + entry.total_pages : ""));
|
|
157
|
+
}
|
|
158
|
+
if (entry.case_number) bits.push("case " + entry.case_number);
|
|
159
|
+
if (entry.filed_date) bits.push("filed " + entry.filed_date);
|
|
160
|
+
if (bits.length) {
|
|
161
|
+
var span = document.createElement("span");
|
|
162
|
+
span.className = "vjask-source-meta";
|
|
163
|
+
span.textContent = " (" + bits.join(", ") + ")";
|
|
164
|
+
li.appendChild(span);
|
|
165
|
+
}
|
|
166
|
+
return li;
|
|
167
|
+
}
|
|
168
|
+
|
|
72
169
|
function renderSources(chunks) {
|
|
73
170
|
var seen = {};
|
|
74
171
|
var items = [];
|
|
@@ -80,12 +177,18 @@
|
|
|
80
177
|
}
|
|
81
178
|
});
|
|
82
179
|
if (!items.length) return;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
180
|
+
sawSources = true;
|
|
181
|
+
// Built via the DOM API rather than innerHTML: entries come from the
|
|
182
|
+
// corpus manifest, and concatenating them into markup would be an
|
|
183
|
+
// injection path through a data file.
|
|
184
|
+
sources.textContent = "";
|
|
185
|
+
var title = document.createElement("li");
|
|
186
|
+
title.className = "vjask-sources-title";
|
|
187
|
+
title.textContent = "Sources";
|
|
188
|
+
sources.appendChild(title);
|
|
189
|
+
items.forEach(function (k) {
|
|
190
|
+
sources.appendChild(citationNode(k));
|
|
191
|
+
});
|
|
89
192
|
sources.hidden = false;
|
|
90
193
|
}
|
|
91
194
|
|
|
@@ -109,6 +212,8 @@
|
|
|
109
212
|
if (delta && delta.content) answer.textContent += delta.content;
|
|
110
213
|
}
|
|
111
214
|
|
|
215
|
+
var sawSources = false;
|
|
216
|
+
|
|
112
217
|
form.addEventListener("submit", function (ev) {
|
|
113
218
|
ev.preventDefault();
|
|
114
219
|
var q = input.value.trim();
|
|
@@ -116,8 +221,10 @@
|
|
|
116
221
|
btn.disabled = true;
|
|
117
222
|
answer.textContent = "";
|
|
118
223
|
sources.hidden = true;
|
|
119
|
-
sources.
|
|
224
|
+
sources.textContent = "";
|
|
225
|
+
sawSources = false;
|
|
120
226
|
|
|
227
|
+
loadManifest().then(function () {
|
|
121
228
|
fetch(endpoint, {
|
|
122
229
|
method: "POST",
|
|
123
230
|
headers: { "content-type": "application/json" },
|
|
@@ -153,6 +260,12 @@
|
|
|
153
260
|
answer.textContent = "Sorry, something went wrong (" + err.message + ").";
|
|
154
261
|
})
|
|
155
262
|
.finally(function () {
|
|
263
|
+
// No retrieved sources means nothing in the corpus matched. Say that
|
|
264
|
+
// outright rather than leaving a plausible-looking unsourced answer
|
|
265
|
+
// on screen.
|
|
266
|
+
if (!sawSources && !answer.textContent.trim()) {
|
|
267
|
+
answer.textContent = emptyText;
|
|
268
|
+
}
|
|
156
269
|
btn.disabled = false;
|
|
157
270
|
if (sitekey && window.turnstile) {
|
|
158
271
|
try {
|
|
@@ -160,6 +273,7 @@
|
|
|
160
273
|
} catch (e) {}
|
|
161
274
|
}
|
|
162
275
|
});
|
|
276
|
+
});
|
|
163
277
|
});
|
|
164
278
|
});
|
|
165
279
|
})();
|
package/scripts/sync-ingest.mjs
CHANGED
|
@@ -123,19 +123,323 @@ export function ingestKind(relPath) {
|
|
|
123
123
|
return DOC_EXT.has(ext) || basename(relPath).startsWith("README") ? "doc" : "code";
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One prefix rule, shared by the allowlist and the denylist so the two can
|
|
129
|
+
* never drift. An entry is a repo-relative path: with a trailing "/" it means
|
|
130
|
+
* the subtree, without one it means that exact file or the subtree rooted at
|
|
131
|
+
* that name (so "docs" matches "docs" and "docs/a.md" but never "docsite/a.md").
|
|
132
|
+
*/
|
|
133
|
+
export function matchesPathPrefix(relPath, prefix) {
|
|
134
|
+
if (typeof prefix !== "string" || prefix === "") return false;
|
|
135
|
+
if (prefix.endsWith("/")) return relPath.startsWith(prefix);
|
|
136
|
+
return relPath === prefix || relPath.startsWith(prefix + "/");
|
|
137
|
+
}
|
|
138
|
+
|
|
126
139
|
/**
|
|
127
|
-
* Per-repo corpus exclusion (targets.json `excludePaths`).
|
|
128
|
-
*
|
|
129
|
-
* exact file or the subtree rooted at that name.
|
|
140
|
+
* Per-repo corpus exclusion (targets.json `excludePaths`). Denylist, applied on
|
|
141
|
+
* top of the allowlist. Absent or empty means "exclude nothing".
|
|
130
142
|
*/
|
|
131
143
|
export function isExcludedPath(relPath, prefixes) {
|
|
132
144
|
for (const p of prefixes || []) {
|
|
133
|
-
if (
|
|
134
|
-
if (p.endsWith("/")) {
|
|
135
|
-
if (relPath.startsWith(p)) return true;
|
|
136
|
-
} else if (relPath === p || relPath.startsWith(p + "/")) {
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
145
|
+
if (matchesPathPrefix(relPath, p)) return true;
|
|
139
146
|
}
|
|
140
147
|
return false;
|
|
141
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Per-repo corpus ALLOWLIST (targets.json `includePaths`). When a repo has an
|
|
152
|
+
* entry, ONLY paths under one of its prefixes are eligible for the corpus;
|
|
153
|
+
* everything else is refused. When a repo has NO entry (undefined/null) every
|
|
154
|
+
* path is eligible and `excludePaths` alone applies, so a config that never
|
|
155
|
+
* mentions includePaths behaves exactly as it did before.
|
|
156
|
+
*
|
|
157
|
+
* Why both exist. A denylist is fail-OPEN: add a new top-level file to a repo
|
|
158
|
+
* and it silently joins the corpus. That is fine for a docs site and wrong for
|
|
159
|
+
* a corpus whose boundary matters -- a court-record mirror, anything with a
|
|
160
|
+
* privileged directory, anything where "we forgot to exclude it" is an incident
|
|
161
|
+
* rather than noise. An allowlist is fail-CLOSED: a new path is ineligible
|
|
162
|
+
* until someone says otherwise.
|
|
163
|
+
*
|
|
164
|
+
* An entry that is PRESENT but empty (or malformed) matches nothing rather than
|
|
165
|
+
* everything. "The operator wrote an allowlist" and "the operator wrote no
|
|
166
|
+
* allowlist" are different states and must not collapse into the permissive
|
|
167
|
+
* one; the config validation below turns that state into a hard error before a
|
|
168
|
+
* sync ever reaches this matcher.
|
|
169
|
+
*/
|
|
170
|
+
export function isIncludedPath(relPath, prefixes) {
|
|
171
|
+
if (prefixes === undefined || prefixes === null) return true;
|
|
172
|
+
if (!Array.isArray(prefixes)) return false;
|
|
173
|
+
for (const p of prefixes) {
|
|
174
|
+
if (matchesPathPrefix(relPath, p)) return true;
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Thrown for every includePaths refusal. `code` names the specific state so a
|
|
181
|
+
* test can prove a failure happened for the stated reason and not by accident.
|
|
182
|
+
*/
|
|
183
|
+
export class IncludePathsError extends Error {
|
|
184
|
+
constructor(code, message) {
|
|
185
|
+
super(message);
|
|
186
|
+
this.name = "IncludePathsError";
|
|
187
|
+
this.code = code;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function describeType(v) {
|
|
192
|
+
if (v === null) return "null";
|
|
193
|
+
if (Array.isArray(v)) return "array";
|
|
194
|
+
return typeof v;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function q(v) {
|
|
198
|
+
return typeof v === "string" ? JSON.stringify(v) : describeType(v) + " " + JSON.stringify(v);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Every repo named by any target. An includePaths key outside this set reaches nothing. */
|
|
202
|
+
export function knownTargetRepos(cfg) {
|
|
203
|
+
const repos = new Set();
|
|
204
|
+
for (const target of Object.values(cfg?.targets || {})) {
|
|
205
|
+
for (const repo of target?.repos || []) repos.add(repo);
|
|
206
|
+
}
|
|
207
|
+
return repos;
|
|
208
|
+
}
|
|
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
|
+
|
|
279
|
+
/** Shape check for one repo entry. Returns {code, message} objects, never throws. */
|
|
280
|
+
export function validateIncludePathsEntry(repo, prefixes) {
|
|
281
|
+
const errors = [];
|
|
282
|
+
if (!Array.isArray(prefixes)) {
|
|
283
|
+
errors.push({
|
|
284
|
+
code: "include_paths_entry_not_array",
|
|
285
|
+
message:
|
|
286
|
+
`includePaths for repo ${q(repo)} must be an array of repo-relative path prefixes, ` +
|
|
287
|
+
`got ${describeType(prefixes)}. Shape: "includePaths": { "my-repo": ["_corpus/"] }.`,
|
|
288
|
+
});
|
|
289
|
+
return errors;
|
|
290
|
+
}
|
|
291
|
+
if (prefixes.length === 0) {
|
|
292
|
+
errors.push({
|
|
293
|
+
code: "include_paths_entry_empty",
|
|
294
|
+
message:
|
|
295
|
+
`includePaths for repo ${q(repo)} is an empty array, which is a configuration error, ` +
|
|
296
|
+
`not an instruction to index nothing. Remove the repo entry to index all of it, or ` +
|
|
297
|
+
`list the prefixes to index.`,
|
|
298
|
+
});
|
|
299
|
+
return errors;
|
|
300
|
+
}
|
|
301
|
+
for (const p of prefixes) {
|
|
302
|
+
if (typeof p !== "string" || p.trim() === "") {
|
|
303
|
+
errors.push({
|
|
304
|
+
code: "include_paths_entry_invalid",
|
|
305
|
+
message:
|
|
306
|
+
`includePaths for repo ${q(repo)} has entry ${q(p)}: entries must be non-empty strings.`,
|
|
307
|
+
});
|
|
308
|
+
} else if (p.startsWith("/")) {
|
|
309
|
+
errors.push({
|
|
310
|
+
code: "include_paths_entry_invalid",
|
|
311
|
+
message:
|
|
312
|
+
`includePaths for repo ${q(repo)} has entry ${q(p)}: entries are repo-relative and ` +
|
|
313
|
+
`must not start with "/". A git path never does, so this would match nothing.`,
|
|
314
|
+
});
|
|
315
|
+
} else if (p.split("/").includes("..")) {
|
|
316
|
+
errors.push({
|
|
317
|
+
code: "include_paths_entry_invalid",
|
|
318
|
+
message:
|
|
319
|
+
`includePaths for repo ${q(repo)} has entry ${q(p)}: ".." is not something a git path ` +
|
|
320
|
+
`can start with, so this would match nothing.`,
|
|
321
|
+
});
|
|
322
|
+
} else if (p.includes("\\")) {
|
|
323
|
+
errors.push({
|
|
324
|
+
code: "include_paths_entry_invalid",
|
|
325
|
+
message:
|
|
326
|
+
`includePaths for repo ${q(repo)} has entry ${q(p)}: git paths use "/" as the ` +
|
|
327
|
+
`separator, so a backslash would match nothing.`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return errors;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
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.
|
|
338
|
+
*
|
|
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.
|
|
342
|
+
*/
|
|
343
|
+
export function validateIncludePathsConfig(cfg) {
|
|
344
|
+
const errors = [];
|
|
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
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return errors;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Fail-closed wrapper: throws IncludePathsError carrying the first code and every message. */
|
|
358
|
+
export function assertIncludePathsConfig(cfg) {
|
|
359
|
+
const errors = validateIncludePathsConfig(cfg);
|
|
360
|
+
if (!errors.length) return;
|
|
361
|
+
throw new IncludePathsError(errors[0].code, errors.map((e) => e.message).join(" "));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
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.
|
|
369
|
+
*/
|
|
370
|
+
export function unknownExcludePathsRepos(cfg) {
|
|
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];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Pure path selection for one repo: allowlist first, then denylist on top, so
|
|
393
|
+
* the two compose as defense in depth rather than one replacing the other.
|
|
394
|
+
*
|
|
395
|
+
* `relPaths` is the repo git-tracked file list. Refuses, loudly:
|
|
396
|
+
*
|
|
397
|
+
* include_paths_entry_* the entry is malformed or empty
|
|
398
|
+
* include_paths_no_match an entry matched ZERO tracked files
|
|
399
|
+
* include_paths_all_excluded the allowlist matched, excludePaths took it all
|
|
400
|
+
*
|
|
401
|
+
* The zero-match refusal is the point of the whole mechanism. A prefix that
|
|
402
|
+
* matches nothing (a rename, a misspelling, a directory that moved) otherwise
|
|
403
|
+
* plans an empty corpus, the mirror prune deletes what was there, the reindex
|
|
404
|
+
* succeeds over nothing, and the answer surface returns a confident nothing with
|
|
405
|
+
* every status light green. Coverage is measured against the tracked list before
|
|
406
|
+
* exclusion, so "this prefix names nothing in the repo" stays a distinct
|
|
407
|
+
* diagnostic from "everything it named was excluded".
|
|
408
|
+
*/
|
|
409
|
+
export function selectRepoPaths(repo, relPaths, opts = {}) {
|
|
410
|
+
const { includePrefixes, excludePrefixes } = opts;
|
|
411
|
+
const paths = relPaths || [];
|
|
412
|
+
if (includePrefixes === undefined || includePrefixes === null) {
|
|
413
|
+
return paths.filter((rel) => !isExcludedPath(rel, excludePrefixes));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const shapeErrors = validateIncludePathsEntry(repo, includePrefixes);
|
|
417
|
+
if (shapeErrors.length) {
|
|
418
|
+
throw new IncludePathsError(shapeErrors[0].code, shapeErrors.map((e) => e.message).join(" "));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const unmatched = includePrefixes.filter((p) => !paths.some((rel) => matchesPathPrefix(rel, p)));
|
|
422
|
+
if (unmatched.length) {
|
|
423
|
+
throw new IncludePathsError(
|
|
424
|
+
"include_paths_no_match",
|
|
425
|
+
`includePaths for repo ${q(repo)} matched 0 git-tracked files: ` +
|
|
426
|
+
`${unmatched.map(q).join(", ")} (the repo has ${paths.length} tracked file(s)). ` +
|
|
427
|
+
`An allowlist entry that matches nothing would sync an empty corpus over a healthy one ` +
|
|
428
|
+
`and report success, so the sync refuses instead. Fix the prefix in targets.json ` +
|
|
429
|
+
`(repo-relative, trailing slash for a subtree), or drop the entry if the path is gone. ` +
|
|
430
|
+
`Only the prefixes named above matched nothing.`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const included = paths.filter((rel) => isIncludedPath(rel, includePrefixes));
|
|
435
|
+
const kept = included.filter((rel) => !isExcludedPath(rel, excludePrefixes));
|
|
436
|
+
if (kept.length === 0) {
|
|
437
|
+
throw new IncludePathsError(
|
|
438
|
+
"include_paths_all_excluded",
|
|
439
|
+
`includePaths for repo ${q(repo)} selected ${included.length} file(s) and excludePaths ` +
|
|
440
|
+
`removed every one of them. That leaves nothing to sync for this repo, which is an ` +
|
|
441
|
+
`empty corpus dressed as a successful run. Widen includePaths or narrow excludePaths.`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
return kept;
|
|
445
|
+
}
|
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
|
@@ -36,7 +36,11 @@ import {
|
|
|
36
36
|
ingestContentType,
|
|
37
37
|
ingestKind,
|
|
38
38
|
fileExt,
|
|
39
|
-
|
|
39
|
+
selectRepoPaths,
|
|
40
|
+
assertIncludePathsConfig,
|
|
41
|
+
unknownExcludePathsRepos,
|
|
42
|
+
pathMapsForTarget,
|
|
43
|
+
IncludePathsError,
|
|
40
44
|
} from "./sync-ingest.mjs";
|
|
41
45
|
import {
|
|
42
46
|
resolveTargetsPath,
|
|
@@ -47,11 +51,18 @@ import {
|
|
|
47
51
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
48
52
|
const TARGETS_PATH = resolveTargetsPath(HERE);
|
|
49
53
|
const REPO_ROOT = defaultRepoRoot(HERE, TARGETS_PATH);
|
|
50
|
-
|
|
54
|
+
// Objects larger than this are skipped. Configurable because the right value
|
|
55
|
+
// depends on the corpus: 4 MB is fine for a source tree and wrong for scanned
|
|
56
|
+
// PDFs, where the single largest document is often the one that matters most.
|
|
57
|
+
// Skips are also summarised at the end of the run, not only warned inline: a
|
|
58
|
+
// warning in a long CI log is a silent omission in practice.
|
|
59
|
+
const MAX_BYTES = Number(process.env.SYNC_MAX_BYTES || 4 * 1024 * 1024);
|
|
51
60
|
|
|
52
61
|
const args = process.argv.slice(2);
|
|
53
62
|
const DRY = args.includes("--dry-run");
|
|
54
63
|
const SKIP_GITHUB = args.includes("--no-github-verify") || process.env.SYNC_SKIP_GITHUB_VERIFY === "1";
|
|
64
|
+
// Opt-in strictness: turn an incomplete corpus into a failed run.
|
|
65
|
+
const FAIL_ON_SKIP = args.includes("--fail-on-skip");
|
|
55
66
|
const targetName = args.find((a) => !a.startsWith("--"));
|
|
56
67
|
|
|
57
68
|
const SKIP_EXT = new Set([
|
|
@@ -123,16 +134,33 @@ function loadConfig() {
|
|
|
123
134
|
return JSON.parse(readFileSync(TARGETS_PATH, "utf8"));
|
|
124
135
|
}
|
|
125
136
|
|
|
126
|
-
function planRepo(repo, excludePrefixes = []) {
|
|
137
|
+
function planRepo(repo, excludePrefixes, includePrefixes, skipped = []) {
|
|
127
138
|
const repoDir = join(REPO_ROOT, repo);
|
|
139
|
+
const hasAllowlist = includePrefixes !== undefined && includePrefixes !== null;
|
|
128
140
|
if (!existsSync(repoDir)) {
|
|
141
|
+
// A repo with an allowlist has been described exactly: it contributes these
|
|
142
|
+
// paths and nothing else. A missing clone contributes nothing, which is the
|
|
143
|
+
// same empty-corpus-with-green-lights failure the allowlist exists to stop.
|
|
144
|
+
if (hasAllowlist) {
|
|
145
|
+
throw new IncludePathsError(
|
|
146
|
+
"include_paths_repo_not_cloned",
|
|
147
|
+
`includePaths for repo "${repo}" cannot be enforced: not cloned at ${repoDir}. ` +
|
|
148
|
+
`A repo with an allowlist is expected to contribute exactly those paths, so a ` +
|
|
149
|
+
`missing clone refuses the sync instead of quietly syncing nothing for it.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
129
152
|
console.warn(` ! skip ${repo}: not cloned at ${repoDir}`);
|
|
130
153
|
return [];
|
|
131
154
|
}
|
|
155
|
+
// Allowlist first, then denylist on top; both refusals happen here, before a
|
|
156
|
+
// single object is uploaded or pruned.
|
|
157
|
+
const candidates = selectRepoPaths(repo, trackedFiles(repoDir), {
|
|
158
|
+
includePrefixes,
|
|
159
|
+
excludePrefixes,
|
|
160
|
+
});
|
|
132
161
|
const items = [];
|
|
133
|
-
for (const rel of
|
|
162
|
+
for (const rel of candidates) {
|
|
134
163
|
if (shouldSkip(rel)) continue;
|
|
135
|
-
if (isExcludedPath(rel, excludePrefixes)) continue;
|
|
136
164
|
const abs = join(repoDir, rel);
|
|
137
165
|
let size;
|
|
138
166
|
try {
|
|
@@ -141,7 +169,10 @@ function planRepo(repo, excludePrefixes = []) {
|
|
|
141
169
|
continue;
|
|
142
170
|
}
|
|
143
171
|
if (size > MAX_BYTES) {
|
|
144
|
-
|
|
172
|
+
const mb = (size / 1048576).toFixed(1);
|
|
173
|
+
const capMb = (MAX_BYTES / 1048576).toFixed(1);
|
|
174
|
+
console.warn(` ! skip ${repo}/${rel}: ${mb} MB over the ${capMb} MB cap`);
|
|
175
|
+
skipped.push({ repo, path: rel, mb });
|
|
145
176
|
continue;
|
|
146
177
|
}
|
|
147
178
|
const sample = readTextSample(abs, size);
|
|
@@ -161,12 +192,23 @@ function planRepo(repo, excludePrefixes = []) {
|
|
|
161
192
|
},
|
|
162
193
|
});
|
|
163
194
|
}
|
|
195
|
+
if (hasAllowlist && items.length === 0) {
|
|
196
|
+
throw new IncludePathsError(
|
|
197
|
+
"include_paths_all_filtered",
|
|
198
|
+
`includePaths for repo "${repo}" selected ${candidates.length} file(s) and none of them ` +
|
|
199
|
+
`survived the ingest filters (build/vendor directories, lockfiles, .env, binaries, the ` +
|
|
200
|
+
`size cap, or the text sniff). Nothing would upload for this repo, so the sync refuses ` +
|
|
201
|
+
`rather than publish an empty corpus.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
164
204
|
return items;
|
|
165
205
|
}
|
|
166
206
|
|
|
167
207
|
async function main() {
|
|
168
208
|
if (!targetName) {
|
|
169
|
-
console.error(
|
|
209
|
+
console.error(
|
|
210
|
+
"usage: node scripts/sync.mjs <target> [--dry-run] [--no-github-verify] [--fail-on-skip]",
|
|
211
|
+
);
|
|
170
212
|
process.exit(2);
|
|
171
213
|
}
|
|
172
214
|
const cfg = loadConfig();
|
|
@@ -176,6 +218,17 @@ async function main() {
|
|
|
176
218
|
process.exit(2);
|
|
177
219
|
}
|
|
178
220
|
|
|
221
|
+
// Config-level allowlist checks run for every target, not just the one being
|
|
222
|
+
// synced: a rule that names a repo no target lists is rot, and rot found only
|
|
223
|
+
// when someone syncs that one target is rot found late.
|
|
224
|
+
assertIncludePathsConfig(cfg);
|
|
225
|
+
for (const repo of unknownExcludePathsRepos(cfg)) {
|
|
226
|
+
console.warn(
|
|
227
|
+
` ! excludePaths names repo '${repo}', which no target lists in its repos. ` +
|
|
228
|
+
"That rule is inert; delete it or add the repo to a target.",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
179
232
|
assertPublicCorpusBoundary(cfg, targetName);
|
|
180
233
|
if (targetName === "public" && !SKIP_GITHUB) {
|
|
181
234
|
const gh = await assertPublicGithubVisibility(cfg);
|
|
@@ -194,14 +247,39 @@ async function main() {
|
|
|
194
247
|
console.log(`Target '${targetName}' -> bucket ${target.bucket} (instance ${target.instance})`);
|
|
195
248
|
console.log(`Repo root: ${REPO_ROOT}${DRY ? " [DRY RUN]" : ""}`);
|
|
196
249
|
|
|
250
|
+
// #62: per-target path maps win over top-level for this target.
|
|
251
|
+
const pathMaps = pathMapsForTarget(cfg, targetName);
|
|
252
|
+
|
|
197
253
|
const plan = [];
|
|
254
|
+
const skipped = [];
|
|
198
255
|
for (const repo of target.repos) {
|
|
199
|
-
const items = planRepo(
|
|
256
|
+
const items = planRepo(
|
|
257
|
+
repo,
|
|
258
|
+
pathMaps.excludePaths?.[repo],
|
|
259
|
+
pathMaps.includePaths?.[repo],
|
|
260
|
+
skipped,
|
|
261
|
+
);
|
|
200
262
|
if (items.length) console.log(` + ${repo}: ${items.length} files`);
|
|
201
263
|
plan.push(...items);
|
|
202
264
|
}
|
|
203
265
|
console.log(`Planned ${plan.length} objects for ${target.bucket}.`);
|
|
204
266
|
|
|
267
|
+
// Summarise oversize skips. A file silently missing from the corpus looks
|
|
268
|
+
// exactly like a file the corpus does not contain, which is the worst
|
|
269
|
+
// possible failure mode for something that answers questions.
|
|
270
|
+
if (skipped.length) {
|
|
271
|
+
console.warn(`\n ! ${skipped.length} file(s) skipped for exceeding the size cap:`);
|
|
272
|
+
for (const s of skipped) console.warn(` ${s.repo}/${s.path} (${s.mb} MB)`);
|
|
273
|
+
console.warn(
|
|
274
|
+
" These are NOT in the corpus. Raise SYNC_MAX_BYTES, extract their text into\n" +
|
|
275
|
+
" a smaller form, or accept that nothing can answer from them.",
|
|
276
|
+
);
|
|
277
|
+
if (FAIL_ON_SKIP) {
|
|
278
|
+
console.error("--fail-on-skip: refusing to continue with an incomplete corpus.");
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
205
283
|
if (DRY) {
|
|
206
284
|
for (const it of plan.slice(0, 25)) console.log(` ${it.key}`);
|
|
207
285
|
if (plan.length > 25) console.log(` ... and ${plan.length - 25} more`);
|
|
@@ -283,6 +361,12 @@ async function main() {
|
|
|
283
361
|
}
|
|
284
362
|
|
|
285
363
|
main().catch((err) => {
|
|
364
|
+
// A config refusal is an operator error, not a crash: print the diagnostic
|
|
365
|
+
// without a stack, and exit 2 like the other targets.json failures.
|
|
366
|
+
if (err instanceof IncludePathsError) {
|
|
367
|
+
console.error(`\n${err.message}\n [${err.code}]`);
|
|
368
|
+
process.exit(2);
|
|
369
|
+
}
|
|
286
370
|
console.error(err);
|
|
287
371
|
process.exit(1);
|
|
288
372
|
});
|
|
@@ -1,5 +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. 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
|
+
"includePaths": {
|
|
6
|
+
"example-repo": ["docs/"]
|
|
7
|
+
},
|
|
8
|
+
"_excludePaths_comment": "DENYLIST, optional, per repo. Applied after includePaths. Same top-level vs per-target nesting as includePaths.",
|
|
3
9
|
"excludePaths": {
|
|
4
10
|
"example-repo": ["vendor/private-notes/"]
|
|
5
11
|
},
|
|
@@ -9,6 +15,15 @@
|
|
|
9
15
|
"instance": "my-ai-search-instance",
|
|
10
16
|
"bucket": "my-search-corpus",
|
|
11
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
|
+
}
|
|
12
27
|
}
|
|
13
28
|
}
|
|
14
29
|
}
|