gitnexus 1.6.10-rc.6 → 1.6.10-rc.7
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/dist/cli/analyze.js +46 -2
- package/dist/cli/cli-message.d.ts +1 -1
- package/dist/core/embeddings/http-client.d.ts +28 -1
- package/dist/core/embeddings/http-client.js +107 -15
- package/dist/core/lbug/lbug-adapter.js +7 -16
- package/dist/core/lbug/pool-adapter.js +10 -1
- package/dist/core/lbug/sidecar-recovery.d.ts +32 -0
- package/dist/core/lbug/sidecar-recovery.js +91 -7
- package/package.json +1 -1
- package/scripts/cross-platform-tests.ts +1 -0
package/dist/cli/analyze.js
CHANGED
|
@@ -27,7 +27,7 @@ import { cliError } from './cli-message.js';
|
|
|
27
27
|
import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.js';
|
|
28
28
|
import { formatElapsed } from './format-elapsed.js';
|
|
29
29
|
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
|
|
30
|
-
import { isHttpMode, safeUrl } from '../core/embeddings/http-client.js';
|
|
30
|
+
import { isHttpEmbeddingDimsError, isHttpEmbeddingError, isHttpMode, safeUrl, } from '../core/embeddings/http-client.js';
|
|
31
31
|
import { isLocalEmbeddingRuntimeBlockerMessage, isMissingLocalEmbeddingStackMessage, localEmbeddingPrefixUnloadableMessage, localEmbeddingStackMissingMessage, } from '../core/embeddings/runtime-support.js';
|
|
32
32
|
import { ANALYZE_EMBEDDING_INSTALL_TIMEOUT_MS, getEmbeddingInstallTimeoutMs, getEmbeddingRuntimeDir, installEmbeddingRuntime, isPrefixRuntimeLoadable, resolveEmbeddingRuntime, } from '../core/embeddings/runtime-install.js';
|
|
33
33
|
import { warnIfNpm11NpxRisk } from './resolve-invocation.js';
|
|
@@ -1262,10 +1262,54 @@ const analyzeCommandImpl = async (inputPath, cliOptions) => {
|
|
|
1262
1262
|
process.exitCode = 1;
|
|
1263
1263
|
return;
|
|
1264
1264
|
}
|
|
1265
|
+
// Malformed GITNEXUS_EMBEDDING_DIMS env var (#2385). readConfig() throws a
|
|
1266
|
+
// plain Error (a config mistake, not an endpoint failure), surfacing here from
|
|
1267
|
+
// httpEmbed()->readConfig() inside the analysis run. Show a clean config
|
|
1268
|
+
// message rather than a raw stack dump. The --embedding-dims CLI flag is
|
|
1269
|
+
// validated up front (EMBEDDING_DIMS_ERROR); this covers the env-var path.
|
|
1270
|
+
// Checked before the endpoint/HF branches: it is a plain Error, so
|
|
1271
|
+
// isHttpEmbeddingError() is false and the HF network heuristic must not claim it.
|
|
1272
|
+
if (isHttpEmbeddingDimsError(msg)) {
|
|
1273
|
+
cliError(` ${msg.replace(/\n/g, '\n ')}\n`, {
|
|
1274
|
+
recoveryHint: 'embedding-dims-invalid',
|
|
1275
|
+
});
|
|
1276
|
+
process.exitCode = 1;
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
// Custom HTTP embedding endpoint failure (#2385). When a `--embedding-base-url`
|
|
1280
|
+
// is configured, HTTP mode never downloads a model — so a failure talking to
|
|
1281
|
+
// that endpoint must NOT show the huggingface-download guidance. Keyed on the
|
|
1282
|
+
// error *type* (HttpEmbeddingError), not its message text, so it stays correct
|
|
1283
|
+
// regardless of locale or wording. Checked before the HF branch, whose network
|
|
1284
|
+
// heuristic (`fetch failed` / `ECONNREFUSED`) would otherwise also match a
|
|
1285
|
+
// wrapped endpoint-connection error. The header is deliberately neutral: this
|
|
1286
|
+
// type covers both never-reached failures (connection/timeout/DNS) and
|
|
1287
|
+
// reached-but-failed ones (4xx/5xx, dimension/shape mismatch), so it must not
|
|
1288
|
+
// assert "unreachable". The thrown `msg` carries the specific reason (and the
|
|
1289
|
+
// masked URL where one applies), so it is surfaced verbatim.
|
|
1290
|
+
if (isHttpEmbeddingError(err)) {
|
|
1291
|
+
cliError(` The custom embedding endpoint request failed.\n` +
|
|
1292
|
+
` ${msg.replace(/\n/g, '\n ')}\n` +
|
|
1293
|
+
` Suggestions:\n` +
|
|
1294
|
+
` 1. Verify the endpoint URL is reachable and running ` +
|
|
1295
|
+
`(--embedding-base-url / GITNEXUS_EMBEDDING_URL: host, port, /v1 path).\n` +
|
|
1296
|
+
` 2. Confirm the model name and embedding dimensions match what the endpoint serves.\n` +
|
|
1297
|
+
` 3. Re-run without --embeddings to index without vectors.\n`, { recoveryHint: 'http-embedding-endpoint-error' });
|
|
1298
|
+
process.exitCode = 1;
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
// isHttpMode() is a pure presence probe (URL+MODEL) that never throws — a
|
|
1302
|
+
// malformed GITNEXUS_EMBEDDING_DIMS is handled by the dims branch above — so
|
|
1303
|
+
// no defensive try/catch is needed here (#2385).
|
|
1304
|
+
const inHttpMode = isHttpMode();
|
|
1265
1305
|
// HF download failure — show clean guidance without the raw stack trace.
|
|
1266
1306
|
// Checked before writeFatalToStderr so the user sees one focused message
|
|
1267
1307
|
// rather than a stack-trace dump followed by a second remediation block.
|
|
1268
|
-
|
|
1308
|
+
// Gated on !inHttpMode: with a custom endpoint configured no model download
|
|
1309
|
+
// is ever attempted, so a network error there is the endpoint's, handled by
|
|
1310
|
+
// the HttpEmbeddingError branch above — never HF's (#2385).
|
|
1311
|
+
if ((isHfDownloadFailure(msg) || msg.includes('Failed to download embedding model')) &&
|
|
1312
|
+
!inHttpMode) {
|
|
1269
1313
|
cliError(` The embedding model could not be downloaded.\n` +
|
|
1270
1314
|
` huggingface.co may be unreachable from your network\n` +
|
|
1271
1315
|
` (e.g. behind a corporate proxy or a regional firewall).\n` +
|
|
@@ -12,7 +12,7 @@ import { type CliMessageKey, type CliMessageVars } from './i18n/index.js';
|
|
|
12
12
|
* Consumers can import this type to narrow log-record `recoveryHint`
|
|
13
13
|
* fields without restating the literal list.
|
|
14
14
|
*/
|
|
15
|
-
export type RecoveryHint = 'wal-corruption' | 'wal-checkpoint-threshold' | 'heap-oom-respawn' | 'native-worker-abort' | 'hf-endpoint-unreachable' | 'local-embedding-unsupported' | 'local-embedding-stack-missing' | 'large-repo' | 'npm-resolution' | 'module-not-found' | 'gitnexusrc-invalid' | 'default-branch-invalid';
|
|
15
|
+
export type RecoveryHint = 'wal-corruption' | 'wal-checkpoint-threshold' | 'heap-oom-respawn' | 'native-worker-abort' | 'hf-endpoint-unreachable' | 'http-embedding-endpoint-error' | 'embedding-dims-invalid' | 'local-embedding-unsupported' | 'local-embedding-stack-missing' | 'large-repo' | 'npm-resolution' | 'module-not-found' | 'gitnexusrc-invalid' | 'default-branch-invalid';
|
|
16
16
|
/**
|
|
17
17
|
* Common shape for the optional structured-field bag passed to
|
|
18
18
|
* `cliError`/`cliWarn`/`cliInfo`. Typed so the `recoveryHint` slot is
|
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
* via `AbortSignal.timeout` on the underlying fetch.
|
|
12
12
|
*/
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Whether HTTP embedding mode is active — i.e. both `GITNEXUS_EMBEDDING_URL` and
|
|
15
|
+
* `GITNEXUS_EMBEDDING_MODEL` are set. A pure presence probe: it deliberately does
|
|
16
|
+
* NOT call {@link readConfig}, so it never throws on a malformed
|
|
17
|
+
* `GITNEXUS_EMBEDDING_DIMS`. This lets its ~13 call sites (analyze, doctor,
|
|
18
|
+
* run-analyze, embedder, mcp) probe the mode without a defensive try/catch; the
|
|
19
|
+
* DIMS value is validated where it is actually used (`readConfig` in
|
|
20
|
+
* `httpEmbed`/`httpEmbedQuery`), surfacing a recognizable config error. See #2385.
|
|
15
21
|
*/
|
|
16
22
|
export declare const isHttpMode: () => boolean;
|
|
17
23
|
/**
|
|
@@ -26,6 +32,27 @@ export declare const getHttpDimensions: () => number | undefined;
|
|
|
26
32
|
* custom-endpoint confirmation can mask the same way.
|
|
27
33
|
*/
|
|
28
34
|
export declare const safeUrl: (url: string) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Error thrown by this module's HTTP embedding path (`httpEmbedBatch` /
|
|
37
|
+
* `httpEmbed` / `httpEmbedQuery`) for any endpoint failure — a
|
|
38
|
+
* connection/timeout/DNS error, an open circuit, a non-OK status, an
|
|
39
|
+
* unparseable or wrong-shape response body, an empty response, or a dimension
|
|
40
|
+
* mismatch.
|
|
41
|
+
*
|
|
42
|
+
* Carrying a distinct type (rather than a plain `Error`) lets the CLI tell a
|
|
43
|
+
* *custom endpoint* failure apart from a HuggingFace *model download* failure
|
|
44
|
+
* without matching message text: the two share the same underlying network
|
|
45
|
+
* substrings (`fetch failed`, `ECONNREFUSED`, …), which is exactly why
|
|
46
|
+
* `isNetworkFetchError` in `hf-env.ts` cannot tell them apart. Keying on the
|
|
47
|
+
* type instead of the message is also locale-proof and survives message
|
|
48
|
+
* rewording. The human-readable `.message` (built with `safeUrl` and the
|
|
49
|
+
* underlying reason) is what the CLI surfaces to the user. See #2385.
|
|
50
|
+
*/
|
|
51
|
+
export declare class HttpEmbeddingError extends Error {
|
|
52
|
+
constructor(message: string, options?: {
|
|
53
|
+
cause?: unknown;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
29
56
|
/**
|
|
30
57
|
* Embed texts via the HTTP backend, splitting into batches.
|
|
31
58
|
* Reads config from env vars on every call.
|
|
@@ -17,10 +17,26 @@ const HTTP_RETRY_BACKOFF_MS = 1_000;
|
|
|
17
17
|
const HTTP_BATCH_SIZE = 64;
|
|
18
18
|
const DEFAULT_DIMS = 384;
|
|
19
19
|
const HTTP_BREAKER_KEY = 'embeddings-http';
|
|
20
|
+
/**
|
|
21
|
+
* Stable lead of the {@link readConfig} malformed-`GITNEXUS_EMBEDDING_DIMS`
|
|
22
|
+
* error. `readConfig` throws a plain `Error` (not an {@link HttpEmbeddingError})
|
|
23
|
+
* because this is a *config* mistake, not an endpoint failure — so the CLI
|
|
24
|
+
* recognizes it by this lead ({@link isHttpEmbeddingDimsError}) and prints a
|
|
25
|
+
* clean config message instead of a raw stack dump. See #2385.
|
|
26
|
+
*/
|
|
27
|
+
const EMBEDDING_DIMS_ENV_ERROR_LEAD = 'GITNEXUS_EMBEDDING_DIMS must be a positive integer';
|
|
28
|
+
/**
|
|
29
|
+
* @internal Exported for the CLI analyze error handler. True when `message` is
|
|
30
|
+
* the {@link readConfig} malformed-DIMS config error (a plain `Error`).
|
|
31
|
+
*/
|
|
32
|
+
export const isHttpEmbeddingDimsError = (message) => message.includes(EMBEDDING_DIMS_ENV_ERROR_LEAD);
|
|
20
33
|
/**
|
|
21
34
|
* Build config from the current process.env snapshot.
|
|
22
35
|
* Returns null when GITNEXUS_EMBEDDING_URL + GITNEXUS_EMBEDDING_MODEL are unset.
|
|
23
36
|
* Not cached — env vars are read fresh so late configuration takes effect.
|
|
37
|
+
* Validates GITNEXUS_EMBEDDING_DIMS and throws on a malformed value; callers
|
|
38
|
+
* that only need to know whether HTTP mode is *configured* must use
|
|
39
|
+
* {@link isHttpMode} (a presence probe that never throws), not this.
|
|
24
40
|
*/
|
|
25
41
|
const readConfig = () => {
|
|
26
42
|
const baseUrl = process.env.GITNEXUS_EMBEDDING_URL;
|
|
@@ -31,11 +47,11 @@ const readConfig = () => {
|
|
|
31
47
|
let dimensions;
|
|
32
48
|
if (rawDims !== undefined) {
|
|
33
49
|
if (!/^\d+$/.test(rawDims)) {
|
|
34
|
-
throw new Error(
|
|
50
|
+
throw new Error(`${EMBEDDING_DIMS_ENV_ERROR_LEAD}, got "${rawDims}"`);
|
|
35
51
|
}
|
|
36
52
|
const parsed = parseInt(rawDims, 10);
|
|
37
53
|
if (parsed <= 0) {
|
|
38
|
-
throw new Error(
|
|
54
|
+
throw new Error(`${EMBEDDING_DIMS_ENV_ERROR_LEAD}, got "${rawDims}"`);
|
|
39
55
|
}
|
|
40
56
|
dimensions = parsed;
|
|
41
57
|
}
|
|
@@ -47,9 +63,15 @@ const readConfig = () => {
|
|
|
47
63
|
};
|
|
48
64
|
};
|
|
49
65
|
/**
|
|
50
|
-
*
|
|
66
|
+
* Whether HTTP embedding mode is active — i.e. both `GITNEXUS_EMBEDDING_URL` and
|
|
67
|
+
* `GITNEXUS_EMBEDDING_MODEL` are set. A pure presence probe: it deliberately does
|
|
68
|
+
* NOT call {@link readConfig}, so it never throws on a malformed
|
|
69
|
+
* `GITNEXUS_EMBEDDING_DIMS`. This lets its ~13 call sites (analyze, doctor,
|
|
70
|
+
* run-analyze, embedder, mcp) probe the mode without a defensive try/catch; the
|
|
71
|
+
* DIMS value is validated where it is actually used (`readConfig` in
|
|
72
|
+
* `httpEmbed`/`httpEmbedQuery`), surfacing a recognizable config error. See #2385.
|
|
51
73
|
*/
|
|
52
|
-
export const isHttpMode = () =>
|
|
74
|
+
export const isHttpMode = () => Boolean(process.env.GITNEXUS_EMBEDDING_URL && process.env.GITNEXUS_EMBEDDING_MODEL);
|
|
53
75
|
/**
|
|
54
76
|
* Return the configured embedding dimensions for HTTP mode, or undefined
|
|
55
77
|
* if HTTP mode is not active or no explicit dimensions are set.
|
|
@@ -70,6 +92,63 @@ export const safeUrl = (url) => {
|
|
|
70
92
|
return '<invalid-url>';
|
|
71
93
|
}
|
|
72
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* Strip credentials from an underlying transport error message before it is
|
|
97
|
+
* surfaced. A credential-bearing endpoint URL (`https://user:secret@host/v1`)
|
|
98
|
+
* makes undici throw `TypeError: Request cannot be constructed from a URL that
|
|
99
|
+
* includes credentials: <that full URL>`; interpolating `err.message` verbatim
|
|
100
|
+
* would re-leak the secret to stderr + logs even though the URL argument is
|
|
101
|
+
* already masked with {@link safeUrl}. First swap the exact configured `url` for
|
|
102
|
+
* its masked form, then strip any residual `scheme://userinfo@` the transport may
|
|
103
|
+
* have echoed in a normalized (non-exact) form. See #2385.
|
|
104
|
+
*/
|
|
105
|
+
const sanitizeReason = (reason, url) => reason
|
|
106
|
+
.split(url)
|
|
107
|
+
.join(safeUrl(url))
|
|
108
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1');
|
|
109
|
+
/**
|
|
110
|
+
* Error thrown by this module's HTTP embedding path (`httpEmbedBatch` /
|
|
111
|
+
* `httpEmbed` / `httpEmbedQuery`) for any endpoint failure — a
|
|
112
|
+
* connection/timeout/DNS error, an open circuit, a non-OK status, an
|
|
113
|
+
* unparseable or wrong-shape response body, an empty response, or a dimension
|
|
114
|
+
* mismatch.
|
|
115
|
+
*
|
|
116
|
+
* Carrying a distinct type (rather than a plain `Error`) lets the CLI tell a
|
|
117
|
+
* *custom endpoint* failure apart from a HuggingFace *model download* failure
|
|
118
|
+
* without matching message text: the two share the same underlying network
|
|
119
|
+
* substrings (`fetch failed`, `ECONNREFUSED`, …), which is exactly why
|
|
120
|
+
* `isNetworkFetchError` in `hf-env.ts` cannot tell them apart. Keying on the
|
|
121
|
+
* type instead of the message is also locale-proof and survives message
|
|
122
|
+
* rewording. The human-readable `.message` (built with `safeUrl` and the
|
|
123
|
+
* underlying reason) is what the CLI surfaces to the user. See #2385.
|
|
124
|
+
*/
|
|
125
|
+
export class HttpEmbeddingError extends Error {
|
|
126
|
+
constructor(message, options) {
|
|
127
|
+
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
|
|
128
|
+
this.name = 'HttpEmbeddingError';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* @internal Exported for the CLI analyze error handler and unit tests.
|
|
133
|
+
*
|
|
134
|
+
* Type-guard for {@link HttpEmbeddingError}. The `name` fallback keeps the
|
|
135
|
+
* check working across module-realm boundaries where `instanceof` can fail
|
|
136
|
+
* (two loaded copies of the class) — mirroring the codebase's existing
|
|
137
|
+
* `err.name === 'TimeoutError'` idiom. Matches on the stable class
|
|
138
|
+
* discriminator, never on the human-readable (potentially localized) message.
|
|
139
|
+
*/
|
|
140
|
+
export const isHttpEmbeddingError = (err) => err instanceof HttpEmbeddingError || (err instanceof Error && err.name === 'HttpEmbeddingError');
|
|
141
|
+
/**
|
|
142
|
+
* Runtime guard for a single response item. The `Array.isArray(data.data)` shape
|
|
143
|
+
* check only validates the outer array — a 200 body like `{"data":[null]}` passes
|
|
144
|
+
* it, then crashes at `new Float32Array(item.embedding)` (`httpEmbed`) or
|
|
145
|
+
* `items[0].embedding` (`httpEmbedQuery`) with a raw `TypeError` that escapes the
|
|
146
|
+
* typed boundary, landing on the CLI's generic stack-dump path — the exact class
|
|
147
|
+
* #2385 closes. Validate each item so every wrong-shape body stays classifiable.
|
|
148
|
+
*/
|
|
149
|
+
const isEmbeddingItem = (item) => typeof item === 'object' &&
|
|
150
|
+
item !== null &&
|
|
151
|
+
Array.isArray(item.embedding);
|
|
73
152
|
/**
|
|
74
153
|
* Send a single batch of texts to the embedding endpoint with retry.
|
|
75
154
|
*
|
|
@@ -111,23 +190,36 @@ const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensi
|
|
|
111
190
|
}
|
|
112
191
|
catch (err) {
|
|
113
192
|
if (err instanceof CircuitOpenError) {
|
|
114
|
-
throw new
|
|
193
|
+
throw new HttpEmbeddingError(`Embedding endpoint circuit open (${safeUrl(url)}, batch ${batchIndex}): retry in ${Math.ceil(err.retryAfterMs / 1000)}s`, { cause: err });
|
|
115
194
|
}
|
|
116
195
|
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
|
117
|
-
throw new
|
|
196
|
+
throw new HttpEmbeddingError(`Embedding request timed out after ${HTTP_TIMEOUT_MS}ms (${safeUrl(url)}, batch ${batchIndex})`, { cause: err });
|
|
118
197
|
}
|
|
119
198
|
if (err instanceof ResilientFetchExhaustedError) {
|
|
120
|
-
throw new
|
|
199
|
+
throw new HttpEmbeddingError(`Embedding endpoint returned ${err.response.status} (${safeUrl(url)}, batch ${batchIndex})`, { cause: err });
|
|
121
200
|
}
|
|
122
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
123
|
-
throw new
|
|
201
|
+
const reason = sanitizeReason(err instanceof Error ? err.message : String(err), url);
|
|
202
|
+
throw new HttpEmbeddingError(`Embedding request failed (${safeUrl(url)}, batch ${batchIndex}): ${reason}`, { cause: err });
|
|
124
203
|
}
|
|
125
204
|
if (!resp.ok) {
|
|
126
205
|
// resilientFetch already retried 5xx/429; any non-OK response here is
|
|
127
206
|
// a terminal client error (4xx other than 429).
|
|
128
|
-
throw new
|
|
207
|
+
throw new HttpEmbeddingError(`Embedding endpoint returned ${resp.status} (${safeUrl(url)}, batch ${batchIndex})`);
|
|
208
|
+
}
|
|
209
|
+
// A reachable-but-wrong endpoint (e.g. a captive portal or a non-embeddings
|
|
210
|
+
// service) can answer 200 with an HTML/truncated body. Parse inside the
|
|
211
|
+
// typed-error boundary so that lands as an endpoint failure the CLI can
|
|
212
|
+
// classify, not a raw SyntaxError/TypeError on the generic stack-dump path.
|
|
213
|
+
let data;
|
|
214
|
+
try {
|
|
215
|
+
data = (await resp.json());
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
throw new HttpEmbeddingError(`Embedding endpoint returned an unparseable response (${safeUrl(url)}, batch ${batchIndex})`, { cause: err });
|
|
219
|
+
}
|
|
220
|
+
if (!Array.isArray(data?.data) || !data.data.every(isEmbeddingItem)) {
|
|
221
|
+
throw new HttpEmbeddingError(`Embedding endpoint returned an unexpected response shape (${safeUrl(url)}, batch ${batchIndex})`);
|
|
129
222
|
}
|
|
130
|
-
const data = (await resp.json());
|
|
131
223
|
return data.data;
|
|
132
224
|
};
|
|
133
225
|
/**
|
|
@@ -150,7 +242,7 @@ export const httpEmbed = async (texts) => {
|
|
|
150
242
|
const batchIndex = Math.floor(i / HTTP_BATCH_SIZE);
|
|
151
243
|
const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex, config.dimensions);
|
|
152
244
|
if (items.length !== batch.length) {
|
|
153
|
-
throw new
|
|
245
|
+
throw new HttpEmbeddingError(`Embedding endpoint returned ${items.length} vectors for ${batch.length} texts ` +
|
|
154
246
|
`(${safeUrl(url)}, batch ${batchIndex})`);
|
|
155
247
|
}
|
|
156
248
|
for (const item of items) {
|
|
@@ -162,7 +254,7 @@ export const httpEmbed = async (texts) => {
|
|
|
162
254
|
const hint = config.dimensions
|
|
163
255
|
? 'Update GITNEXUS_EMBEDDING_DIMS to match your model output.'
|
|
164
256
|
: `Set GITNEXUS_EMBEDDING_DIMS=${vec.length} to match your model output.`;
|
|
165
|
-
throw new
|
|
257
|
+
throw new HttpEmbeddingError(`Embedding dimension mismatch: endpoint returned ${vec.length}d vector, ` +
|
|
166
258
|
`but expected ${expected}d. ${hint}`);
|
|
167
259
|
}
|
|
168
260
|
allVectors.push(vec);
|
|
@@ -184,7 +276,7 @@ export const httpEmbedQuery = async (text) => {
|
|
|
184
276
|
const url = `${config.baseUrl}/embeddings`;
|
|
185
277
|
const items = await httpEmbedBatch(url, [text], config.model, config.apiKey, 0, config.dimensions);
|
|
186
278
|
if (!items.length) {
|
|
187
|
-
throw new
|
|
279
|
+
throw new HttpEmbeddingError(`Embedding endpoint returned empty response (${safeUrl(url)})`);
|
|
188
280
|
}
|
|
189
281
|
const embedding = items[0].embedding;
|
|
190
282
|
// Same dimension checks as httpEmbed — catch mismatches before they
|
|
@@ -194,7 +286,7 @@ export const httpEmbedQuery = async (text) => {
|
|
|
194
286
|
const hint = config.dimensions
|
|
195
287
|
? 'Update GITNEXUS_EMBEDDING_DIMS to match your model output.'
|
|
196
288
|
: `Set GITNEXUS_EMBEDDING_DIMS=${embedding.length} to match your model output.`;
|
|
197
|
-
throw new
|
|
289
|
+
throw new HttpEmbeddingError(`Embedding dimension mismatch: endpoint returned ${embedding.length}d vector, ` +
|
|
198
290
|
`but expected ${expected}d. ${hint}`);
|
|
199
291
|
}
|
|
200
292
|
return embedding;
|
|
@@ -13,7 +13,7 @@ import { streamAllCSVsToDisk } from './csv-generator.js';
|
|
|
13
13
|
import { getNodeLabel as deriveNodeLabel } from './rel-pair-routing.js';
|
|
14
14
|
import { extensionManager } from './extension-loader.js';
|
|
15
15
|
import { classifyDeleteAllError, closeLbugConnection, isDbBusyError, isOpenRetryExhausted, isWalCorruptionError, openLbugConnection, toNativeSafePath, resolveNativeSafeStorageDir, WAL_RECOVERY_SUGGESTION, waitForWindowsHandleRelease, } from './lbug-config.js';
|
|
16
|
-
import { finalizeLbugSidecarsAfterClose,
|
|
16
|
+
import { finalizeLbugSidecarsAfterClose, guardWalQuarantine, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, shadowSidecarRecoveryMessage, } from './sidecar-recovery.js';
|
|
17
17
|
import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
|
|
18
18
|
import { logger } from '../logger.js';
|
|
19
19
|
/**
|
|
@@ -409,23 +409,14 @@ const queryAndDrain = async (targetConn, cypher) => {
|
|
|
409
409
|
};
|
|
410
410
|
const READ_ONLY_SHADOW_REPLAY_PROBE = 'MATCH (n) RETURN n LIMIT 1';
|
|
411
411
|
/**
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
* Throws shadowSidecarRecoveryMessage immediately when the WAL is large,
|
|
418
|
-
* preserving the uncheckpointed pages for explicit operator recovery.
|
|
419
|
-
* Returns silently when the WAL is absent, tiny, or in any other state
|
|
420
|
-
* where the existing recovery path is safe to proceed.
|
|
412
|
+
* Serve-side entry to the shared WAL-quarantine safety gate. Refuses (throws)
|
|
413
|
+
* when the `.shadow` is present on disk or the orphan WAL is too large to
|
|
414
|
+
* safely discard; returns silently otherwise. The policy itself lives in
|
|
415
|
+
* `guardWalQuarantine` (sidecar-recovery.ts) so serve and the MCP pool share
|
|
416
|
+
* one source of truth (PR #1747 review D2; issue #2382 review, Finding B).
|
|
421
417
|
*/
|
|
422
418
|
const refuseLargeWalQuarantine = async (dbPath, mode, triggeringErr) => {
|
|
423
|
-
|
|
424
|
-
if (state.kind === 'orphan-wal') {
|
|
425
|
-
logger.warn(`GitNexus: refusing to quarantine large WAL (${state.walBytes} bytes) at ${dbPath}.wal during ${mode} recovery; ` +
|
|
426
|
-
'manual recovery required — run `gitnexus analyze --force <repo-path> --index-only`.');
|
|
427
|
-
throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr));
|
|
428
|
-
}
|
|
419
|
+
await guardWalQuarantine(dbPath, mode, triggeringErr, logger);
|
|
429
420
|
};
|
|
430
421
|
const reopenReadOnlyAfterMissingShadow = async (dbPath, err) => {
|
|
431
422
|
await refuseLargeWalQuarantine(dbPath, 'read-only', err);
|
|
@@ -19,7 +19,7 @@ import lbug from '@ladybugdb/core';
|
|
|
19
19
|
import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
|
|
20
20
|
import { closeQueryResults } from './query-result-utils.js';
|
|
21
21
|
import { createLbugDatabase, isWalCorruptionError, toNativeSafePath, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js';
|
|
22
|
-
import { isMissingFsError, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, statIfExists, } from './sidecar-recovery.js';
|
|
22
|
+
import { guardWalQuarantine, isMissingFsError, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, statIfExists, } from './sidecar-recovery.js';
|
|
23
23
|
const pool = new Map();
|
|
24
24
|
const poolCloseListeners = new Set();
|
|
25
25
|
/**
|
|
@@ -349,6 +349,12 @@ const poolSidecarLogger = {
|
|
|
349
349
|
* See plan: docs/plans/2026-05-21-001-fix-pr-1747-quarantine-enoent-and-large-wal-plan.md (U2)
|
|
350
350
|
*/
|
|
351
351
|
async function tryQuarantineForMissingShadow(dbPath, opts) {
|
|
352
|
+
// Refuse (throw) before renaming a live WAL when the shadow is present on
|
|
353
|
+
// disk or the orphan WAL is too large — parity with the serve path's
|
|
354
|
+
// refuseLargeWalQuarantine (issue #2382 review, Finding B). Kept OUTSIDE the
|
|
355
|
+
// try so the actionable recovery message propagates to the MCP caller rather
|
|
356
|
+
// than being re-wrapped as a rename failure.
|
|
357
|
+
await guardWalQuarantine(dbPath, opts.reason, opts.err, poolSidecarLogger);
|
|
352
358
|
try {
|
|
353
359
|
const quarantinePath = await quarantineWalForMissingShadow(dbPath, {
|
|
354
360
|
logger: poolSidecarLogger,
|
|
@@ -396,6 +402,7 @@ async function replayShadowPagesWithWritableOpen(dbPath) {
|
|
|
396
402
|
if (isMissingShadowSidecarError(err)) {
|
|
397
403
|
await tryQuarantineForMissingShadow(dbPath, {
|
|
398
404
|
reason: 'pool writable replay recovery',
|
|
405
|
+
err,
|
|
399
406
|
});
|
|
400
407
|
return;
|
|
401
408
|
}
|
|
@@ -429,6 +436,7 @@ async function openReadOnlyDatabase(dbPath) {
|
|
|
429
436
|
db = undefined;
|
|
430
437
|
await tryQuarantineForMissingShadow(dbPath, {
|
|
431
438
|
reason: 'pool read-only recovery',
|
|
439
|
+
err,
|
|
432
440
|
});
|
|
433
441
|
await preflightLbugSidecars(dbPath, {
|
|
434
442
|
mode: 'read-only',
|
|
@@ -559,6 +567,7 @@ async function doInitLbug(repoId, dbPath) {
|
|
|
559
567
|
}
|
|
560
568
|
}
|
|
561
569
|
if (lastError.message.startsWith('LadybugDB checkpoint sidecar is missing') ||
|
|
570
|
+
lastError.message.startsWith('LadybugDB checkpoint sidecar is present but unreachable') ||
|
|
562
571
|
lastError.message.startsWith('GitNexus could not move the LadybugDB WAL sidecar') ||
|
|
563
572
|
isMissingShadowSidecarError(lastError)) {
|
|
564
573
|
throw lastError;
|
|
@@ -32,6 +32,16 @@ export declare const statIfExists: (filePath: string) => Promise<{
|
|
|
32
32
|
export declare const isMissingShadowSidecarError: (err: unknown) => boolean;
|
|
33
33
|
export declare const isReadOnlyShadowReplayError: (err: unknown) => boolean;
|
|
34
34
|
export declare const shadowSidecarRecoveryMessage: (dbPath: string, err: unknown) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Actionable message for the case where LadybugDB reports a "missing shadow"
|
|
37
|
+
* but `inspectLbugSidecars` finds the `.shadow` PRESENT on disk — the open
|
|
38
|
+
* failed on path reachability or a lock, not a genuinely-missing sidecar (issue
|
|
39
|
+
* #2382 review, S2). Unlike `shadowSidecarRecoveryMessage` it does NOT tell the
|
|
40
|
+
* operator to rebuild the index (the remedy is fixing the lock/path). Keeps the
|
|
41
|
+
* `Original error:` tail so downstream `isMissingShadowSidecarError` recognition
|
|
42
|
+
* still matches the wrapped error.
|
|
43
|
+
*/
|
|
44
|
+
export declare const presentShadowUnreachableMessage: (dbPath: string, err: unknown) => string;
|
|
35
45
|
export declare const isPermissionRenameError: (err: unknown) => boolean;
|
|
36
46
|
/**
|
|
37
47
|
* Classify a failure surfaced by quarantine rename into an actionable user-facing
|
|
@@ -50,6 +60,28 @@ export declare const isPermissionRenameError: (err: unknown) => boolean;
|
|
|
50
60
|
*/
|
|
51
61
|
export declare const renameFailureMessage: (dbPath: string, err: unknown) => string;
|
|
52
62
|
export declare function inspectLbugSidecars(dbPath: string): Promise<LbugSidecarState>;
|
|
63
|
+
/**
|
|
64
|
+
* Reject the WAL-quarantine path when discarding the WAL would be unsafe or
|
|
65
|
+
* wrong. Shared by every reactive missing-shadow recovery consumer — serve (via
|
|
66
|
+
* lbug-adapter's `refuseLargeWalQuarantine`) and the MCP/wiki/augmentation pool
|
|
67
|
+
* (via pool-adapter's `tryQuarantineForMissingShadow`) — so the quarantine
|
|
68
|
+
* safety policy has a single source of truth (issue #2382 review, Finding B).
|
|
69
|
+
*
|
|
70
|
+
* 1. `wal-with-shadow` — the `.shadow` sidecar is PRESENT on disk. A
|
|
71
|
+
* "missing shadow" error alongside a present shadow means the open failed
|
|
72
|
+
* on path reachability or a lock (the #1811 non-ASCII path-garble on
|
|
73
|
+
* Windows), not a genuinely-missing shadow; quarantining would move a live
|
|
74
|
+
* WAL sitting next to its shadow — data loss.
|
|
75
|
+
* 2. `orphan-wal` — the orphan WAL is too large to safely discard
|
|
76
|
+
* (>TINY_ORPHAN_WAL_BYTES); preserve the uncheckpointed pages for explicit
|
|
77
|
+
* operator recovery.
|
|
78
|
+
*
|
|
79
|
+
* Throws `shadowSidecarRecoveryMessage` in either case. Returns silently only
|
|
80
|
+
* when the shadow is absent AND the WAL is absent or tiny — the states where
|
|
81
|
+
* the existing recovery path is safe to proceed. `mode` is a label used only in
|
|
82
|
+
* the warning text (e.g. 'read-only', 'writable', 'pool read-only recovery').
|
|
83
|
+
*/
|
|
84
|
+
export declare const guardWalQuarantine: (dbPath: string, mode: string, triggeringErr: unknown, logger: SidecarRecoveryLogger) => Promise<void>;
|
|
53
85
|
export declare function quarantineWalForMissingShadow(dbPath: string, options: {
|
|
54
86
|
logger: SidecarRecoveryLogger;
|
|
55
87
|
level?: 'debug' | 'info' | 'warn';
|
|
@@ -87,16 +87,47 @@ const warnOnce = (logger, key, message) => {
|
|
|
87
87
|
logger.warn(`${message} (${ordinal(next)} occurrence of this condition)`);
|
|
88
88
|
};
|
|
89
89
|
// LADYBUGDB-CONTRACT: matches @ladybugdb/core ^0.18.0 native error text.
|
|
90
|
-
// When bumping LadybugDB, re-validate this
|
|
90
|
+
// When bumping LadybugDB, re-validate this against the new error format
|
|
91
91
|
// — `git grep "LADYBUGDB-CONTRACT"` enumerates every version-coupled spot.
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
92
|
+
//
|
|
93
|
+
// Two native formats reach here for a genuinely-missing shadow sidecar:
|
|
94
|
+
// POSIX: `Cannot open file <path>.shadow: No such file or directory`
|
|
95
|
+
// Windows: `Cannot open file. path: <path>.shadow - Error 2: <system text>`
|
|
96
|
+
// Windows OS text is localized on non-English installs (issue #2382 was filed
|
|
97
|
+
// from a non-English Windows), so we key on the locale-invariant Win32 code
|
|
98
|
+
// (2 = ERROR_FILE_NOT_FOUND), NOT the English phrase. The code is matched only
|
|
99
|
+
// in the reason AFTER the LAST `.shadow` token (the real failing sidecar; the
|
|
100
|
+
// reason text never contains `.shadow`), so a repo *path* containing e.g.
|
|
101
|
+
// `\error 2\` — even under a `.shadow`-suffixed parent directory — cannot trip
|
|
102
|
+
// it. Deliberate exclusions:
|
|
103
|
+
// - `Error 3` (ERROR_PATH_NOT_FOUND): the #1811 non-ASCII path-garble
|
|
104
|
+
// artifact (see lbug-config.ts) where the shadow is PRESENT on disk;
|
|
105
|
+
// treating it as missing would quarantine a live WAL — data loss.
|
|
106
|
+
// - `Error 5` / `Error 32` / POSIX `Permission denied`: present-but-locked;
|
|
107
|
+
// handled as permission/lock classes, must not quarantine.
|
|
108
|
+
// The quarantine path adds a present-shadow disk check as a belt (see
|
|
109
|
+
// refuseLargeWalQuarantine in lbug-adapter.ts).
|
|
110
|
+
//
|
|
111
|
+
// The Windows branch is derived from the issue #2382 reported string, not a
|
|
112
|
+
// self-produced live crash; unit/consumer tests inject that same string, so
|
|
113
|
+
// GREEN TESTS DO NOT PROVE the byte-exact 0.18.0 Windows format — confirm
|
|
114
|
+
// against a real Windows run before closing #2382.
|
|
97
115
|
export const isMissingShadowSidecarError = (err) => {
|
|
98
116
|
const msg = err instanceof Error ? err.message : String(err);
|
|
99
|
-
|
|
117
|
+
if (!/cannot open file/i.test(msg))
|
|
118
|
+
return false;
|
|
119
|
+
// Anchor on the LAST `.shadow`, not the first: LadybugDB names the failing
|
|
120
|
+
// sidecar as the final `.shadow` token and its reason text (POSIX
|
|
121
|
+
// `: No such file or directory` / Windows ` - Error N: ...`) never contains
|
|
122
|
+
// `.shadow`. Slicing from the last match isolates the true reason, so an
|
|
123
|
+
// earlier `.shadow`-suffixed path segment (e.g. a `branch=subdir` directory
|
|
124
|
+
// like `snap.shadow\`) can't shift the anchor and let a path-embedded
|
|
125
|
+
// `error 2` be read as the Win32 code (issue #2382 review, Finding A).
|
|
126
|
+
const lastShadow = [...msg.matchAll(/\.shadow\b/gi)].at(-1);
|
|
127
|
+
if (lastShadow?.index === undefined)
|
|
128
|
+
return false;
|
|
129
|
+
const reason = msg.slice(lastShadow.index);
|
|
130
|
+
return /no such file or directory/i.test(reason) || /\berror\s+2\b/i.test(reason);
|
|
100
131
|
};
|
|
101
132
|
// LADYBUGDB-CONTRACT: matches @ladybugdb/core ^0.18.0 native error text.
|
|
102
133
|
// When bumping LadybugDB, re-validate this regex against the new error format
|
|
@@ -114,6 +145,24 @@ export const shadowSidecarRecoveryMessage = (dbPath, err) => {
|
|
|
114
145
|
'Rebuild the index with `gitnexus analyze --force <repo-path> --index-only` and restart `gitnexus serve`.' +
|
|
115
146
|
`\n Original error: ${msg.slice(0, 200)}`);
|
|
116
147
|
};
|
|
148
|
+
/**
|
|
149
|
+
* Actionable message for the case where LadybugDB reports a "missing shadow"
|
|
150
|
+
* but `inspectLbugSidecars` finds the `.shadow` PRESENT on disk — the open
|
|
151
|
+
* failed on path reachability or a lock, not a genuinely-missing sidecar (issue
|
|
152
|
+
* #2382 review, S2). Unlike `shadowSidecarRecoveryMessage` it does NOT tell the
|
|
153
|
+
* operator to rebuild the index (the remedy is fixing the lock/path). Keeps the
|
|
154
|
+
* `Original error:` tail so downstream `isMissingShadowSidecarError` recognition
|
|
155
|
+
* still matches the wrapped error.
|
|
156
|
+
*/
|
|
157
|
+
export const presentShadowUnreachableMessage = (dbPath, err) => {
|
|
158
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
159
|
+
return (`LadybugDB checkpoint sidecar is present but unreachable for ${dbPath}. ` +
|
|
160
|
+
'The .shadow file is on disk, so the open likely failed on path reachability or a file lock ' +
|
|
161
|
+
'(antivirus, another process holding a handle, or a non-ASCII path) rather than a missing sidecar. ' +
|
|
162
|
+
'Check filesystem access and locks; only run `gitnexus analyze --force <repo-path> --index-only` ' +
|
|
163
|
+
'if the index is genuinely broken.' +
|
|
164
|
+
`\n Original error: ${msg.slice(0, 200)}`);
|
|
165
|
+
};
|
|
117
166
|
const PERMISSION_RENAME_CODES = new Set(['EACCES', 'EPERM', 'EBUSY']);
|
|
118
167
|
export const isPermissionRenameError = (err) => {
|
|
119
168
|
const code = err?.code;
|
|
@@ -165,6 +214,41 @@ export async function inspectLbugSidecars(dbPath) {
|
|
|
165
214
|
}
|
|
166
215
|
return { kind: 'clean', dbPath };
|
|
167
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Reject the WAL-quarantine path when discarding the WAL would be unsafe or
|
|
219
|
+
* wrong. Shared by every reactive missing-shadow recovery consumer — serve (via
|
|
220
|
+
* lbug-adapter's `refuseLargeWalQuarantine`) and the MCP/wiki/augmentation pool
|
|
221
|
+
* (via pool-adapter's `tryQuarantineForMissingShadow`) — so the quarantine
|
|
222
|
+
* safety policy has a single source of truth (issue #2382 review, Finding B).
|
|
223
|
+
*
|
|
224
|
+
* 1. `wal-with-shadow` — the `.shadow` sidecar is PRESENT on disk. A
|
|
225
|
+
* "missing shadow" error alongside a present shadow means the open failed
|
|
226
|
+
* on path reachability or a lock (the #1811 non-ASCII path-garble on
|
|
227
|
+
* Windows), not a genuinely-missing shadow; quarantining would move a live
|
|
228
|
+
* WAL sitting next to its shadow — data loss.
|
|
229
|
+
* 2. `orphan-wal` — the orphan WAL is too large to safely discard
|
|
230
|
+
* (>TINY_ORPHAN_WAL_BYTES); preserve the uncheckpointed pages for explicit
|
|
231
|
+
* operator recovery.
|
|
232
|
+
*
|
|
233
|
+
* Throws `shadowSidecarRecoveryMessage` in either case. Returns silently only
|
|
234
|
+
* when the shadow is absent AND the WAL is absent or tiny — the states where
|
|
235
|
+
* the existing recovery path is safe to proceed. `mode` is a label used only in
|
|
236
|
+
* the warning text (e.g. 'read-only', 'writable', 'pool read-only recovery').
|
|
237
|
+
*/
|
|
238
|
+
export const guardWalQuarantine = async (dbPath, mode, triggeringErr, logger) => {
|
|
239
|
+
const state = await inspectLbugSidecars(dbPath);
|
|
240
|
+
if (state.kind === 'wal-with-shadow') {
|
|
241
|
+
warnOnce(logger, `${dbPath}:present-shadow-refuse:${mode}`, `GitNexus: refusing to quarantine WAL at ${dbPath}.wal during ${mode} recovery — ` +
|
|
242
|
+
'the .shadow sidecar is present on disk, so the open likely failed on path reachability or a lock ' +
|
|
243
|
+
'rather than a missing shadow. Run `gitnexus analyze --force <repo-path> --index-only` if the index is genuinely broken.');
|
|
244
|
+
throw new Error(presentShadowUnreachableMessage(dbPath, triggeringErr));
|
|
245
|
+
}
|
|
246
|
+
if (state.kind === 'orphan-wal') {
|
|
247
|
+
warnOnce(logger, `${dbPath}:large-wal-refuse:${mode}`, `GitNexus: refusing to quarantine large WAL (${state.walBytes} bytes) at ${dbPath}.wal during ${mode} recovery; ` +
|
|
248
|
+
'manual recovery required — run `gitnexus analyze --force <repo-path> --index-only`.');
|
|
249
|
+
throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr));
|
|
250
|
+
}
|
|
251
|
+
};
|
|
168
252
|
export async function quarantineWalForMissingShadow(dbPath, options) {
|
|
169
253
|
const walPath = `${dbPath}.wal`;
|
|
170
254
|
const quarantinePath = `${walPath}.missing-shadow.${Date.now()}-${Math.random()
|
package/package.json
CHANGED
|
@@ -43,6 +43,7 @@ const PLATFORM_LOGIC = [
|
|
|
43
43
|
'test/unit/cursor-hook.test.ts',
|
|
44
44
|
'test/unit/sidecar-recovery.test.ts',
|
|
45
45
|
'test/unit/pool-wal-recovery.test.ts',
|
|
46
|
+
'test/unit/lbug-adapter-wal-schema.test.ts',
|
|
46
47
|
'test/unit/detect-changes-worktree.test.ts',
|
|
47
48
|
'test/unit/eval-server-bind-restriction.test.ts',
|
|
48
49
|
'test/unit/ignore-service.test.ts',
|