gitnexus 1.6.12 → 1.6.13-rc.2

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.
Files changed (48) hide show
  1. package/README.md +28 -31
  2. package/dist/cli/analyze.js +7 -24
  3. package/dist/cli/doctor.js +14 -9
  4. package/dist/core/lbug/extension-load-error.d.ts +8 -2
  5. package/dist/core/lbug/extension-load-error.js +23 -16
  6. package/dist/core/lbug/extension-loader.d.ts +21 -6
  7. package/dist/core/lbug/extension-loader.js +72 -17
  8. package/dist/core/lbug/lbug-adapter.d.ts +6 -25
  9. package/dist/core/lbug/lbug-adapter.js +36 -47
  10. package/dist/core/lbug/native-check.d.ts +15 -1
  11. package/dist/core/lbug/native-check.js +45 -22
  12. package/dist/core/lbug/pool-adapter.js +8 -1
  13. package/dist/core/lbug/sidecar-recovery.d.ts +32 -1
  14. package/dist/core/lbug/sidecar-recovery.js +63 -1
  15. package/dist/core/lbug/vendored-extension-path.d.ts +26 -0
  16. package/dist/core/lbug/vendored-extension-path.js +98 -0
  17. package/dist/core/lbug/wal-checkpoint-driver.d.ts +5 -2
  18. package/dist/core/lbug/wal-checkpoint-driver.js +7 -3
  19. package/dist/core/run-analyze.d.ts +4 -2
  20. package/dist/core/run-analyze.js +205 -68
  21. package/dist/core/search/fts-crash-marker.d.ts +59 -0
  22. package/dist/core/search/fts-crash-marker.js +55 -0
  23. package/dist/core/search/fts-indexes.js +2 -2
  24. package/dist/core/search/fts-policy.d.ts +11 -1
  25. package/dist/core/search/fts-policy.js +47 -0
  26. package/dist/core/tree-sitter/vendored-grammars.d.ts +2 -10
  27. package/dist/core/tree-sitter/vendored-grammars.js +2 -11
  28. package/dist/core/vendor-root.d.ts +8 -0
  29. package/dist/core/vendor-root.js +10 -0
  30. package/dist/mcp/server.js +10 -3
  31. package/dist/server/api.js +21 -14
  32. package/dist/storage/repo-meta.d.ts +31 -2
  33. package/package.json +2 -2
  34. package/scripts/assert-publish-fts-coverage.cjs +246 -0
  35. package/scripts/cross-platform-shard.ts +7 -2
  36. package/scripts/cross-platform-tests.ts +15 -0
  37. package/scripts/ensure-fts.ts +13 -11
  38. package/vendor/lbug-fts/LICENSE +27 -0
  39. package/vendor/lbug-fts/manifest.json +21 -0
  40. package/vendor/lbug-fts/prebuilds/SHA256SUMS +5 -0
  41. package/vendor/lbug-fts/prebuilds/darwin-arm64/libfts.lbug_extension +0 -0
  42. package/vendor/lbug-fts/prebuilds/darwin-x64/libfts.lbug_extension +0 -0
  43. package/vendor/lbug-fts/prebuilds/linux-arm64/libfts.lbug_extension +0 -0
  44. package/vendor/lbug-fts/prebuilds/linux-x64/libfts.lbug_extension +0 -0
  45. package/vendor/lbug-fts/prebuilds/win32-x64/libfts.lbug_extension +0 -0
  46. package/web/assets/{agent-DeT_Hy8W.js → agent-DiLVv9Xg.js} +8 -8
  47. package/web/assets/{index-K4KOdfVv.js → index-coee0RPm.js} +3 -3
  48. package/web/index.html +1 -1
@@ -11,6 +11,8 @@ import { warnIfQueryTextUnbounded } from './query-batch.js';
11
11
  import { escapeCypherString } from './cypher-escape.js';
12
12
  import { withConnLock } from './conn-lock.js';
13
13
  import { isWalDriverActive } from './wal-driver-state.js';
14
+ import { loadMeta } from '../../storage/repo-meta.js';
15
+ import { allowsFtsCrashWalPark, hasRecoveredInPlaceFtsAbort } from '../search/fts-crash-marker.js';
14
16
  import { NODE_TABLES, REL_TABLE_NAME, SCHEMA_QUERIES, EMBEDDING_TABLE_NAME, CREATE_VECTOR_INDEX_QUERY, STALE_HASH_SENTINEL, } from './schema.js';
15
17
  // Analyze-only, but reached from MCP startup via `pool-adapter.js`. #2802
16
18
  // proposed lazy-importing it; rejected — `core/search/bm25-index.ts` statically
@@ -25,9 +27,10 @@ import { EMBEDDABLE_LABELS } from '../embeddings/types.js';
25
27
  import { extensionManager, getFtsCapability, resolveAnalyzeInstallPolicy, } from './extension-loader.js';
26
28
  // Remedy classification for LOAD failures (#2374/#2383). Pure + node:fs only, so
27
29
  // this adds no cycle: `extension-loader.ts` already depends on it.
28
- import { diagnoseExtensionLoad } from './extension-load-error.js';
30
+ import { diagnoseExtensionLoad, extractExtensionPath } from './extension-load-error.js';
31
+ import { resolveFtsVersionPair } from './vendored-extension-path.js';
29
32
  import { classifyDeleteAllError, closeLbugConnection, HANDLE_RELEASE_PROBE_ATTEMPTS, HANDLE_RELEASE_PROBE_DELAY_MS, isDbBusyError, isOpenRetryExhausted, isStorageVersionMismatchError, isWalCorruptionError, throwIfStorageVersionMismatch, bufferPoolExhaustionRemedy, openLbugConnection, sleep, toNativeSafePath, resolveNativeSafeStorageDir, WAL_RECOVERY_SUGGESTION, waitForWindowsHandleRelease, } from './lbug-config.js';
30
- import { cleanQuarantinedMissingShadowWals, finalizeLbugSidecarsAfterClose, guardWalQuarantine, isMissingShadowSidecarError, isReadOnlyShadowReplayError, lbugLockRemediation, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, shadowSidecarRecoveryMessage, sidecarPreflightDisabled, } from './sidecar-recovery.js';
33
+ import { assertReadOnlyFtsCrashSafe, cleanQuarantinedMissingShadowWals, finalizeLbugSidecarsAfterClose, guardWalQuarantine, isMissingShadowSidecarError, isReadOnlyShadowReplayError, lbugLockRemediation, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, shadowSidecarRecoveryMessage, sidecarPreflightDisabled, } from './sidecar-recovery.js';
31
34
  import { isProcessAlive } from '../../utils/process-identity.js';
32
35
  import { logger } from '../logger.js';
33
36
  import { SPRING_AUTO_CONFIGURATION_REASONS, SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX, } from '../ingestion/frameworks/spring/auto-configuration.js';
@@ -196,10 +199,9 @@ const DB_LOCK_RETRY_DELAY_MS = 500;
196
199
  /**
197
200
  * Return true when the error message indicates a write was attempted against
198
201
  * a read-only LadybugDB connection. The MCP query pool opens DBs read-only,
199
- * so any path that calls a `CREATE_*` procedure there will surface this
200
- * (e.g. defensive `ensureFTSIndex` calls). Owners of the writable analyze
201
- * path should ignore this error — index creation is owned by `gitnexus
202
- * analyze` and either already happened or will happen on the next run.
202
+ * so any path that calls a `CREATE_*` procedure there will surface this.
203
+ * Index creation is owned by `gitnexus analyze` and either already happened
204
+ * or will happen on the next run.
203
205
  */
204
206
  export const isReadOnlyDbError = (err) => {
205
207
  // Walk the `cause` chain (bounded) so a wrapped read-only error — e.g. the
@@ -426,8 +428,10 @@ const READ_ONLY_SHADOW_REPLAY_PROBE = 'MATCH (n) RETURN n LIMIT 1';
426
428
  * `guardWalQuarantine` (sidecar-recovery.ts) so serve and the MCP pool share
427
429
  * one source of truth (PR #1747 review D2; issue #2382 review, Finding B).
428
430
  */
429
- const refuseLargeWalQuarantine = async (dbPath, mode, triggeringErr) => {
430
- await guardWalQuarantine(dbPath, mode, triggeringErr, logger);
431
+ const refuseLargeWalQuarantine = async (dbPath, mode, triggeringErr, crashEvidence) => {
432
+ // Latitude defaults to refusal. Only the analyze writer passes
433
+ // `fts-inplace-checkpointed`; serve never does (R9).
434
+ await guardWalQuarantine(dbPath, mode, triggeringErr, logger, crashEvidence);
431
435
  };
432
436
  const reopenReadOnlyAfterMissingShadow = async (dbPath, err) => {
433
437
  await refuseLargeWalQuarantine(dbPath, 'read-only', err);
@@ -454,8 +458,25 @@ const reopenReadOnlyAfterMissingShadow = async (dbPath, err) => {
454
458
  throw retryErr;
455
459
  }
456
460
  };
461
+ const writableFtsCrashWalEvidence = async (dbPath) => {
462
+ try {
463
+ const meta = await loadMeta(path.dirname(dbPath));
464
+ if (meta &&
465
+ (allowsFtsCrashWalPark(meta.incrementalInProgress) ||
466
+ hasRecoveredInPlaceFtsAbort(meta.capabilities?.fts))) {
467
+ return { kind: 'fts-inplace-checkpointed' };
468
+ }
469
+ }
470
+ catch {
471
+ return undefined;
472
+ }
473
+ return undefined;
474
+ };
457
475
  const reopenWritableAfterMissingShadow = async (dbPath, err) => {
458
- await refuseLargeWalQuarantine(dbPath, 'writable', err);
476
+ // Analyze writers may park a leftover in-place FTS abort WAL. Serve/embed
477
+ // refuse first via assertReadOnlyFtsCrashSafe and must not pass this
478
+ // evidence themselves (R9); read-only reopen never parks.
479
+ await refuseLargeWalQuarantine(dbPath, 'writable', err, await writableFtsCrashWalEvidence(dbPath));
459
480
  try {
460
481
  await quarantineWalForMissingShadow(dbPath, {
461
482
  logger,
@@ -662,6 +683,7 @@ const doInitLbug = async (dbPath, options = {}) => {
662
683
  // create databases and don't need the lock.
663
684
  // ---------------------------------------------------------------------------
664
685
  if (readOnly) {
686
+ await assertReadOnlyFtsCrashSafe(dbPath);
665
687
  await preflightLbugSidecars(dbPath, {
666
688
  mode: 'read-only',
667
689
  logger,
@@ -2984,8 +3006,8 @@ export const loadVectorExtension = async (targetConn, opts = {}) => {
2984
3006
  };
2985
3007
  /**
2986
3008
  * Default stemmer for FTS indexes. Single source so the analyze path
2987
- * (`getSearchFTSStemmer`) and the read-only `createFTSIndex`/`ensureFTSIndex`
2988
- * defaults can never silently diverge.
3009
+ * (`getSearchFTSStemmer`) and `createFTSIndex` defaults can never silently
3010
+ * diverge.
2989
3011
  */
2990
3012
  export const DEFAULT_FTS_STEMMER = 'porter';
2991
3013
  /**
@@ -3284,41 +3306,6 @@ export const ensureFtsRowDmlSafe = async (indexRows, options = {}) => {
3284
3306
  return false;
3285
3307
  return await loadFTSExtension(undefined, { policy: resolveAnalyzeInstallPolicy() });
3286
3308
  };
3287
- /**
3288
- * Lazy-create an FTS index, caching the fact in-process.
3289
- *
3290
- * Kept for writable maintenance paths that need to lazily materialize an
3291
- * index. Read-only query paths must not call this; production analysis owns
3292
- * creating the configured search indexes before the database is served.
3293
- *
3294
- * Safe to call repeatedly — the in-process Set guarantees only the first
3295
- * call hits LadybugDB. `closeLbug` clears the cache so re-init starts fresh.
3296
- *
3297
- * Defense in depth: if the active connection is read-only (e.g. the MCP
3298
- * pool adapter), `CREATE_FTS_INDEX` will fail with "Cannot execute write
3299
- * operations in a read-only database". Treat that as a no-op and cache
3300
- * the key so callers don't loop on a path that can never succeed here —
3301
- * the index is owned by `gitnexus analyze` (writable) and either already
3302
- * exists or will be created on the next analyze.
3303
- */
3304
- export const ensureFTSIndex = async (tableName, indexName, properties, stemmer = DEFAULT_FTS_STEMMER) => {
3305
- const key = ftsIndexKey(tableName, indexName);
3306
- if (ensuredFTSIndexes.has(key))
3307
- return;
3308
- try {
3309
- await createFTSIndex(tableName, indexName, properties, stemmer);
3310
- }
3311
- catch (e) {
3312
- // Read-only DB: writable analyze owns index creation; silently skip
3313
- // and cache so callers don't loop on a path that can never succeed
3314
- // here (the MCP query pool opens DBs read-only by design).
3315
- if (isReadOnlyDbError(e)) {
3316
- ensuredFTSIndexes.add(key);
3317
- return;
3318
- }
3319
- throw e;
3320
- }
3321
- };
3322
3309
  /**
3323
3310
  * Classify a `QUERY_FTS_INDEX` failure so a genuinely-missing index (normal —
3324
3311
  * this table's FTS index hasn't been built yet) is distinguished from a real
@@ -3541,7 +3528,9 @@ export const dropFTSIndex = async (tableName, indexName) => {
3541
3528
  // extension binary is not re-inspected, falling back to a fresh structural
3542
3529
  // diagnosis when nothing recorded one.
3543
3530
  const ftsCapability = getFtsCapability();
3544
- const { remedy } = ftsCapability?.diagnosis ?? diagnoseExtensionLoad(ftsCapability?.reason);
3531
+ const inspectPath = extractExtensionPath(ftsCapability?.reason);
3532
+ const { remedy } = ftsCapability?.diagnosis ??
3533
+ diagnoseExtensionLoad(ftsCapability?.reason, 'FTS', inspectPath, resolveFtsVersionPair(inspectPath));
3545
3534
  // Deliberately message-only: `remedy` is generated text (fixed system paths
3546
3535
  // at most), and LadybugDB's own path-bearing `reason` is NEVER interpolated
3547
3536
  // here — the #2374/#2375 redaction contract.
@@ -29,7 +29,11 @@ export interface FtsProbeResult {
29
29
  loaded: boolean;
30
30
  /** Collapsed LadybugDB error when `loaded` is false. */
31
31
  reason?: string;
32
+ /** Policy `never` refused the probe — not a load failure. */
33
+ suppressed?: boolean;
32
34
  }
35
+ export type FtsAvailabilityLabel = 'available' | 'unavailable' | 'suppressed';
36
+ export declare const ftsAvailabilityLabel: (probe: FtsProbeResult) => FtsAvailabilityLabel;
33
37
  /** Same shape for every optional extension; `FtsProbeResult` is the legacy name. */
34
38
  export type ExtensionProbeResult = FtsProbeResult;
35
39
  /**
@@ -48,7 +52,16 @@ export type ExtensionProbeResult = FtsProbeResult;
48
52
  * cannot cancel an in-flight native call, so a future thread-blocking case
49
53
  * would need an out-of-process probe.
50
54
  */
51
- export declare function probeFtsExtensionLoad(timeoutMs?: number): Promise<FtsProbeResult>;
55
+ /** Local copy of the env policy parse — must not import extension-loader
56
+ * (that module statically pulls lbug-config, which would break doctor when
57
+ * the native addon is missing). */
58
+ type ProbeInstallPolicy = 'auto' | 'load-only' | 'never';
59
+ export interface FtsProbeOptions {
60
+ policy?: ProbeInstallPolicy;
61
+ /** Injected vendored path. `null` skips path-LOAD; omit to resolve from disk. */
62
+ vendoredPath?: string | null;
63
+ }
64
+ export declare function probeFtsExtensionLoad(timeoutMs?: number, opts?: FtsProbeOptions): Promise<FtsProbeResult>;
52
65
  /**
53
66
  * Live-probe `LOAD EXTENSION vector`, the VECTOR counterpart of the FTS probe.
54
67
  *
@@ -65,3 +78,4 @@ export declare function probeFtsExtensionLoad(timeoutMs?: number): Promise<FtsPr
65
78
  * as the FTS one above.
66
79
  */
67
80
  export declare function probeVectorExtensionLoad(timeoutMs?: number): Promise<ExtensionProbeResult>;
81
+ export {};
@@ -2,6 +2,8 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { escapeCypherString } from './cypher-escape.js';
6
+ import { resolveVendoredFtsPath } from './vendored-extension-path.js';
5
7
  /** Cap the out-of-process native load probe so a hung filesystem cannot wedge a
6
8
  * CLI startup gate (same bounding rationale as the extension probe below). */
7
9
  const NATIVE_LOAD_PROBE_TIMEOUT_MS = 15_000;
@@ -334,6 +336,13 @@ export function glibcTooOldMessage(stderr) {
334
336
  ' - Or use the GitNexus container image, which bundles a current glibc.',
335
337
  ].join('\n');
336
338
  }
339
+ export const ftsAvailabilityLabel = (probe) => {
340
+ if (probe.loaded)
341
+ return 'available';
342
+ if (probe.suppressed)
343
+ return 'suppressed';
344
+ return 'unavailable';
345
+ };
337
346
  const DEFAULT_FTS_PROBE_TIMEOUT_MS = 10_000;
338
347
  /** Close each result, swallowing close-time errors so a successful LOAD is not
339
348
  * misreported as a failure (native-check keeps no static lbug dependency, so it
@@ -349,24 +358,23 @@ const closeProbeResults = (result) => {
349
358
  }
350
359
  }
351
360
  };
352
- /**
353
- * Live-probe `LOAD EXTENSION fts` on a throwaway in-memory database.
354
- *
355
- * `doctor` used to print the static platform capability, which contradicted
356
- * analyze whenever the extension file was missing or unloadable (#2374).
357
- * LOAD never touches the network, so the probe is safe offline, and it
358
- * surfaces LadybugDB's real error — which distinguishes a missing extension
359
- * file from a present-but-broken one (wrong platform, truncated download).
360
- * Dynamic import so doctor still runs when the native module itself is broken.
361
- *
362
- * Bounded by `timeoutMs`: an unresponsive extension file (e.g. on a hung
363
- * network home dir) must never freeze `doctor` — the tool the degradation
364
- * warnings send users to. `Promise.race` lets doctor report and move on; it
365
- * cannot cancel an in-flight native call, so a future thread-blocking case
366
- * would need an out-of-process probe.
367
- */
368
- export async function probeFtsExtensionLoad(timeoutMs = DEFAULT_FTS_PROBE_TIMEOUT_MS) {
369
- return await probeExtensionLoad('fts', timeoutMs);
361
+ const resolveProbeInstallPolicy = () => {
362
+ const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
363
+ if (raw === 'load-only' || raw === 'never' || raw === 'auto')
364
+ return raw;
365
+ return 'load-only';
366
+ };
367
+ export async function probeFtsExtensionLoad(timeoutMs = DEFAULT_FTS_PROBE_TIMEOUT_MS, opts) {
368
+ const policy = opts?.policy ?? resolveProbeInstallPolicy();
369
+ if (policy === 'never') {
370
+ return {
371
+ loaded: false,
372
+ suppressed: true,
373
+ reason: 'suppressed by policy GITNEXUS_LBUG_EXTENSION_INSTALL=never',
374
+ };
375
+ }
376
+ const vendoredPath = opts?.vendoredPath !== undefined ? opts.vendoredPath : resolveVendoredFtsPath();
377
+ return await probeExtensionLoad('fts', timeoutMs, vendoredPath);
370
378
  }
371
379
  /**
372
380
  * Live-probe `LOAD EXTENSION vector`, the VECTOR counterpart of the FTS probe.
@@ -389,7 +397,7 @@ export async function probeVectorExtensionLoad(timeoutMs = DEFAULT_FTS_PROBE_TIM
389
397
  /**
390
398
  * Shared LOAD probe. `extension` is a fixed internal literal, never user input.
391
399
  */
392
- async function probeExtensionLoad(extension, timeoutMs) {
400
+ async function probeExtensionLoad(extension, timeoutMs, vendoredPath) {
393
401
  let timer;
394
402
  const timeout = new Promise((resolve) => {
395
403
  timer = setTimeout(() => resolve({
@@ -397,6 +405,11 @@ async function probeExtensionLoad(extension, timeoutMs) {
397
405
  reason: 'probe timed out — extension file or filesystem unresponsive',
398
406
  }), timeoutMs);
399
407
  });
408
+ const statements = [];
409
+ if (extension === 'fts' && vendoredPath) {
410
+ statements.push(`LOAD EXTENSION '${escapeCypherString(path.resolve(vendoredPath))}'`);
411
+ }
412
+ statements.push(`LOAD EXTENSION ${extension}`);
400
413
  const probe = (async () => {
401
414
  try {
402
415
  const { default: lbug } = await import('@ladybugdb/core');
@@ -405,9 +418,19 @@ async function probeExtensionLoad(extension, timeoutMs) {
405
418
  try {
406
419
  const conn = new lbug.Connection(db);
407
420
  try {
408
- const result = await conn.query(`LOAD EXTENSION ${extension}`);
409
- closeProbeResults(result);
410
- return { loaded: true };
421
+ let lastReason;
422
+ for (const sql of statements) {
423
+ try {
424
+ const result = await conn.query(sql);
425
+ closeProbeResults(result);
426
+ return { loaded: true };
427
+ }
428
+ catch (err) {
429
+ const message = err instanceof Error ? err.message : String(err);
430
+ lastReason = message.replace(/\s+/g, ' ').trim();
431
+ }
432
+ }
433
+ return { loaded: false, reason: lastReason };
411
434
  }
412
435
  finally {
413
436
  await conn.close().catch(() => { });
@@ -20,7 +20,7 @@ import { isReadOnlyDbError, loadFTSExtension, loadVectorExtension } from './lbug
20
20
  import { closeQueryResults } from './query-result-utils.js';
21
21
  import { warnIfQueryTextUnbounded } from './query-batch.js';
22
22
  import { createLbugDatabase, isWalCorruptionError, sleep, throwIfStorageVersionMismatch, toNativeSafePath, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js';
23
- import { guardWalQuarantine, isMissingFsError, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, statIfExists, } from './sidecar-recovery.js';
23
+ import { assertReadOnlyFtsCrashSafe, FtsReaderUnrepairableError, guardWalQuarantine, isMissingFsError, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, statIfExists, } from './sidecar-recovery.js';
24
24
  export async function statDbIdentity(dbPath) {
25
25
  try {
26
26
  const s = await fs.stat(dbPath);
@@ -439,6 +439,9 @@ async function tryQuarantineForMissingShadow(dbPath, opts) {
439
439
  // refuseLargeWalQuarantine (issue #2382 review, Finding B). Kept OUTSIDE the
440
440
  // try so the actionable recovery message propagates to the MCP caller rather
441
441
  // than being re-wrapped as a rename failure.
442
+ // Never pass crash evidence: the pool is a reader/MCP surface and must
443
+ // keep today's large-WAL refusal (R9). Analyze parks via the dirty-recovery
444
+ // family before it opens.
442
445
  await guardWalQuarantine(dbPath, opts.reason, opts.err, poolSidecarLogger);
443
446
  try {
444
447
  const quarantinePath = await quarantineWalForMissingShadow(dbPath, {
@@ -502,6 +505,7 @@ async function openReadOnlyDatabase(dbPath) {
502
505
  let db;
503
506
  silenceStdout();
504
507
  try {
508
+ await assertReadOnlyFtsCrashSafe(dbPath);
505
509
  await preflightLbugSidecars(dbPath, {
506
510
  mode: 'read-only',
507
511
  logger: poolSidecarLogger,
@@ -706,6 +710,9 @@ async function doInitLbug(repoId, dbPath) {
706
710
  // Not retryable: the on-disk file's storage version doesn't change
707
711
  // on its own. Fail immediately with an actionable message.
708
712
  throwIfStorageVersionMismatch(lastError);
713
+ if (lastError instanceof FtsReaderUnrepairableError) {
714
+ throw lastError;
715
+ }
709
716
  if (isWalCorruptionError(lastError)) {
710
717
  try {
711
718
  const db = await tryQuarantineAndReopen(dbPath, repoId);
@@ -25,6 +25,31 @@ export interface SidecarRecoveryLogger {
25
25
  debug?: (message: string) => void;
26
26
  }
27
27
  export declare const TINY_ORPHAN_WAL_BYTES: number;
28
+ /**
29
+ * Analyze-writer crash evidence for WAL quarantine latitude (KTD5).
30
+ * `mode` is a warning label only and must not carry this. Omit on serve
31
+ * and the MCP pool — those keep today's large-WAL refusal.
32
+ */
33
+ export type WalCrashEvidence = {
34
+ readonly kind: 'fts-inplace-checkpointed';
35
+ };
36
+ export declare const CLEAN_LBUG_SIDECARS_COMMAND = "gitnexus clean --lbug-sidecars";
37
+ export declare const FTS_READER_REPAIR_COMMAND = "gitnexus analyze --repair-fts";
38
+ export declare const ftsReaderRefuseMessage: (dbPath: string) => string;
39
+ export declare class FtsReaderUnrepairableError extends Error {
40
+ readonly code: "FTS_READER_UNREPAIRABLE";
41
+ constructor(dbPath: string);
42
+ }
43
+ /**
44
+ * Advisory reader gate (KTD10 / R9b). When meta names an in-place FTS
45
+ * abort (live dirty flag, or a persisted in-place `native-abort`) and a
46
+ * WAL is still live, refuse before the native open. Does not write,
47
+ * rename, or repair. Parking still requires the conjunctive
48
+ * `allowsFtsCrashWalPark` warrant. Missing or unreadable meta falls
49
+ * through to today's open path.
50
+ */
51
+ export declare const assertReadOnlyFtsCrashSafe: (dbPath: string) => Promise<void>;
52
+ export declare const ftsCrashParkFailureMessage: (failedPath: string, err?: unknown) => string;
28
53
  export declare const isMissingFsError: (err: unknown) => boolean;
29
54
  export declare const sidecarPreflightDisabled: () => boolean;
30
55
  export declare const statIfExists: (filePath: string) => Promise<{
@@ -96,7 +121,7 @@ export declare function inspectLbugSidecars(dbPath: string): Promise<LbugSidecar
96
121
  * the existing recovery path is safe to proceed. `mode` is a label used only in
97
122
  * the warning text (e.g. 'read-only', 'writable', 'pool read-only recovery').
98
123
  */
99
- export declare const guardWalQuarantine: (dbPath: string, mode: string, triggeringErr: unknown, logger: SidecarRecoveryLogger) => Promise<void>;
124
+ export declare const guardWalQuarantine: (dbPath: string, mode: string, triggeringErr: unknown, logger: SidecarRecoveryLogger, crashEvidence?: WalCrashEvidence) => Promise<void>;
100
125
  export declare function quarantineWalForMissingShadow(dbPath: string, options: {
101
126
  logger: SidecarRecoveryLogger;
102
127
  level?: 'debug' | 'info' | 'warn';
@@ -175,6 +200,12 @@ export declare function finalizeLbugSidecarsAfterClose(dbPath: string, options:
175
200
  * every subsequent open this run performs is replay-free — or the entry is
176
201
  * in `failed` and the caller MUST abort before any DB open.
177
202
  *
203
+ * Retention: these parks are not reclaimed on the next writable open
204
+ * (unlike missing-shadow quarantines). FTS-phase parks stay until
205
+ * `gitnexus clean --lbug-sidecars` or the next park overwrites the same
206
+ * fixed `.dirty-recovery` name. That is intentional — the parked bytes
207
+ * are the only forensic copy of a proven in-place abort.
208
+ *
178
209
  * @returns `moved` — destination paths now holding the parked bytes;
179
210
  * `removed` — source sidecars whose bytes are GONE (forensics lost, replay
180
211
  * risk eliminated); `failed` — source sidecars still in place: a
@@ -1,7 +1,56 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
+ import { loadMeta } from '../../storage/repo-meta.js';
4
+ import { shouldRefuseFtsCrashWal } from '../search/fts-crash-marker.js';
3
5
  import { HANDLE_RELEASE_PROBE_ATTEMPTS, HANDLE_RELEASE_PROBE_DELAY_MS, sleep, } from './lbug-config.js';
4
6
  export const TINY_ORPHAN_WAL_BYTES = 4 * 1024;
7
+ export const CLEAN_LBUG_SIDECARS_COMMAND = 'gitnexus clean --lbug-sidecars';
8
+ export const FTS_READER_REPAIR_COMMAND = 'gitnexus analyze --repair-fts';
9
+ export const ftsReaderRefuseMessage = (dbPath) => `Cannot open ${path.basename(dbPath)} read-only after an in-place FTS abort. ` +
10
+ `The leftover WAL would replay and kill this process. ` +
11
+ `Run \`${FTS_READER_REPAIR_COMMAND}\` after stopping any GitNexus MCP or serve process.`;
12
+ export class FtsReaderUnrepairableError extends Error {
13
+ code = 'FTS_READER_UNREPAIRABLE';
14
+ constructor(dbPath) {
15
+ super(ftsReaderRefuseMessage(dbPath));
16
+ this.name = 'FtsReaderUnrepairableError';
17
+ }
18
+ }
19
+ const sidecarHasLiveWal = (state) => state.kind === 'orphan-wal' ||
20
+ state.kind === 'tiny-orphan-wal' ||
21
+ state.kind === 'wal-with-shadow';
22
+ /**
23
+ * Advisory reader gate (KTD10 / R9b). When meta names an in-place FTS
24
+ * abort (live dirty flag, or a persisted in-place `native-abort`) and a
25
+ * WAL is still live, refuse before the native open. Does not write,
26
+ * rename, or repair. Parking still requires the conjunctive
27
+ * `allowsFtsCrashWalPark` warrant. Missing or unreadable meta falls
28
+ * through to today's open path.
29
+ */
30
+ export const assertReadOnlyFtsCrashSafe = async (dbPath) => {
31
+ let meta;
32
+ try {
33
+ meta = await loadMeta(path.dirname(dbPath));
34
+ }
35
+ catch {
36
+ return;
37
+ }
38
+ if (!meta || !shouldRefuseFtsCrashWal(meta.incrementalInProgress, meta.capabilities?.fts)) {
39
+ return;
40
+ }
41
+ const state = await inspectLbugSidecars(dbPath);
42
+ if (!sidecarHasLiveWal(state))
43
+ return;
44
+ throw new FtsReaderUnrepairableError(dbPath);
45
+ };
46
+ export const ftsCrashParkFailureMessage = (failedPath, err) => {
47
+ const detail = err instanceof Error ? err.message : err != null ? String(err) : '';
48
+ return (`Cannot park ${path.basename(failedPath)} after an in-place FTS abort` +
49
+ (detail ? ` (${detail})` : '') +
50
+ `. The database was not opened. Run \`${CLEAN_LBUG_SIDECARS_COMMAND}\` ` +
51
+ 'after stopping any GitNexus MCP or serve process, then retry ' +
52
+ '`gitnexus analyze` or `gitnexus analyze --repair-fts`.');
53
+ };
5
54
  /**
6
55
  * Counter-based warn anti-spam (PR #1747 review, Finding 6).
7
56
  *
@@ -251,7 +300,7 @@ export async function inspectLbugSidecars(dbPath) {
251
300
  * the existing recovery path is safe to proceed. `mode` is a label used only in
252
301
  * the warning text (e.g. 'read-only', 'writable', 'pool read-only recovery').
253
302
  */
254
- export const guardWalQuarantine = async (dbPath, mode, triggeringErr, logger) => {
303
+ export const guardWalQuarantine = async (dbPath, mode, triggeringErr, logger, crashEvidence) => {
255
304
  const state = await inspectLbugSidecars(dbPath);
256
305
  if (state.kind === 'wal-with-shadow') {
257
306
  warnOnce(logger, `${dbPath}:present-shadow-refuse:${mode}`, `GitNexus: refusing to quarantine WAL at ${dbPath}.wal during ${mode} recovery — ` +
@@ -260,6 +309,13 @@ export const guardWalQuarantine = async (dbPath, mode, triggeringErr, logger) =>
260
309
  throw new Error(presentShadowUnreachableMessage(dbPath, triggeringErr));
261
310
  }
262
311
  if (state.kind === 'orphan-wal') {
312
+ if (crashEvidence?.kind === 'fts-inplace-checkpointed') {
313
+ const { failed } = await quarantineSidecarsForDirtyRecovery(dbPath, (message) => logger.warn(message));
314
+ if (failed.length > 0) {
315
+ throw new Error(ftsCrashParkFailureMessage(failed[0]));
316
+ }
317
+ return;
318
+ }
263
319
  warnOnce(logger, `${dbPath}:large-wal-refuse:${mode}`, `GitNexus: refusing to quarantine large WAL (${state.walBytes} bytes) at ${dbPath}.wal during ${mode} recovery; ` +
264
320
  'manual recovery required — run `gitnexus analyze --force <repo-path> --index-only`.');
265
321
  throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr));
@@ -449,6 +505,12 @@ const dirtyRecoveryParkedNames = (dbPath) => DIRTY_RECOVERY_SIDECAR_SUFFIXES.fla
449
505
  * every subsequent open this run performs is replay-free — or the entry is
450
506
  * in `failed` and the caller MUST abort before any DB open.
451
507
  *
508
+ * Retention: these parks are not reclaimed on the next writable open
509
+ * (unlike missing-shadow quarantines). FTS-phase parks stay until
510
+ * `gitnexus clean --lbug-sidecars` or the next park overwrites the same
511
+ * fixed `.dirty-recovery` name. That is intentional — the parked bytes
512
+ * are the only forensic copy of a proven in-place abort.
513
+ *
452
514
  * @returns `moved` — destination paths now holding the parked bytes;
453
515
  * `removed` — source sidecars whose bytes are GONE (forensics lost, replay
454
516
  * risk eliminated); `failed` — source sidecars still in place: a
@@ -0,0 +1,26 @@
1
+ export declare const defaultVendorRoot: () => string;
2
+ export declare const nodePlatformTuple: (platform?: NodeJS.Platform, arch?: string) => string;
3
+ export interface FtsArtifactManifest {
4
+ coreVersion?: string;
5
+ extensionVersion?: string;
6
+ filename?: string;
7
+ unsupportedTuples?: Array<{
8
+ tuple: string;
9
+ reason?: string;
10
+ }>;
11
+ }
12
+ export declare const readFtsArtifactManifest: (vendorRoot?: string) => FtsArtifactManifest;
13
+ export declare const isUnsupportedFtsTuple: (tuple: string, vendorRoot?: string) => boolean;
14
+ /** Relative-path containment — not a prefix match (rejects `vendor-evil`). */
15
+ export declare const isPathInsideRoot: (root: string, candidate: string) => boolean;
16
+ export declare const validateVendoredExtensionPath: (candidate: string, vendorRoot: string) => string | null;
17
+ export declare const inferExtensionVersionFromPath: (filePath: string | null | undefined) => string | undefined;
18
+ export declare const resolveFtsVersionPair: (inspectPath?: string | null, vendorRoot?: string) => {
19
+ expected?: string;
20
+ found?: string;
21
+ };
22
+ export declare const resolveVendoredFtsPath: (opts?: {
23
+ tuple?: string;
24
+ vendorRoot?: string;
25
+ filename?: string;
26
+ }) => string | null;
@@ -0,0 +1,98 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { VENDOR_ROOT } from '../vendor-root.js';
4
+ /**
5
+ * Resolve the packaged FTS extension for this process's Node platform tuple.
6
+ *
7
+ * Lives here (not in extension-loader) so the doctor startup probe can share
8
+ * the same path without importing the loader, which statically pulls lbug-config.
9
+ */
10
+ const DEFAULT_FILENAME = 'libfts.lbug_extension';
11
+ export const defaultVendorRoot = () => VENDOR_ROOT;
12
+ export const nodePlatformTuple = (platform = process.platform, arch = process.arch) => `${platform}-${arch}`;
13
+ const asOptionalString = (value) => typeof value === 'string' && value.length > 0 ? value : undefined;
14
+ /** Drop non-array / non-object entries so `.some(entry => entry.tuple)` cannot throw. */
15
+ const asUnsupportedTuples = (value) => {
16
+ if (!Array.isArray(value))
17
+ return undefined;
18
+ const entries = [];
19
+ for (const entry of value) {
20
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
21
+ continue;
22
+ const tuple = entry.tuple;
23
+ if (typeof tuple !== 'string' || tuple.length === 0)
24
+ continue;
25
+ const reason = entry.reason;
26
+ entries.push({
27
+ tuple,
28
+ ...(typeof reason === 'string' ? { reason } : {}),
29
+ });
30
+ }
31
+ return entries;
32
+ };
33
+ export const readFtsArtifactManifest = (vendorRoot = defaultVendorRoot()) => {
34
+ const manifestPath = path.join(vendorRoot, 'lbug-fts', 'manifest.json');
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
37
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
38
+ const rec = parsed;
39
+ return {
40
+ coreVersion: asOptionalString(rec.coreVersion),
41
+ extensionVersion: asOptionalString(rec.extensionVersion),
42
+ filename: asOptionalString(rec.filename),
43
+ unsupportedTuples: asUnsupportedTuples(rec.unsupportedTuples),
44
+ };
45
+ }
46
+ return {};
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ };
52
+ export const isUnsupportedFtsTuple = (tuple, vendorRoot = defaultVendorRoot()) => (readFtsArtifactManifest(vendorRoot).unsupportedTuples ?? []).some((entry) => entry.tuple === tuple);
53
+ /** Relative-path containment — not a prefix match (rejects `vendor-evil`). */
54
+ export const isPathInsideRoot = (root, candidate) => {
55
+ const relative = path.relative(root, candidate);
56
+ if (path.isAbsolute(relative))
57
+ return false;
58
+ return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..';
59
+ };
60
+ export const validateVendoredExtensionPath = (candidate, vendorRoot) => {
61
+ let realFile;
62
+ let realRoot;
63
+ try {
64
+ realFile = realpathSync(candidate);
65
+ realRoot = realpathSync(vendorRoot);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ if (!/\.lbug_extension$/i.test(realFile))
71
+ return null;
72
+ if (!isPathInsideRoot(realRoot, realFile))
73
+ return null;
74
+ return realFile;
75
+ };
76
+ export const inferExtensionVersionFromPath = (filePath) => {
77
+ if (!filePath)
78
+ return undefined;
79
+ const home = /[/\\]extension[/\\](\d+\.\d+\.\d+)[/\\]/.exec(filePath);
80
+ return home?.[1];
81
+ };
82
+ export const resolveFtsVersionPair = (inspectPath, vendorRoot) => {
83
+ // Ladybug's home path is `~/.lbdb/extension/<coreVersion>/…`. Compare that
84
+ // directory to the packaged core pin, not the (often different) artifact
85
+ // version, or a matching runtime looks skewed.
86
+ const expected = readFtsArtifactManifest(vendorRoot).coreVersion;
87
+ const found = inferExtensionVersionFromPath(inspectPath);
88
+ return { expected, found };
89
+ };
90
+ export const resolveVendoredFtsPath = (opts) => {
91
+ const vendorRoot = opts?.vendorRoot ?? defaultVendorRoot();
92
+ const tuple = opts?.tuple ?? nodePlatformTuple();
93
+ const filename = opts?.filename ?? readFtsArtifactManifest(vendorRoot).filename ?? DEFAULT_FILENAME;
94
+ const candidate = path.resolve(vendorRoot, 'lbug-fts', 'prebuilds', tuple, filename);
95
+ if (!existsSync(candidate))
96
+ return null;
97
+ return validateVendoredExtensionPath(candidate, vendorRoot);
98
+ };
@@ -65,9 +65,12 @@ export declare const runCheckpointWithRetry: (options?: {
65
65
  *
66
66
  * Honors the `GITNEXUS_WAL_MANUAL_CHECKPOINT=0` opt-out so operators can
67
67
  * disable the manual path if it ever interacts badly with a future
68
- * Ladybug release.
68
+ * Ladybug release. Returns true only when a CHECKPOINT actually flushed
69
+ * (`tryFlushWAL` → true). Opt-out and a no-op flush (no open connection)
70
+ * both return false so an FTS park warrant cannot treat a skipped
71
+ * checkpoint as success.
69
72
  */
70
- export declare const checkpointOnce: () => Promise<void>;
73
+ export declare const checkpointOnce: () => Promise<boolean>;
71
74
  /**
72
75
  * Start a periodic manual checkpoint driver. The returned handle has a
73
76
  * `stop()` method that resolves once the in-flight checkpoint (if any)
@@ -108,12 +108,16 @@ export const runCheckpointWithRetry = async (options = {}) => {
108
108
  *
109
109
  * Honors the `GITNEXUS_WAL_MANUAL_CHECKPOINT=0` opt-out so operators can
110
110
  * disable the manual path if it ever interacts badly with a future
111
- * Ladybug release.
111
+ * Ladybug release. Returns true only when a CHECKPOINT actually flushed
112
+ * (`tryFlushWAL` → true). Opt-out and a no-op flush (no open connection)
113
+ * both return false so an FTS park warrant cannot treat a skipped
114
+ * checkpoint as success.
112
115
  */
113
116
  export const checkpointOnce = async () => {
114
117
  if (!isManualCheckpointEnabled())
115
- return;
116
- await runCheckpointWithRetry();
118
+ return false;
119
+ const { flushed } = await runCheckpointWithRetry();
120
+ return flushed;
117
121
  };
118
122
  /** Default cadence (ms) for the periodic driver. */
119
123
  const DEFAULT_PERIOD_MS = 5_000;
@@ -228,8 +228,10 @@ export interface AnalyzeResult {
228
228
  * `extension-unavailable` (the LadybugDB FTS extension could not load — the
229
229
  * offline-first case, remedied by installing it) vs `build-failed` (the
230
230
  * extension loaded but the index build/verify failed non-fatally — remedied by
231
- * `--repair-fts`, not by installing the extension). Lets the CLI show the
232
- * correct recovery hint instead of always blaming a missing extension.
231
+ * `--repair-fts`, not by installing the extension) vs `native-abort` (inferred
232
+ * on the next run from an FTS-phase crash) vs `tuple-missing` (no packaged
233
+ * artifact for this platform). Lets the CLI show the correct recovery hint
234
+ * instead of always blaming a missing extension.
233
235
  * `disabled-by-flag` and `disabled-by-env` record intentional opt-out;
234
236
  * neither calls for extension installation or repair.
235
237
  */