mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-25 12:01:03 +02:00
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:
parent
c439feb50f
commit
96fb94c89a
6 changed files with 150 additions and 82 deletions
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue