diff --git a/packages/cli/src/managed-python-command.ts b/packages/cli/src/managed-python-command.ts index caa300ca..6056c790 100644 --- a/packages/cli/src/managed-python-command.ts +++ b/packages/cli/src/managed-python-command.ts @@ -121,26 +121,43 @@ export async function ensureManagedPythonCommandRuntime( } } -export async function createManagedPythonSemanticLayerComputePort( +export function createManagedPythonSemanticLayerComputePort( options: ManagedPythonSemanticLayerComputeOptions, -): Promise { - 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 => { + 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), + }; } diff --git a/packages/cli/src/mcp-server-factory.ts b/packages/cli/src/mcp-server-factory.ts index ff1fc14f..dc7b3a69 100644 --- a/packages/cli/src/mcp-server-factory.ts +++ b/packages/cli/src/mcp-server-factory.ts @@ -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, diff --git a/packages/cli/src/sl.ts b/packages/cli/src/sl.ts index 6c849265..87ec2214 100644 --- a/packages/cli/src/sl.ts +++ b/packages/cli/src/sl.ts @@ -80,7 +80,7 @@ interface KtxSlDeps { installPolicy: KtxManagedPythonInstallPolicy; io: KtxSlIo; projectDir?: string; - }) => Promise; + }) => 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, diff --git a/packages/cli/test/managed-python-command.test.ts b/packages/cli/test/managed-python-command.test.ts index a782ae43..23727b88 100644 --- a/packages/cli/test/managed-python-command.test.ts +++ b/packages/cli/test/managed-python-command.test.ts @@ -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 => 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', + ); }); }); diff --git a/packages/cli/test/mcp-server-factory.test.ts b/packages/cli/test/mcp-server-factory.test.ts index 7ca2d2b7..f320e5b6 100644 --- a/packages/cli/test/mcp-server-factory.test.ts +++ b/packages/cli/test/mcp-server-factory.test.ts @@ -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', () => ({ diff --git a/packages/cli/test/sl.test.ts b/packages/cli/test/sl.test.ts index 489ea950..e99e9d95 100644 --- a/packages/cli/test/sl.test.ts +++ b/packages/cli/test/sl.test.ts @@ -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(