fix(cli): defer managed Python runtime install to first semantic-layer call

createManagedPythonSemanticLayerComputePort is now synchronous and resolves
the managed runtime lazily on the first query/validate/generate call, caching
the port on success. An MCP session that only searches never installs the
Python runtime at boot, avoiding the eager-install crash loop on uv-less hosts.
This commit is contained in:
Andrey Avtomonov 2026-06-18 14:02:24 +02:00
parent c439feb50f
commit 96fb94c89a
6 changed files with 150 additions and 82 deletions

View file

@ -121,26 +121,43 @@ export async function ensureManagedPythonCommandRuntime(
}
}
export async function createManagedPythonSemanticLayerComputePort(
export function createManagedPythonSemanticLayerComputePort(
options: ManagedPythonSemanticLayerComputeOptions,
): Promise<KtxSemanticLayerComputePort> {
const runtime = await ensureManagedPythonCommandRuntime({
cliVersion: options.cliVersion,
installPolicy: options.installPolicy,
io: options.io,
feature: 'core',
...(options.readStatus ? { readStatus: options.readStatus } : {}),
...(options.installRuntime ? { installRuntime: options.installRuntime } : {}),
...(options.confirmInstall ? { confirmInstall: options.confirmInstall } : {}),
...(options.spinner ? { spinner: options.spinner } : {}),
});
): KtxSemanticLayerComputePort {
const createPythonCompute = options.createPythonCompute ?? createPythonSemanticLayerComputePort;
const projectId = options.projectDir
? await readExistingTelemetryProjectId({ projectDir: options.projectDir })
: undefined;
return createPythonCompute({
command: runtime.manifest.python.daemonExecutable,
args: [],
...(projectId ? { projectId } : {}),
});
let cachedPort: KtxSemanticLayerComputePort | undefined;
// Runtime install is deferred to the first compute call so an MCP session
// that only searches never installs the Python runtime at boot. Cached on
// success, so a failed install retries on the next call.
const resolvePort = async (): Promise<KtxSemanticLayerComputePort> => {
if (cachedPort) {
return cachedPort;
}
const runtime = await ensureManagedPythonCommandRuntime({
cliVersion: options.cliVersion,
installPolicy: options.installPolicy,
io: options.io,
feature: 'core',
...(options.readStatus ? { readStatus: options.readStatus } : {}),
...(options.installRuntime ? { installRuntime: options.installRuntime } : {}),
...(options.confirmInstall ? { confirmInstall: options.confirmInstall } : {}),
...(options.spinner ? { spinner: options.spinner } : {}),
});
const projectId = options.projectDir
? await readExistingTelemetryProjectId({ projectDir: options.projectDir })
: undefined;
cachedPort = createPythonCompute({
command: runtime.manifest.python.daemonExecutable,
args: [],
...(projectId ? { projectId } : {}),
});
return cachedPort;
};
return {
query: async (input) => (await resolvePort()).query(input),
validateSources: async (input) => (await resolvePort()).validateSources(input),
generateSources: async (input) => (await resolvePort()).generateSources(input),
};
}

View file

@ -26,7 +26,7 @@ export async function createKtxMcpServerFactory(input: {
}): Promise<() => McpServer> {
const io = input.io ?? noopMcpIo();
const queryExecutor = createKtxCliIngestQueryExecutor(input.project);
const semanticLayerCompute = await createManagedPythonSemanticLayerComputePort({
const semanticLayerCompute = createManagedPythonSemanticLayerComputePort({
cliVersion: input.cliVersion,
installPolicy: 'auto',
io,

View file

@ -80,7 +80,7 @@ interface KtxSlDeps {
installPolicy: KtxManagedPythonInstallPolicy;
io: KtxSlIo;
projectDir?: string;
}) => Promise<KtxSemanticLayerComputePort>;
}) => KtxSemanticLayerComputePort;
createQueryExecutor?: (project: KtxLocalProject) => KtxSqlQueryExecutorPort;
}
@ -315,7 +315,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
queryForTelemetry = query;
const compute = deps.createSemanticLayerCompute
? deps.createSemanticLayerCompute()
: await (deps.createManagedSemanticLayerCompute ?? createManagedPythonSemanticLayerComputePort)({
: (deps.createManagedSemanticLayerCompute ?? createManagedPythonSemanticLayerComputePort)({
cliVersion: args.cliVersion,
installPolicy: args.runtimeInstallPolicy,
io,

View file

@ -186,64 +186,111 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
]);
});
it('uses the managed ktx-daemon executable when the runtime is ready', async () => {
it('does not touch the runtime when the port is only constructed', () => {
const io = makeIo();
const readStatus = vi.fn(async () => readyStatus());
const installRuntime = vi.fn(async () => installResult());
const createPythonCompute = vi.fn(() => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() }));
createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'auto',
io: io.io,
readStatus,
installRuntime,
createPythonCompute,
});
expect(readStatus).not.toHaveBeenCalled();
expect(installRuntime).not.toHaveBeenCalled();
expect(createPythonCompute).not.toHaveBeenCalled();
expect(io.stderr()).toBe('');
});
it('uses the managed ktx-daemon executable on the first compute call', async () => {
const io = makeIo();
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const createPythonCompute = vi.fn(() => compute);
await expect(
createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'never',
io: io.io,
readStatus: vi.fn(async () => readyStatus()),
installRuntime: vi.fn(),
createPythonCompute,
}),
).resolves.toBe(compute);
const port = createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'never',
io: io.io,
readStatus: vi.fn(async () => readyStatus()),
installRuntime: vi.fn(),
createPythonCompute,
});
await port.validateSources({ sources: [], dialect: 'postgres' });
expect(createPythonCompute).toHaveBeenCalledWith({
command: '/runtime/0.2.0/.venv/bin/ktx-daemon',
args: [],
});
expect(compute.validateSources).toHaveBeenCalledWith({ sources: [], dialect: 'postgres' });
expect(io.stderr()).toBe('');
});
it('fails with a preparation command when input is disabled and the runtime is missing', async () => {
it('resolves the runtime once across multiple compute calls', async () => {
const io = makeIo();
const readStatus = vi.fn(async () => readyStatus());
const createPythonCompute = vi.fn(() => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() }));
const port = createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'never',
io: io.io,
readStatus,
installRuntime: vi.fn(),
createPythonCompute,
});
await port.validateSources({ sources: [], dialect: 'postgres' });
await port.validateSources({ sources: [], dialect: 'postgres' });
expect(readStatus).toHaveBeenCalledTimes(1);
expect(createPythonCompute).toHaveBeenCalledTimes(1);
});
it('fails with a preparation command on first use when input is disabled and the runtime is missing', async () => {
const io = makeIo();
const installRuntime = vi.fn();
await expect(
createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'never',
io: io.io,
readStatus: vi.fn(async () => missingStatus()),
installRuntime,
}),
).rejects.toThrow('ktx Python runtime is required for this command. Run: ktx admin runtime install --yes');
const port = createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'never',
io: io.io,
readStatus: vi.fn(async () => missingStatus()),
installRuntime,
});
await expect(port.validateSources({ sources: [], dialect: 'postgres' })).rejects.toThrow(
'ktx Python runtime is required for this command. Run: ktx admin runtime install --yes',
);
expect(installRuntime).not.toHaveBeenCalled();
});
it('installs the core runtime without prompting when policy is auto', async () => {
it('installs the core runtime without prompting on first use when policy is auto', async () => {
const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const createPythonCompute = vi.fn(() => compute);
const installRuntime = vi.fn(async () => installResult());
await expect(
createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'auto',
io: io.io,
readStatus: vi.fn(async () => missingStatus()),
installRuntime,
createPythonCompute,
spinner,
}),
).resolves.toBe(compute);
const port = createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'auto',
io: io.io,
readStatus: vi.fn(async () => missingStatus()),
installRuntime,
createPythonCompute,
spinner,
});
expect(installRuntime).not.toHaveBeenCalled();
await port.validateSources({ sources: [], dialect: 'postgres' });
expect(installRuntime).toHaveBeenCalledWith({
cliVersion: '0.2.0',
@ -256,13 +303,13 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
]);
});
it('prompts before installing when policy is prompt', async () => {
it('prompts before installing on first use when policy is prompt', async () => {
const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const confirmInstall = vi.fn(async () => true);
const installRuntime = vi.fn(async () => installResult());
await createManagedPythonSemanticLayerComputePort({
const port = createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'prompt',
io: io.io,
@ -273,6 +320,8 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
spinner,
});
await port.validateSources({ sources: [], dialect: 'postgres' });
expect(confirmInstall).toHaveBeenCalledWith(
'ktx needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?',
io.io,
@ -292,18 +341,18 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
const installRuntime = vi.fn(async (): Promise<ManagedPythonRuntimeInstallResult> => installResult());
const confirmInstall = vi.fn(async () => true);
await expect(
createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'prompt',
io: io.io,
readStatus: async () => missingStatus(),
installRuntime,
confirmInstall,
createPythonCompute: () => compute,
spinner,
}),
).resolves.toBe(compute);
const port = createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'prompt',
io: io.io,
readStatus: async () => missingStatus(),
installRuntime,
confirmInstall,
createPythonCompute: () => compute,
spinner,
});
await port.validateSources({ sources: [], dialect: 'postgres' });
expect(confirmInstall).toHaveBeenCalledWith(
'ktx needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?',
@ -316,15 +365,17 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
const io = makeIo();
Object.assign(io.io.stdout, { isTTY: false });
await expect(
createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'prompt',
io: io.io,
readStatus: async () => missingStatus(),
installRuntime: vi.fn(),
createPythonCompute: () => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() }),
}),
).rejects.toThrow('ktx Python runtime installation was cancelled');
const port = createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'prompt',
io: io.io,
readStatus: async () => missingStatus(),
installRuntime: vi.fn(),
createPythonCompute: () => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() }),
});
await expect(port.validateSources({ sources: [], dialect: 'postgres' })).rejects.toThrow(
'ktx Python runtime installation was cancelled',
);
});
});

View file

@ -62,7 +62,7 @@ vi.mock('../src/local-scan-connectors.js', () => ({
}));
vi.mock('../src/managed-python-command.js', () => ({
createManagedPythonSemanticLayerComputePort: vi.fn(async () => mocks.semanticLayerCompute),
createManagedPythonSemanticLayerComputePort: vi.fn(() => mocks.semanticLayerCompute),
}));
vi.mock('../src/managed-python-http.js', () => ({

View file

@ -751,7 +751,7 @@ joins: []
validateSources: vi.fn(),
generateSources: vi.fn(),
};
const createManagedSemanticLayerCompute = vi.fn(async () => compute);
const createManagedSemanticLayerCompute = vi.fn(() => compute);
await expect(
runKtxSl(