fix: surface silent failures and drop unused dead-code paths (#193)

Address overengineering audit findings across cli/context/connector packages:

- F1 Snowflake `query`: drop bare catch that flattened all errors to empty result
- F2 memory-agent: treat LLM `stopReason === 'error'` as crash (skip squash-merge)
- F3 WikiSearchTool: description honest about token-only fallback vs sqlite-fts5 hybrid
- F5 Scan enrichment provider resolution: return discriminated status and surface
  distinct `llm_unavailable` / `embedding_unavailable` warnings per failure mode
- F6 Relationship validation budget: drop dead `tableCount === undefined → 'all'`
  branch; update tests to pass `tableCount` like production
- F8 `ktx sql`: use canonical `resolveOutputMode` (now honors KTX_OUTPUT/CI/TTY)
- F9 MCP stdio server: default `protocolIo.stderr` to `process.stderr` so
  memory_ingest startup failures are visible
- F13/F14 Scan/setup JSON readers: distinguish ENOENT from corruption instead of
  silently treating both as missing
- F15 `createKtxCliScanConnector`: throw config-shape error when driver matches
  but type guard rejects, instead of "no native connector"
- F16 ContextEvidenceSearchTool: surface `embedding_unhealthy:<reason>` instead
  of silently dropping the semantic lane
- F17 PromptService: default partials to `[]` (removes stale `clinical_policy`
  reference from a prior product)
- F20 `contextBuildCommands`: drop unused `runId` parameter

Dead-code removal:

- F4 Delete `AgentRunnerService` (duplicated `RuntimeAgentRunner`, only test-used);
  migrate tests to exercise `AiSdkKtxLlmRuntime.runAgentLoop` directly
- F7 Delete `KtxScanOrchestrator` and its test (no production callers; the
  inline pipeline in `runLocalScan` is the single source of truth)
- F18 Delete `generateKtxText`/`generateKtxObject` pass-through helpers; inline
  the single `runtime.generateObject` call at its caller

Plus a clarifying comment on the SQLite `resolveStringReference` `file:` carve-out
(load-bearing for SQLite URI form, not a bug).
This commit is contained in:
Andrey Avtomonov 2026-05-21 02:38:18 +02:00 committed by GitHub
parent 7737ccaf1a
commit 0958bc03dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 186 additions and 820 deletions

View file

@ -19,47 +19,60 @@ export async function createKtxCliScanConnector(
}
if (driver === 'sqlite' || driver === 'sqlite3') {
const { KtxSqliteScanConnector, isKtxSqliteConnectionConfig } = await import('@ktx/connector-sqlite');
if (isKtxSqliteConnectionConfig(connection)) {
return new KtxSqliteScanConnector({ connectionId, connection, projectDir: project.projectDir });
if (!isKtxSqliteConnectionConfig(connection)) {
throw invalidConnectionConfigError(connectionId, driver);
}
return new KtxSqliteScanConnector({ connectionId, connection, projectDir: project.projectDir });
}
if (driver === 'postgres' || driver === 'postgresql') {
const { KtxPostgresScanConnector, isKtxPostgresConnectionConfig } = await import('@ktx/connector-postgres');
if (isKtxPostgresConnectionConfig(connection)) {
return new KtxPostgresScanConnector({ connectionId, connection });
if (!isKtxPostgresConnectionConfig(connection)) {
throw invalidConnectionConfigError(connectionId, driver);
}
return new KtxPostgresScanConnector({ connectionId, connection });
}
if (driver === 'mysql') {
const { KtxMysqlScanConnector, isKtxMysqlConnectionConfig } = await import('@ktx/connector-mysql');
if (isKtxMysqlConnectionConfig(connection)) {
return new KtxMysqlScanConnector({ connectionId, connection });
if (!isKtxMysqlConnectionConfig(connection)) {
throw invalidConnectionConfigError(connectionId, driver);
}
return new KtxMysqlScanConnector({ connectionId, connection });
}
if (driver === 'clickhouse') {
const { KtxClickHouseScanConnector, isKtxClickHouseConnectionConfig } = await import('@ktx/connector-clickhouse');
if (isKtxClickHouseConnectionConfig(connection)) {
return new KtxClickHouseScanConnector({ connectionId, connection });
if (!isKtxClickHouseConnectionConfig(connection)) {
throw invalidConnectionConfigError(connectionId, driver);
}
return new KtxClickHouseScanConnector({ connectionId, connection });
}
if (driver === 'sqlserver') {
const { KtxSqlServerScanConnector, isKtxSqlServerConnectionConfig } = await import('@ktx/connector-sqlserver');
if (isKtxSqlServerConnectionConfig(connection)) {
return new KtxSqlServerScanConnector({ connectionId, connection });
if (!isKtxSqlServerConnectionConfig(connection)) {
throw invalidConnectionConfigError(connectionId, driver);
}
return new KtxSqlServerScanConnector({ connectionId, connection });
}
if (driver === 'bigquery') {
const { KtxBigQueryScanConnector, isKtxBigQueryConnectionConfig } = await import('@ktx/connector-bigquery');
if (isKtxBigQueryConnectionConfig(connection)) {
return new KtxBigQueryScanConnector({ connectionId, connection });
if (!isKtxBigQueryConnectionConfig(connection)) {
throw invalidConnectionConfigError(connectionId, driver);
}
return new KtxBigQueryScanConnector({ connectionId, connection });
}
if (driver === 'snowflake') {
const { KtxSnowflakeScanConnector, isKtxSnowflakeConnectionConfig } = await import('@ktx/connector-snowflake');
if (isKtxSnowflakeConnectionConfig(connection)) {
return new KtxSnowflakeScanConnector({ connectionId, connection });
if (!isKtxSnowflakeConnectionConfig(connection)) {
throw invalidConnectionConfigError(connectionId, driver);
}
return new KtxSnowflakeScanConnector({ connectionId, connection });
}
throw new Error(
`Connection "${connectionId}" uses driver "${driver}", which has no native standalone KTX scan connector. Supported drivers: ${SUPPORTED_DRIVERS}.`,
);
}
function invalidConnectionConfigError(connectionId: string, driver: string): Error {
return new Error(
`Connection "${connectionId}" uses driver "${driver}" but its configuration in ktx.yaml does not match the expected shape for that driver. Check the required fields for ${driver} (e.g. url/host/database).`,
);
}

View file

@ -59,7 +59,7 @@ export async function createKtxMcpServerFactory(input: {
try {
memoryIngest = createLocalProjectMemoryIngest(input.project, { semanticLayerCompute, queryExecutor });
} catch (error) {
input.io?.stderr.write(`KTX MCP memory_ingest disabled: ${error instanceof Error ? error.message : String(error)}\n`);
io.stderr.write(`KTX MCP memory_ingest disabled: ${error instanceof Error ? error.message : String(error)}\n`);
}
return () =>

View file

@ -23,7 +23,7 @@ export async function runKtxMcpStdioServer(options: RunKtxMcpStdioServerOptions)
: undefined;
const protocolIo: KtxCliIo = {
stdout: { write() {} },
stderr: options.io?.stderr ?? { write() {} },
stderr: options.io?.stderr ?? process.stderr,
};
const createMcpServer =
options.createMcpServer ??

View file

@ -206,7 +206,7 @@ describe('setup context build state', () => {
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'),
commands: contextBuildCommands(tempDir),
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
sourceProgress: [
{
@ -638,7 +638,7 @@ describe('setup context build state', () => {
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-stale'),
commands: contextBuildCommands(tempDir),
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
});
const io = makeIo();

View file

@ -125,7 +125,7 @@ async function pathExists(path: string): Promise<boolean> {
}
}
export function contextBuildCommands(projectDir: string, runId?: string): KtxSetupContextCommands {
export function contextBuildCommands(projectDir: string): KtxSetupContextCommands {
const resolvedProjectDir = resolve(projectDir);
return {
build: `ktx setup --project-dir ${resolvedProjectDir}`,
@ -177,7 +177,7 @@ function normalizeState(projectDir: string, value: unknown): KtxSetupContextStat
retryableFailedTargets: Array.isArray(record.retryableFailedTargets)
? record.retryableFailedTargets.filter((item): item is string => typeof item === 'string')
: [],
commands: contextBuildCommands(projectDir, runId),
commands: contextBuildCommands(projectDir),
...(typeof record.failureReason === 'string' ? { failureReason: record.failureReason } : {}),
...(normalizeSourceProgress(record.sourceProgress) ? { sourceProgress: normalizeSourceProgress(record.sourceProgress) } : {}),
};
@ -241,7 +241,7 @@ export async function writeKtxSetupContextState(projectDir: string, state: KtxSe
await mkdir(join(resolvedProjectDir, '.ktx', 'setup'), { recursive: true });
const normalized = normalizeState(resolvedProjectDir, {
...state,
commands: contextBuildCommands(resolvedProjectDir, state.runId),
commands: contextBuildCommands(resolvedProjectDir),
});
await writeFile(statePath(resolvedProjectDir), `${JSON.stringify(normalized, null, 2)}\n`, 'utf-8');
}
@ -323,8 +323,11 @@ function stringArrayValue(value: unknown): string[] {
async function readJsonFile(path: string): Promise<unknown | null> {
try {
return JSON.parse(await readFile(path, 'utf-8')) as unknown;
} catch {
return null;
} catch (error) {
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw new Error(`Failed to read JSON file ${path}: ${error instanceof Error ? error.message : String(error)}`);
}
}
@ -549,7 +552,7 @@ async function runBuild(
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(args.projectDir, runId),
commands: contextBuildCommands(args.projectDir),
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
};
await writeKtxSetupContextState(args.projectDir, incompleteState);
@ -663,7 +666,7 @@ async function completeExistingContext(
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(args.projectDir, runId),
commands: contextBuildCommands(args.projectDir),
});
writeExistingContextSuccess(readiness, io);
return { status: 'ready', projectDir: args.projectDir, runId };

View file

@ -331,7 +331,7 @@ describe('setup status', () => {
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'),
commands: contextBuildCommands(tempDir),
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
});
@ -505,7 +505,7 @@ describe('setup status', () => {
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-test'),
commands: contextBuildCommands(tempDir),
});
await writeKtxSetupState(tempDir, { completed_steps: ['project', 'context'] });
return { status: 'ready', projectDir: tempDir, runId: 'setup-context-local-test' };
@ -2043,7 +2043,7 @@ describe('setup status', () => {
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-ready'),
commands: contextBuildCommands(tempDir),
});
const previousRuntimeRoot = process.env.KTX_RUNTIME_ROOT;
@ -2148,7 +2148,7 @@ describe('setup status', () => {
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-ready'),
commands: contextBuildCommands(tempDir),
});
const readyMenuSelect = vi.fn();

View file

@ -2,13 +2,14 @@ import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import type { KtxQueryResult, KtxScanConnector } from '@ktx/context/scan';
import type { SqlAnalysisDialect, SqlAnalysisPort } from '@ktx/context/sql-analysis';
import type { KtxCliIo } from './cli-runtime.js';
import { type KtxOutputMode, resolveOutputMode } from './io/mode.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
import { profileMark } from './startup-profile.js';
profileMark('module:sql');
type KtxSqlOutputMode = 'pretty' | 'plain' | 'json';
type KtxSqlOutputMode = KtxOutputMode;
export type KtxSqlArgs = {
command: 'execute';
@ -53,11 +54,6 @@ function sqlAnalysisDialectForDriver(driver: string | undefined): SqlAnalysisDia
return map[normalized] ?? 'postgres';
}
function resolveOutputMode(args: KtxSqlArgs): KtxSqlOutputMode {
if (args.json === true) return 'json';
return args.output ?? 'pretty';
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
@ -159,7 +155,8 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
},
{ runId: 'cli-sql' },
);
printSqlResult(resultOutput(args.connectionId, result), resolveOutputMode(args), io);
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
printSqlResult(resultOutput(args.connectionId, result), mode, io);
return 0;
} finally {
await cleanupConnector(connector);