fix(cli): make connection-not-configured errors actionable and expected (#301)

The MCP sql_execution/sl_query tools and the `ktx sql` CLI threw a plain Error naming no valid connection ids when an agent passed an unconfigured connectionId (or omitted it with multiple connections). The message reached the agent verbatim but gave it nothing to correct with, so it re-guessed for days, and each correct caller-driven rejection filed in PostHog Error Tracking as a ktx fault (issue 019eb10c, 8 occurrences on one install).

Add a shared resolver (resolveConfiguredConnection / resolveRequiredConnectionId) that throws KtxExpectedError listing the configured connections, and route the three SQL-execution call sites through it. Expected-error classification keeps these out of Error Tracking while the actionable message lets agents self-correct.
This commit is contained in:
Andrey Avtomonov 2026-06-15 14:38:44 +02:00 committed by GitHub
parent 9587049283
commit 8a50601582
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 189 additions and 20 deletions

View file

@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import {
buildDefaultKtxProjectConfig,
type KtxProjectConfig,
} from '../../../src/context/project/config.js';
import {
resolveConfiguredConnection,
resolveRequiredConnectionId,
} from '../../../src/context/connections/resolve-connection.js';
import { KtxExpectedError } from '../../../src/errors.js';
function configWith(ids: string[]): KtxProjectConfig {
const config = buildDefaultKtxProjectConfig();
for (const id of ids) {
config.connections[id] = { driver: 'postgres' };
}
return config;
}
describe('resolveConfiguredConnection', () => {
it('returns the connection config when the id is configured', () => {
const config = configWith(['warehouse']);
expect(resolveConfiguredConnection(config, 'warehouse')).toEqual({ driver: 'postgres' });
});
it('throws an expected error that lists the configured connections', () => {
const config = configWith(['analytics', 'warehouse']);
let error: unknown;
try {
resolveConfiguredConnection(config, 'ARK');
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(KtxExpectedError);
expect((error as Error).message).toBe(
'Connection "ARK" is not configured in ktx.yaml. Configured connections: analytics, warehouse.',
);
});
it('reports when no connections are configured at all', () => {
const config = configWith([]);
expect(() => resolveConfiguredConnection(config, 'warehouse')).toThrow(
'Connection "warehouse" is not configured in ktx.yaml. No connections are configured in ktx.yaml.',
);
});
});
describe('resolveRequiredConnectionId', () => {
it('returns the requested id when it is configured', () => {
const config = configWith(['warehouse']);
expect(resolveRequiredConnectionId(config, 'warehouse')).toBe('warehouse');
});
it('throws an expected error listing connections when the requested id is unknown', () => {
const config = configWith(['analytics', 'warehouse']);
expect(() => resolveRequiredConnectionId(config, 'DIG_SMART_REP')).toThrow(KtxExpectedError);
expect(() => resolveRequiredConnectionId(config, 'DIG_SMART_REP')).toThrow(
'Connection "DIG_SMART_REP" is not configured in ktx.yaml. Configured connections: analytics, warehouse.',
);
});
it('defaults to the only connection when the id is omitted', () => {
const config = configWith(['warehouse']);
expect(resolveRequiredConnectionId(config, undefined)).toBe('warehouse');
});
it('throws an expected error listing connections when the id is omitted and several exist', () => {
const config = configWith(['analytics', 'warehouse']);
let error: unknown;
try {
resolveRequiredConnectionId(config, undefined);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(KtxExpectedError);
expect((error as Error).message).toBe(
'connectionId is required. Configured connections: analytics, warehouse.',
);
});
});

View file

@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initKtxProject } from '../../../src/context/project/project.js';
import { KtxQueryError } from '../../../src/errors.js';
import { KtxExpectedError, KtxQueryError } from '../../../src/errors.js';
import { createKtxConnectorCapabilities, type KtxQueryResult, type KtxScanConnector, type KtxSchemaSnapshot } from '../../../src/context/scan/types.js';
import { SemanticLayerService } from '../../../src/context/sl/semantic-layer.service.js';
import type { SemanticLayerSource } from '../../../src/context/sl/types.js';
@ -245,6 +245,43 @@ describe('createLocalProjectMcpContextPorts', () => {
expect(connector.cleanup).toHaveBeenCalled();
});
it('rejects sql_execution against an unconfigured connection with an actionable expected error', async () => {
const project = await initKtxProject({ projectDir: tempDir });
project.config.connections.warehouse = {
driver: 'postgres',
url: 'env:DATABASE_URL',
};
const connector = testConnector(testSnapshot(), {
headers: ['id'],
headerTypes: ['integer'],
rows: [[1]],
totalRows: 1,
rowCount: 1,
});
const createConnector = vi.fn(async () => connector);
const sqlAnalysis = {
analyzeForFingerprint: vi.fn(),
analyzeBatch: vi.fn(),
validateReadOnly: vi.fn(async () => ({ ok: true, error: null })),
};
const ports = createLocalProjectMcpContextPorts(project, {
sqlAnalysis,
localScan: { createConnector },
embeddingService: null,
});
const execution = ports.sqlExecution?.execute({
connectionId: 'DIG_SMART_REP',
sql: 'select 1',
maxRows: 5,
});
await expect(execution).rejects.toBeInstanceOf(KtxExpectedError);
await expect(execution).rejects.toThrow(
'Connection "DIG_SMART_REP" is not configured in ktx.yaml. Configured connections: warehouse.',
);
expect(createConnector).not.toHaveBeenCalled();
});
it('emits sql_execution progress stages from local MCP ports', async () => {
const project = await initKtxProject({ projectDir: tempDir });
project.config.connections.warehouse = {

View file

@ -324,7 +324,7 @@ grain: []
).rejects.toThrow('Local semantic-layer execution requires a query executor.');
});
it('requires connectionId when multiple connections are configured', async () => {
it('requires connectionId, listing the configured connections, when several exist', async () => {
project.config.connections.analytics = { driver: 'bigquery' };
await expect(
@ -332,6 +332,16 @@ grain: []
query: { measures: ['orders.order_count'], dimensions: [] },
compute,
}),
).rejects.toThrow('connectionId is required when the local project has zero or multiple connections.');
).rejects.toThrow('connectionId is required. Configured connections: analytics, warehouse.');
});
it('rejects a connectionId that is not configured, listing the configured connections', async () => {
await expect(
compileLocalSlQuery(project, {
connectionId: 'DIG_SMART_REP',
query: { measures: ['orders.order_count'], dimensions: [] },
compute,
}),
).rejects.toThrow('Connection "DIG_SMART_REP" is not configured in ktx.yaml. Configured connections: warehouse.');
});
});