mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* fix(mcp): surface resolved repo/branch/indexed-at in the FTS-degraded warning Turns the generic "FTS indexes missing" message into a diagnostic that reveals what this MCP session actually resolved, so a CLI/MCP mismatch or stale-connection theory is visible in the warning text itself instead of requiring a separate debugging round-trip (#2767). * fix(mcp): stop swallowing real FTS query errors behind the missing-index message queryFTSViaExecutor previously collapsed 'index genuinely missing' and 'a real query/connection error occurred' into the same silent null, so a real failure could masquerade as the generic FTS-degraded message with no diagnostic trail — even when it happened on only some of the per-table queries while others succeeded. Classifies the failure (mirroring queryFTS's own check for this exact cypher call), always logs a non-benign error server-side regardless of overall outcome, and surfaces it (redacted) in the client warning only when every table failed (#2767). * fix(mcp): give --repair-fts a dedicated freshness signal for warm readers --repair-fts intentionally never restamps indexedAt (it doesn't regenerate the graph), so a long-lived MCP session's pool staleness check had no explicit signal that a repair happened, only the incidental file-identity delta. Reuses the existing (forensic-only) capabilities.fts.status field: repair-fts now stamps just that sub-field (everything else byte-identical), and ensureInitialized compares it as a third, independent reinit trigger alongside the existing stamp/identity checks, seeded at cold init too so a fresh process's first warm check doesn't false-trigger (#2767). * test(mcp): warm session picks up an out-of-band --repair-fts rebuild (#2767) New end-to-end integration test: a real writable LadybugDB session builds an index WITHOUT FTS, a real LocalBackend observes 'FTS indexes missing' through the real pool, a separate writable session performs the exact repair-fts writes (real createSearchFTSIndexes + the #2767 capability-only meta stamp), and the SAME still-warm backend re-queries successfully without a restart — closing the one end-to-end gap no existing test covered. Running this against the real engine surfaced a second real message shape for a missing FTS index ("doesn't have an index with name X", not just "does not exist") that the U2 classifier didn't recognize — fixed classifyFtsQueryError to match both, with a regression test pinning the exact observed string. * fix(review): address code-review findings on the #2767 FTS fix - Anchor classifyFtsQueryError to the exception class (mirroring isBenignDropFtsIndexError) instead of a bare substring search, so a real, differently-classed error that happens to echo the benign phrase in its body (e.g. an echoed user query) can't be misclassified as a benign missing-index (adversarial review). - Re-read the on-disk meta immediately before the --repair-fts capability stamp write instead of reusing the pre-rebuild snapshot, so a concurrent writer (e.g. the HTTP server's background embedding checkpoint job) landing mid-repair isn't silently reverted. - Surface a client-facing partial-result warning (mirroring the existing enrichmentDegraded convention) when some FTS tables succeed but at least one hits a real error, instead of only logging it server-side. - Update RepoMeta.capabilities' stale 'no programmatic readers' docstring now that ensureInitialized reads capabilities.fts.status. - Widen the warm-session integration test's polling deadline for more margin over the production 5s staleness-check throttle. * fix(ci): drop the cold-init loadMeta call ensureInitialized never needed It stole the mocked loadMeta call an unrelated upstream PDG test depends on (test/integration/impact-pdg-statement-precise.test.ts queues a single mockResolvedValueOnce for its own PDG-config read; the extra call consumed that slot before the PDG code ran, so it fell through to the mock's null default and epistemic came back undefined instead of 'pdg-intra-procedural'). Cold init now leaves lastObservedFtsStatus unseeded — the cost is at most one redundant initLbug call on the first warm check, which no-ops via a single fs.stat when nothing actually changed, not a real reopen. * fix(review): address tri-review findings on the #2767 FTS fix Fixes two P1s (misleading repair-fts advice on real query errors; embedding-checkpoint job silently reverting the capabilities.fts stamp for up to its 30-minute lifetime), five P2/P3s (stale indexedAt in warnings, extension-unavailable noise, mismatched log severity, a table-missing vs index-missing conflation confirmed against a live LadybugDB, and a reinit-watermark latching bug), and the four residual items already self-disclosed in this PR's description (shared FTS error classifier, consolidated per-pool observed-state map, a redactPaths whitespace gap, and an isolated ftsCapsChanged test). A /simplify pass afterward caught one more real bug: the extension-unavailable short-circuit only guarded the MCP pool path, so the CLI-path fix above it started surfacing spurious non-benign errors for the same expected degraded state the pool path stays silent on — now both paths agree. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
228 lines
8.1 KiB
TypeScript
228 lines
8.1 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
extensionManager,
|
|
getExtensionCapabilities,
|
|
resetExtensionState,
|
|
} from '../../src/core/lbug/extension-loader.js';
|
|
import { ftsDegradedWarning } from '../../src/core/search/fts-indexes.js';
|
|
|
|
afterEach(() => {
|
|
resetExtensionState();
|
|
});
|
|
|
|
describe('ftsDegradedWarning (#2374)', () => {
|
|
it('reports missing indexes when the FTS extension loaded fine', async () => {
|
|
await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', {
|
|
policy: 'load-only',
|
|
});
|
|
|
|
expect(ftsDegradedWarning()).toContain('FTS indexes missing');
|
|
});
|
|
|
|
it('reports the live load failure with its reason when the extension cannot load', async () => {
|
|
await extensionManager.ensure(
|
|
vi.fn().mockRejectedValue(new Error('invalid ELF header.')),
|
|
'fts',
|
|
'FTS',
|
|
{ policy: 'load-only' },
|
|
);
|
|
|
|
const warning = ftsDegradedWarning();
|
|
expect(warning).toContain('FTS extension failed to load');
|
|
expect(warning).toContain('invalid ELF header');
|
|
expect(warning).toContain('gitnexus doctor');
|
|
});
|
|
|
|
it('falls back to the indexes-missing message when no load was attempted in this process', () => {
|
|
expect(ftsDegradedWarning()).toContain('FTS indexes missing');
|
|
});
|
|
|
|
it('redacts the absolute extension path from the warning but keeps the error class', async () => {
|
|
await extensionManager.ensure(
|
|
vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new Error(
|
|
"Failed to load library '/home/alice/.lbdb/extension/0.18.0/linux_amd64/fts/libfts.lbug_extension': invalid ELF header",
|
|
),
|
|
),
|
|
'fts',
|
|
'FTS',
|
|
{ policy: 'load-only' },
|
|
);
|
|
|
|
const warning = ftsDegradedWarning();
|
|
// The username / home dir / absolute path must not leak to HTTP or MCP clients.
|
|
expect(warning).not.toMatch(/\/home\/|\/Users\/|C:\\Users\\/);
|
|
// …but the actionable error class survives redaction.
|
|
expect(warning).toContain('FTS extension failed to load');
|
|
expect(warning).toContain('Failed to load library');
|
|
expect(warning).toContain('invalid ELF header');
|
|
});
|
|
|
|
it('redacts Windows-style extension paths too', async () => {
|
|
await extensionManager.ensure(
|
|
vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new Error(
|
|
"Failed to load library 'C:\\Users\\bob\\.lbdb\\extension\\0.18.0\\win_amd64\\fts\\libfts.lbug_extension': not a valid Win32 application",
|
|
),
|
|
),
|
|
'fts',
|
|
'FTS',
|
|
{ policy: 'load-only' },
|
|
);
|
|
|
|
const warning = ftsDegradedWarning();
|
|
expect(warning).not.toMatch(/C:\\Users\\/);
|
|
expect(warning).toContain('not a valid Win32 application');
|
|
});
|
|
|
|
it('fully redacts a Windows path containing a space in the username (tri-review Residual-3, was only partially redacted)', async () => {
|
|
await extensionManager.ensure(
|
|
vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new Error(
|
|
"Failed to load library 'C:\\Users\\alice smith\\.lbdb\\extension\\0.18.0\\win_amd64\\fts\\libfts.lbug_extension': not a valid Win32 application",
|
|
),
|
|
),
|
|
'fts',
|
|
'FTS',
|
|
{ policy: 'load-only' },
|
|
);
|
|
|
|
const warning = ftsDegradedWarning();
|
|
// Neither the drive-letter prefix NOR the tail after the space may leak.
|
|
expect(warning).not.toMatch(/C:\\Users\\/);
|
|
expect(warning).not.toContain('smith');
|
|
expect(warning).not.toContain('alice');
|
|
expect(warning).toContain('not a valid Win32 application');
|
|
});
|
|
|
|
it('surfaces the runtime-install remedy, not reinstall, for a Windows missing-dependency error', async () => {
|
|
await extensionManager.ensure(
|
|
vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new Error(
|
|
"Failed to load library 'C:\\Users\\bob\\.lbdb\\extension\\0.18.0\\win_amd64\\fts\\libfts.lbug_extension' which is needed by extension: fts. Error: The specified module could not be found.",
|
|
),
|
|
),
|
|
'fts',
|
|
'FTS',
|
|
{ policy: 'load-only' },
|
|
);
|
|
|
|
const warning = ftsDegradedWarning();
|
|
expect(warning).toContain('FTS extension failed to load');
|
|
expect(warning).toMatch(/Visual C\+\+/);
|
|
expect(warning).toMatch(/vc_redist\.x64\.exe/);
|
|
// The old "reinstall with network access" tail must not appear for this class.
|
|
expect(warning).not.toMatch(/with network access to reinstall/);
|
|
// Absolute path still redacted from the client-facing warning.
|
|
expect(warning).not.toMatch(/C:\\Users\\/);
|
|
});
|
|
|
|
it('keeps the reinstall guidance for a never-installed extension', async () => {
|
|
await extensionManager.ensure(
|
|
vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new Error('Extension "fts" is an official extension and has not been installed.'),
|
|
),
|
|
'fts',
|
|
'FTS',
|
|
{ policy: 'load-only' },
|
|
);
|
|
|
|
expect(ftsDegradedWarning()).toContain('--repair-fts');
|
|
});
|
|
|
|
it('caches the load diagnosis on the capability so the warning does no per-request I/O (#2383 F3)', async () => {
|
|
await extensionManager.ensure(
|
|
vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new Error(
|
|
"Failed to load library '/home/alice/.lbdb/extension/0.18.0/linux_amd64/fts/libfts.lbug_extension': The specified module could not be found.",
|
|
),
|
|
),
|
|
'fts',
|
|
'FTS',
|
|
{ policy: 'load-only' },
|
|
);
|
|
// The diagnosis is computed ONCE at mark-unavailable time and cached on the
|
|
// capability, so ftsDegradedWarning (per-request on /api/search + MCP query)
|
|
// reads it instead of re-inspecting the extension file on every call.
|
|
const fts = getExtensionCapabilities().find((c) => c.name === 'fts');
|
|
expect(fts).toMatchObject({ loaded: false, diagnosis: { kind: 'missing_dependency' } });
|
|
expect(ftsDegradedWarning()).toMatch(/Visual C\+\+/);
|
|
});
|
|
});
|
|
|
|
describe('ftsDegradedWarning resolved-repo context (#2767)', () => {
|
|
it('omits the context suffix entirely when no context is passed (unchanged message)', async () => {
|
|
await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', {
|
|
policy: 'load-only',
|
|
});
|
|
|
|
expect(ftsDegradedWarning()).toBe(
|
|
'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.',
|
|
);
|
|
});
|
|
|
|
it('appends the resolved repo name and indexed-at on the indexes-missing branch', async () => {
|
|
await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', {
|
|
policy: 'load-only',
|
|
});
|
|
|
|
const warning = ftsDegradedWarning({
|
|
repoName: 'myrepo',
|
|
indexedAt: '2026-07-30T12:00:00.000Z',
|
|
});
|
|
expect(warning).toContain('FTS indexes missing');
|
|
expect(warning).toContain('resolved: myrepo');
|
|
expect(warning).toContain('indexed 2026-07-30T12:00:00.000Z');
|
|
});
|
|
|
|
it('includes the branch label when the resolved handle is branch-scoped', async () => {
|
|
await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', {
|
|
policy: 'load-only',
|
|
});
|
|
|
|
const warning = ftsDegradedWarning({ repoName: 'myrepo', branch: 'feature/x' });
|
|
expect(warning).toContain('branch:feature/x');
|
|
});
|
|
|
|
it('never leaks an absolute path via the context suffix', async () => {
|
|
await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', {
|
|
policy: 'load-only',
|
|
});
|
|
|
|
const warning = ftsDegradedWarning({
|
|
repoName: 'myrepo',
|
|
lastErrorRedacted: 'connection reset',
|
|
});
|
|
expect(warning).not.toMatch(/\/home\/|\/Users\/|C:\\Users\\/);
|
|
expect(warning).toContain('last error: connection reset');
|
|
});
|
|
|
|
it('also renders the context suffix on the extension-failed-to-load branch', async () => {
|
|
await extensionManager.ensure(
|
|
vi.fn().mockRejectedValue(new Error('invalid ELF header.')),
|
|
'fts',
|
|
'FTS',
|
|
{ policy: 'load-only' },
|
|
);
|
|
|
|
const warning = ftsDegradedWarning({
|
|
repoName: 'myrepo',
|
|
indexedAt: '2026-07-30T12:00:00.000Z',
|
|
});
|
|
expect(warning).toContain('FTS extension failed to load');
|
|
expect(warning).toContain('resolved: myrepo');
|
|
expect(warning).toContain('indexed 2026-07-30T12:00:00.000Z');
|
|
});
|
|
});
|