mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
feat: npm-managed Python runtime for @kaelio/ktx (#7)
* docs: add npm managed python runtime design * build: add bundled python runtime wheel builder * build: make local embedding dependencies optional * build: bundle python runtime wheel in cli artifacts * build: track bundled python runtime release artifact * test: verify bundled python runtime wheel * docs: add plan for bundled python runtime wheel * test: cover managed python runtime lifecycle * feat: add managed python runtime installer * feat: add runtime command runner * feat: expose runtime management commands * test: verify managed python runtime commands * docs: add plan for managed python runtime installer * feat: add managed python command helper * feat: use managed runtime for sl query compute * feat: route sl query managed runtime policy * docs: add plan for managed runtime sl query integration * feat: add managed runtime daemon metadata * feat: manage python daemon lifecycle * feat: add runtime daemon start stop commands * fix: verify managed runtime daemon lifecycle * docs: add plan for managed runtime daemon lifecycle * feat: add managed local embeddings config marker * feat: add managed local embeddings daemon helper * feat: use managed runtime for local embedding setup * feat: pass managed runtime policy through setup * docs: add plan for managed local embeddings runtime * feat: read CLI package metadata dynamically * feat: assemble public kaelio ktx npm package * feat: release one public kaelio ktx npm artifact * test: cover public kaelio ktx package invocations * chore: verify public kaelio ktx package artifacts * docs: add plan for public kaelio ktx npm package * test: verify managed runtime in public package smoke * test: finalize managed runtime release smoke * docs: add plan for managed runtime release smoke * test: specify local embeddings release smoke * feat: add local embeddings runtime smoke * chore: register local embeddings smoke * fix: verify local embeddings smoke * fix: restore artifact smoke python env helper * docs: add plan for managed local embeddings release smoke * refactor: share managed runtime install policy parsing * feat: use managed runtime for agent semantic queries * feat: use managed runtime for MCP semantic compute * docs: add plan for managed agent and MCP semantic runtime * feat(cli): add managed daemon HTTP helpers * feat(cli): route local adapters through managed daemon * feat(cli): use managed daemon for ingest helpers * feat(cli): pass managed daemon options to scan * feat(context): pass MCP ingest pull config options * feat(cli): pass managed daemon options to serve ingest * test: verify managed local ingest daemon runtime * docs: add plan for managed local ingest daemon runtime * docs: align managed runtime examples * docs: add plan for managed runtime docs cleanup * test: cover published package runtime smoke commands * test: validate published package smoke outputs * docs: add plan for published package runtime smoke * build: stamp public npm package version * release: add npm public release policy * release: add guarded npm publish script * release: document public npm release handoff * docs: add plan for public npm release handoff * test: cover managed runtime prune in package smoke * docs: document managed runtime prune * docs: add plan for managed runtime prune smoke and docs * chore: encode uv runtime prerequisite policy * fix: clarify missing uv runtime error * docs: document uv runtime prerequisite * docs: add plan for uv runtime prerequisite contract * refactor: limit release artifacts to public package runtime * chore: align release policy with bundled runtime wheel * docs: describe single public runtime artifact surface * test: verify single public runtime artifact contract * docs: add plan for single public runtime artifact cleanup * fix: align local embeddings smoke with public version * docs: add plan for local embeddings smoke public version * release: soft-launch as @kaelio/ktx@0.1.0-rc.0 on next tag Publish target moves to the pre-release version 0.1.0-rc.0 under the next dist-tag so npm install @kaelio/ktx (which resolves to latest) does not pick up the soft-launch build. Users opt in via @kaelio/ktx@next. * Fix release script boundary checks * Remove PostHog from public package bundle
This commit is contained in:
parent
075764fe77
commit
9dad936ac7
99 changed files with 25375 additions and 1538 deletions
|
|
@ -105,4 +105,48 @@ describe('agent runtime helpers', () => {
|
|||
queryExecutor,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates managed semantic compute when no test override is injected', async () => {
|
||||
const project = {
|
||||
projectDir: tempDir,
|
||||
configPath: join(tempDir, 'ktx.yaml'),
|
||||
config: { project: 'revenue', connections: {} },
|
||||
coreConfig: {},
|
||||
git: {},
|
||||
fileStore: {},
|
||||
} as never;
|
||||
const ports = { semanticLayer: {} } as never;
|
||||
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
|
||||
const loadProject = vi.fn(async () => project);
|
||||
const createContextTools = vi.fn(() => ports);
|
||||
const createManagedSemanticLayerCompute = vi.fn(async () => semanticLayerCompute);
|
||||
const { io } = makeIo();
|
||||
|
||||
await expect(
|
||||
createKtxAgentRuntime(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
enableSemanticCompute: true,
|
||||
enableQueryExecution: false,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
io,
|
||||
},
|
||||
{
|
||||
loadProject,
|
||||
createContextTools,
|
||||
createManagedSemanticLayerCompute,
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({ project, ports, semanticLayerCompute });
|
||||
|
||||
expect(createManagedSemanticLayerCompute).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io,
|
||||
});
|
||||
expect(createContextTools).toHaveBeenCalledWith(project, {
|
||||
semanticLayerCompute,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { createDefaultLocalQueryExecutor, type KtxSqlQueryExecutorPort } from '@ktx/context/connections';
|
||||
import { createPythonSemanticLayerComputePort, type KtxSemanticLayerComputePort } from '@ktx/context/daemon';
|
||||
import type { KtxSemanticLayerComputePort } from '@ktx/context/daemon';
|
||||
import { createLocalProjectMcpContextPorts, type KtxMcpContextPorts } from '@ktx/context/mcp';
|
||||
import { type KtxLocalProject, loadKtxProject } from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import {
|
||||
createManagedPythonSemanticLayerComputePort,
|
||||
type KtxManagedPythonInstallPolicy,
|
||||
} from './managed-python-command.js';
|
||||
|
||||
export const KTX_AGENT_MAX_ROWS_CAP = 1000;
|
||||
|
||||
|
|
@ -11,6 +15,9 @@ export interface KtxAgentRuntimeOptions {
|
|||
projectDir: string;
|
||||
enableSemanticCompute: boolean;
|
||||
enableQueryExecution: boolean;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
io?: KtxCliIo;
|
||||
}
|
||||
|
||||
export interface KtxAgentRuntime {
|
||||
|
|
@ -24,6 +31,7 @@ export interface KtxAgentRuntimeDeps {
|
|||
loadProject?: typeof loadKtxProject;
|
||||
createContextTools?: typeof createLocalProjectMcpContextPorts;
|
||||
createSemanticLayerCompute?: () => KtxSemanticLayerComputePort;
|
||||
createManagedSemanticLayerCompute?: typeof createManagedPythonSemanticLayerComputePort;
|
||||
createQueryExecutor?: () => KtxSqlQueryExecutorPort;
|
||||
}
|
||||
|
||||
|
|
@ -57,14 +65,34 @@ export function parseAgentMaxRows(value: number | undefined): number {
|
|||
return value;
|
||||
}
|
||||
|
||||
async function createAgentSemanticLayerCompute(
|
||||
options: KtxAgentRuntimeOptions,
|
||||
deps: KtxAgentRuntimeDeps,
|
||||
): Promise<KtxSemanticLayerComputePort | undefined> {
|
||||
if (!options.enableSemanticCompute) {
|
||||
return undefined;
|
||||
}
|
||||
if (deps.createSemanticLayerCompute) {
|
||||
return deps.createSemanticLayerCompute();
|
||||
}
|
||||
if (!options.cliVersion || !options.runtimeInstallPolicy || !options.io) {
|
||||
throw new Error('Managed Python semantic compute requires cliVersion, runtimeInstallPolicy, and io.');
|
||||
}
|
||||
const createManagedSemanticLayerCompute =
|
||||
deps.createManagedSemanticLayerCompute ?? createManagedPythonSemanticLayerComputePort;
|
||||
return createManagedSemanticLayerCompute({
|
||||
cliVersion: options.cliVersion,
|
||||
installPolicy: options.runtimeInstallPolicy,
|
||||
io: options.io,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createKtxAgentRuntime(
|
||||
options: KtxAgentRuntimeOptions,
|
||||
deps: KtxAgentRuntimeDeps = {},
|
||||
): Promise<KtxAgentRuntime> {
|
||||
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: options.projectDir });
|
||||
const semanticLayerCompute = options.enableSemanticCompute
|
||||
? (deps.createSemanticLayerCompute ?? createPythonSemanticLayerComputePort)()
|
||||
: undefined;
|
||||
const semanticLayerCompute = await createAgentSemanticLayerCompute(options, deps);
|
||||
const queryExecutor = options.enableQueryExecution
|
||||
? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)()
|
||||
: undefined;
|
||||
|
|
|
|||
|
|
@ -231,6 +231,8 @@ describe('runKtxAgent', () => {
|
|||
queryFile,
|
||||
execute: true,
|
||||
maxRows: 100,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'never',
|
||||
},
|
||||
io.io,
|
||||
{ createRuntime: async () => runtime() },
|
||||
|
|
@ -240,6 +242,39 @@ describe('runKtxAgent', () => {
|
|||
expect(JSON.parse(io.stdout())).toMatchObject({ sql: 'select 1', rows: [[1]] });
|
||||
});
|
||||
|
||||
it('passes managed runtime options into default SL query runtime creation', async () => {
|
||||
const queryFile = join(tempDir, 'sl-query.json');
|
||||
const io = makeIo();
|
||||
const createRuntime = vi.fn(async () => runtime());
|
||||
await writeFile(queryFile, '{"measures":["total_revenue"],"dimensions":[]}', 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxAgent(
|
||||
{
|
||||
command: 'sl-query',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
queryFile,
|
||||
execute: false,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
io.io,
|
||||
{ createRuntime },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(createRuntime).toHaveBeenCalledWith({
|
||||
projectDir: tempDir,
|
||||
enableSemanticCompute: true,
|
||||
enableQueryExecution: false,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
io: io.io,
|
||||
});
|
||||
});
|
||||
|
||||
it('executes read-only SQL from a SQL file with an explicit row limit', async () => {
|
||||
const sqlFile = join(tempDir, 'query.sql');
|
||||
const fakeRuntime = runtime();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
noIndexedSourcesSlSearchReadiness,
|
||||
type KtxAgentSlSearchReadinessDetail,
|
||||
} from './agent-search-readiness.js';
|
||||
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
|
||||
import { readKtxSetupStatus, type KtxSetupStatus } from './setup.js';
|
||||
|
||||
export type KtxAgentArgs =
|
||||
|
|
@ -32,6 +33,8 @@ export type KtxAgentArgs =
|
|||
queryFile: string;
|
||||
execute: boolean;
|
||||
maxRows?: number;
|
||||
cliVersion: string;
|
||||
runtimeInstallPolicy: KtxManagedPythonInstallPolicy;
|
||||
}
|
||||
| { command: 'wiki-search'; projectDir: string; json: true; query: string; limit: number }
|
||||
| { command: 'wiki-read'; projectDir: string; json: true; pageId: string }
|
||||
|
|
@ -42,6 +45,9 @@ export interface KtxAgentDeps extends KtxAgentRuntimeDeps {
|
|||
projectDir: string;
|
||||
enableSemanticCompute: boolean;
|
||||
enableQueryExecution: boolean;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
io?: KtxCliIo;
|
||||
}) => Promise<KtxAgentRuntime>;
|
||||
readSetupStatus?: (
|
||||
projectDir: string,
|
||||
|
|
@ -68,23 +74,22 @@ function writeAgentSlSearchReadinessError(io: KtxCliIo, detail: KtxAgentSlSearch
|
|||
writeAgentJsonError(io, detail.message, { code: detail.code, nextSteps: detail.nextSteps });
|
||||
}
|
||||
|
||||
async function runtimeFor(args: KtxAgentArgs, deps: KtxAgentDeps): Promise<KtxAgentRuntime> {
|
||||
async function runtimeFor(args: KtxAgentArgs, deps: KtxAgentDeps, io: KtxCliIo): Promise<KtxAgentRuntime> {
|
||||
const needsSemanticCompute = args.command === 'sl-query';
|
||||
const needsQueryExecution = args.command === 'sql-execute' || (args.command === 'sl-query' && args.execute);
|
||||
return deps.createRuntime
|
||||
? deps.createRuntime({
|
||||
projectDir: args.projectDir,
|
||||
enableSemanticCompute: needsSemanticCompute,
|
||||
enableQueryExecution: needsQueryExecution,
|
||||
})
|
||||
: createKtxAgentRuntime(
|
||||
{
|
||||
projectDir: args.projectDir,
|
||||
enableSemanticCompute: needsSemanticCompute,
|
||||
enableQueryExecution: needsQueryExecution,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
const runtimeOptions = {
|
||||
projectDir: args.projectDir,
|
||||
enableSemanticCompute: needsSemanticCompute,
|
||||
enableQueryExecution: needsQueryExecution,
|
||||
...(args.command === 'sl-query'
|
||||
? {
|
||||
cliVersion: args.cliVersion,
|
||||
runtimeInstallPolicy: args.runtimeInstallPolicy,
|
||||
io,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return deps.createRuntime ? deps.createRuntime(runtimeOptions) : createKtxAgentRuntime(runtimeOptions, deps);
|
||||
}
|
||||
|
||||
function connectionIdForSource(runtime: KtxAgentRuntime, requested: string | undefined): string {
|
||||
|
|
@ -101,7 +106,7 @@ export async function runKtxAgent(args: KtxAgentArgs, io: KtxCliIo, deps: KtxAge
|
|||
return 0;
|
||||
}
|
||||
|
||||
const runtime = await runtimeFor(args, deps);
|
||||
const runtime = await runtimeFor(args, deps, io);
|
||||
|
||||
if (args.command === 'context') {
|
||||
const [status, connections, semanticLayer] = await Promise.all([
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { registerAgentCommands } from './commands/agent-commands.js';
|
|||
import { registerConnectionCommands } from './commands/connection-commands.js';
|
||||
import { registerWikiCommands } from './commands/knowledge-commands.js';
|
||||
import { registerPublicIngestCommands } from './commands/public-ingest-commands.js';
|
||||
import { registerRuntimeCommands } from './commands/runtime-commands.js';
|
||||
import { registerServeCommands } from './commands/serve-commands.js';
|
||||
import { registerSetupCommands } from './commands/setup-commands.js';
|
||||
import { registerSlCommands } from './commands/sl-commands.js';
|
||||
|
|
@ -17,6 +18,7 @@ profileMark('module:cli-program');
|
|||
export interface KtxCliCommandContext {
|
||||
io: KtxCliIo;
|
||||
deps: KtxCliDeps;
|
||||
packageInfo: KtxCliPackageInfo;
|
||||
setExitCode: (code: number) => void;
|
||||
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise<number>;
|
||||
writeDebug?: (command: string, commandContext: CommandWithGlobalOptions) => void;
|
||||
|
|
@ -177,6 +179,7 @@ async function runBareInteractiveCommand(
|
|||
skipAgents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: context.packageInfo.version,
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -205,6 +208,7 @@ export async function runCommanderKtxCli(
|
|||
const context: KtxCliCommandContext = {
|
||||
io,
|
||||
deps,
|
||||
packageInfo: info,
|
||||
setExitCode: (code: number) => {
|
||||
exitCode = code;
|
||||
},
|
||||
|
|
@ -229,6 +233,9 @@ export async function runCommanderKtxCli(
|
|||
registerSlCommands(program, context);
|
||||
profileMark('commander:register-sl');
|
||||
|
||||
registerRuntimeCommands(program, context);
|
||||
profileMark('commander:register-runtime');
|
||||
|
||||
registerServeCommands(program, context);
|
||||
profileMark('commander:register-serve');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { createRequire } from 'node:module';
|
||||
|
||||
import type { KtxConnectionMetabaseSetupArgs } from './commands/connection-metabase-setup.js';
|
||||
import type { KtxConnectionNotionArgs } from './commands/connection-notion.js';
|
||||
import type { KtxAgentArgs } from './agent.js';
|
||||
|
|
@ -7,6 +9,7 @@ import type { KtxDoctorArgs } from './doctor.js';
|
|||
import type { KtxIngestArgs } from './ingest.js';
|
||||
import type { KtxKnowledgeArgs } from './knowledge.js';
|
||||
import type { KtxPublicIngestArgs } from './public-ingest.js';
|
||||
import type { KtxRuntimeArgs } from './runtime.js';
|
||||
import type { KtxScanArgs } from './scan.js';
|
||||
import type { KtxServeArgs } from './serve.js';
|
||||
import type { KtxSetupArgs } from './setup.js';
|
||||
|
|
@ -15,9 +18,11 @@ import { profileMark, profileSpan } from './startup-profile.js';
|
|||
|
||||
profileMark('module:cli-runtime');
|
||||
|
||||
const requirePackageJson = createRequire(import.meta.url);
|
||||
|
||||
export interface KtxCliPackageInfo {
|
||||
name: '@ktx/cli';
|
||||
version: '0.0.0-private';
|
||||
name: string;
|
||||
version: string;
|
||||
contextPackageName: '@ktx/context';
|
||||
}
|
||||
|
||||
|
|
@ -37,15 +42,31 @@ export interface KtxCliDeps {
|
|||
doctor?: (args: KtxDoctorArgs, io: KtxCliIo) => Promise<number>;
|
||||
ingest?: (args: KtxIngestArgs, io: KtxCliIo) => Promise<number>;
|
||||
publicIngest?: (args: KtxPublicIngestArgs, io: KtxCliIo) => Promise<number>;
|
||||
runtime?: (args: KtxRuntimeArgs, io: KtxCliIo) => Promise<number>;
|
||||
scan?: (args: KtxScanArgs, io: KtxCliIo) => Promise<number>;
|
||||
knowledge?: (args: KtxKnowledgeArgs, io: KtxCliIo) => Promise<number>;
|
||||
sl?: (args: KtxSlArgs, io: KtxCliIo) => Promise<number>;
|
||||
}
|
||||
|
||||
export function getKtxCliPackageInfo(): KtxCliPackageInfo {
|
||||
return packageInfoFromJson(requirePackageJson('../package.json'));
|
||||
}
|
||||
|
||||
export function packageInfoFromJson(packageJson: unknown): KtxCliPackageInfo {
|
||||
if (
|
||||
typeof packageJson !== 'object' ||
|
||||
packageJson === null ||
|
||||
!('name' in packageJson) ||
|
||||
!('version' in packageJson) ||
|
||||
typeof packageJson.name !== 'string' ||
|
||||
typeof packageJson.version !== 'string'
|
||||
) {
|
||||
throw new Error('Invalid KTX CLI package metadata');
|
||||
}
|
||||
|
||||
return {
|
||||
name: '@ktx/cli',
|
||||
version: '0.0.0-private',
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
contextPackageName: '@ktx/context',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ export const slQueryCommandSchema = z.object({
|
|||
}),
|
||||
format: z.enum(['json', 'sql']),
|
||||
execute: z.boolean(),
|
||||
cliVersion: z.string().min(1),
|
||||
runtimeInstallPolicy: z.enum(['prompt', 'auto', 'never']),
|
||||
maxRows: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Option, type Command } from '@commander-js/extra-typings';
|
|||
import type { KtxAgentArgs } from '../agent.js';
|
||||
import type { KtxCliCommandContext } from '../cli-program.js';
|
||||
import { parsePositiveIntegerOption, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
|
||||
|
||||
async function runAgent(context: KtxCliCommandContext, args: KtxAgentArgs): Promise<void> {
|
||||
const runner = context.deps.agent ?? (await import('../agent.js')).runKtxAgent;
|
||||
|
|
@ -73,10 +74,19 @@ export function registerAgentCommands(program: Command, context: KtxCliCommandCo
|
|||
.requiredOption('--connection-id <id>', 'Connection id for execution')
|
||||
.requiredOption('--query-file <path>', 'JSON semantic-layer query file')
|
||||
.option('--execute', 'Execute the compiled query against the connection', false)
|
||||
.option('--yes', 'Install the managed Python runtime without prompting when required', false)
|
||||
.option('--no-input', 'Disable interactive managed runtime installation')
|
||||
.option('--max-rows <number>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
|
||||
.action(
|
||||
async (
|
||||
options: { connectionId: string; queryFile: string; execute: boolean; maxRows?: number },
|
||||
options: {
|
||||
connectionId: string;
|
||||
queryFile: string;
|
||||
execute: boolean;
|
||||
maxRows?: number;
|
||||
yes?: boolean;
|
||||
input?: boolean;
|
||||
},
|
||||
command,
|
||||
) => {
|
||||
await runAgent(context, {
|
||||
|
|
@ -86,6 +96,8 @@ export function registerAgentCommands(program: Command, context: KtxCliCommandCo
|
|||
connectionId: options.connectionId,
|
||||
queryFile: options.queryFile,
|
||||
execute: options.execute,
|
||||
cliVersion: context.packageInfo.version,
|
||||
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
|
||||
...(options.maxRows !== undefined ? { maxRows: options.maxRows } : {}),
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { type Command, Option } from '@commander-js/extra-typings';
|
|||
import { type KtxCliCommandContext, type OutputModeOptions, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KtxCliDeps, KtxCliIo } from '../index.js';
|
||||
import type { KtxIngestArgs, KtxIngestOutputMode } from '../ingest.js';
|
||||
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/ingest-commands');
|
||||
|
|
@ -75,6 +76,7 @@ export function registerIngestCommands(
|
|||
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json', 'viz']))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts(['plain', 'viz']))
|
||||
.addOption(new Option('--viz', 'Render memory-flow TUI output').conflicts(['plain', 'json']))
|
||||
.option('--yes', 'Install the managed Python runtime without prompting when required', false)
|
||||
.option('--no-input', 'Disable interactive terminal input for visualization')
|
||||
.action(async (options, command) => {
|
||||
if (options.reportFile) {
|
||||
|
|
@ -89,6 +91,8 @@ export function registerIngestCommands(
|
|||
adapter: options.adapter,
|
||||
sourceDir: options.sourceDir ? resolve(options.sourceDir) : undefined,
|
||||
databaseIntrospectionUrl: options.databaseIntrospectionUrl || undefined,
|
||||
cliVersion: context.packageInfo.version,
|
||||
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
|
||||
...(options.debugLlmRequestFile ? { debugLlmRequestFile: resolve(options.debugLlmRequestFile) } : {}),
|
||||
outputMode: outputMode(options),
|
||||
...inputMode(options),
|
||||
|
|
|
|||
100
packages/cli/src/commands/runtime-commands.ts
Normal file
100
packages/cli/src/commands/runtime-commands.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { type Command, Option } from '@commander-js/extra-typings';
|
||||
import type { KtxCliCommandContext } from '../cli-program.js';
|
||||
import type { KtxRuntimeArgs } from '../runtime.js';
|
||||
|
||||
type RuntimeFeature = Extract<KtxRuntimeArgs, { command: 'install' }>['feature'];
|
||||
|
||||
function createRuntimeFeatureOption() {
|
||||
return new Option('--feature <feature>', 'Runtime feature level')
|
||||
.choices(['core', 'local-embeddings'])
|
||||
.default('core');
|
||||
}
|
||||
|
||||
async function runRuntimeArgs(context: KtxCliCommandContext, args: KtxRuntimeArgs): Promise<void> {
|
||||
const runner = context.deps.runtime ?? (await import('../runtime.js')).runKtxRuntime;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerRuntimeCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const runtime = program
|
||||
.command('runtime')
|
||||
.description('Install, inspect, and prune the KTX-managed Python runtime')
|
||||
.showHelpAfterError();
|
||||
|
||||
runtime
|
||||
.command('install')
|
||||
.description('Install the bundled Python runtime wheel into the managed runtime')
|
||||
.addOption(createRuntimeFeatureOption())
|
||||
.option('--yes', 'Accept runtime installation without prompting', false)
|
||||
.option('--force', 'Reinstall even when the runtime already looks ready', false)
|
||||
.action(async (options: { feature: RuntimeFeature; yes?: boolean; force?: boolean }) => {
|
||||
await runRuntimeArgs(context, {
|
||||
command: 'install',
|
||||
cliVersion: context.packageInfo.version,
|
||||
feature: options.feature,
|
||||
force: options.force === true,
|
||||
});
|
||||
});
|
||||
|
||||
runtime
|
||||
.command('start')
|
||||
.description('Start the KTX-managed Python HTTP daemon')
|
||||
.addOption(createRuntimeFeatureOption())
|
||||
.option('--force', 'Restart even when a matching daemon is already running', false)
|
||||
.action(async (options: { feature: RuntimeFeature; force?: boolean }) => {
|
||||
await runRuntimeArgs(context, {
|
||||
command: 'start',
|
||||
cliVersion: context.packageInfo.version,
|
||||
feature: options.feature,
|
||||
force: options.force === true,
|
||||
});
|
||||
});
|
||||
|
||||
runtime
|
||||
.command('stop')
|
||||
.description('Stop the KTX-managed Python HTTP daemon')
|
||||
.action(async () => {
|
||||
await runRuntimeArgs(context, {
|
||||
command: 'stop',
|
||||
cliVersion: context.packageInfo.version,
|
||||
});
|
||||
});
|
||||
|
||||
runtime
|
||||
.command('status')
|
||||
.description('Show managed Python runtime status')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
await runRuntimeArgs(context, {
|
||||
command: 'status',
|
||||
cliVersion: context.packageInfo.version,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
runtime
|
||||
.command('doctor')
|
||||
.description('Check managed Python runtime prerequisites and installation')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
await runRuntimeArgs(context, {
|
||||
command: 'doctor',
|
||||
cliVersion: context.packageInfo.version,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
runtime
|
||||
.command('prune')
|
||||
.description('Remove stale managed Python runtimes for older CLI versions')
|
||||
.option('--dry-run', 'List stale runtimes without deleting them', false)
|
||||
.option('--yes', 'Confirm deletion of stale runtime directories', false)
|
||||
.action(async (options: { dryRun?: boolean; yes?: boolean }) => {
|
||||
await runRuntimeArgs(context, {
|
||||
command: 'prune',
|
||||
cliVersion: context.packageInfo.version,
|
||||
dryRun: options.dryRun === true,
|
||||
yes: options.yes === true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
|
||||
import { type KtxCliCommandContext, parsePositiveIntegerOption, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
|
||||
import type { KtxScanArgs } from '../scan.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
|
|
@ -102,6 +103,8 @@ export function registerScanCommands(program: Command, context: KtxCliCommandCon
|
|||
)
|
||||
.option('--dry-run', 'Run without writing scan results', false)
|
||||
.option('--database-introspection-url <url>', 'Daemon URL for live-database introspection')
|
||||
.option('--yes', 'Install the managed Python runtime without prompting when required', false)
|
||||
.option('--no-input', 'Disable interactive managed runtime installation')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
|
|
@ -126,6 +129,8 @@ export function registerScanCommands(program: Command, context: KtxCliCommandCon
|
|||
detectRelationships: mode === 'relationships',
|
||||
dryRun: options.dryRun === true,
|
||||
databaseIntrospectionUrl: options.databaseIntrospectionUrl,
|
||||
cliVersion: context.packageInfo.version,
|
||||
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { type Command, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||
import { type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
|
||||
import type { KtxServeArgs } from '../serve.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
|
|
@ -20,6 +21,8 @@ export function registerServeCommands(program: Command, context: KtxCliCommandCo
|
|||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.option('--semantic-compute', 'Enable semantic-layer compute', false)
|
||||
.option('--semantic-compute-url <url>', 'HTTP semantic-layer compute URL')
|
||||
.option('--yes', 'Install the managed Python runtime without prompting when required', false)
|
||||
.option('--no-input', 'Disable interactive managed runtime installation')
|
||||
.option('--database-introspection-url <url>', 'Daemon URL for live-database introspection')
|
||||
.option('--execute-queries', 'Allow semantic-layer query execution', false)
|
||||
.option('--memory-capture', 'Enable memory capture', false)
|
||||
|
|
@ -40,6 +43,8 @@ export function registerServeCommands(program: Command, context: KtxCliCommandCo
|
|||
executeQueries: options.executeQueries === true,
|
||||
memoryCapture: options.memoryCapture === true,
|
||||
memoryModel: options.memoryModel,
|
||||
cliVersion: context.packageInfo.version,
|
||||
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
|
||||
};
|
||||
const runner = context.deps.serveStdio ?? (await import('../serve.js')).runKtxServeStdio;
|
||||
context.setExitCode(await runner(args));
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
skipAgents: options.skipAgents === true,
|
||||
inputMode: options.input === false ? 'disabled' : 'auto',
|
||||
yes: options.yes === true,
|
||||
cliVersion: context.packageInfo.version,
|
||||
...(options.anthropicApiKeyEnv ? { anthropicApiKeyEnv: options.anthropicApiKeyEnv } : {}),
|
||||
...(options.anthropicApiKeyFile ? { anthropicApiKeyFile: options.anthropicApiKeyFile } : {}),
|
||||
...(options.anthropicModel ? { anthropicModel: options.anthropicModel } : {}),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
resolveCommandProjectDir,
|
||||
} from '../cli-program.js';
|
||||
import { slQueryCommandSchema } from '../command-schemas.js';
|
||||
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
|
||||
import type { KtxSlArgs } from '../sl.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
|
|
@ -121,6 +122,8 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
.option('--include-empty', 'Include empty rows', false)
|
||||
.addOption(new Option('--format <format>', 'json or sql').choices(['json', 'sql']).default('json'))
|
||||
.option('--execute', 'Execute the compiled query', false)
|
||||
.option('--yes', 'Install the managed Python runtime without prompting when required', false)
|
||||
.option('--no-input', 'Disable interactive managed runtime installation')
|
||||
.option('--max-rows <n>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
|
||||
.action(async (options, command) => {
|
||||
if (options.measure.length === 0) {
|
||||
|
|
@ -141,6 +144,8 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
},
|
||||
format: options.format,
|
||||
execute: options.execute === true,
|
||||
cliVersion: context.packageInfo.version,
|
||||
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
|
||||
...(options.maxRows !== undefined ? { maxRows: options.maxRows } : {}),
|
||||
});
|
||||
await runSlArgs(context, args);
|
||||
|
|
|
|||
|
|
@ -234,6 +234,8 @@ describe('dev Commander tree', () => {
|
|||
detectRelationships: false,
|
||||
dryRun: true,
|
||||
databaseIntrospectionUrl: undefined,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'prompt',
|
||||
},
|
||||
scanIo.io,
|
||||
);
|
||||
|
|
@ -259,6 +261,8 @@ describe('dev Commander tree', () => {
|
|||
detectRelationships: true,
|
||||
dryRun: false,
|
||||
databaseIntrospectionUrl: undefined,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'prompt',
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
|
|
@ -661,6 +665,8 @@ describe('dev Commander tree', () => {
|
|||
adapter: 'metabase',
|
||||
sourceDir: undefined,
|
||||
databaseIntrospectionUrl: undefined,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'prompt',
|
||||
outputMode: 'json',
|
||||
},
|
||||
io.io,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import {
|
||||
getKtxCliPackageInfo,
|
||||
packageInfoFromJson,
|
||||
rendererUnavailableVizFallback,
|
||||
renderMemoryFlowTui,
|
||||
resolveVizFallback,
|
||||
|
|
@ -56,6 +57,19 @@ describe('getKtxCliPackageInfo', () => {
|
|||
version: '0.0.0-private',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes public package metadata from package.json contents', () => {
|
||||
expect(
|
||||
packageInfoFromJson({
|
||||
name: '@kaelio/ktx',
|
||||
version: '0.1.0',
|
||||
}),
|
||||
).toEqual({
|
||||
name: '@kaelio/ktx',
|
||||
version: '0.1.0',
|
||||
contextPackageName: '@ktx/context',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory-flow renderer exports', () => {
|
||||
|
|
@ -108,7 +122,7 @@ describe('runKtxCli', () => {
|
|||
await expect(runKtxCli(['--help'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx [options] [command]');
|
||||
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'serve', 'status']) {
|
||||
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'runtime', 'serve', 'status']) {
|
||||
expect(testIo.stdout()).toContain(`${command}`);
|
||||
}
|
||||
for (const removed of ['demo', 'init', 'connect', 'scan', 'ask', 'knowledge', 'agent', 'completion']) {
|
||||
|
|
@ -124,6 +138,151 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('routes runtime management commands with the CLI package version', async () => {
|
||||
const runtime = vi.fn(async () => 0);
|
||||
const installIo = makeIo();
|
||||
const startIo = makeIo();
|
||||
const stopIo = makeIo();
|
||||
const statusIo = makeIo();
|
||||
const doctorIo = makeIo();
|
||||
const pruneIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(['runtime', 'install', '--feature', 'local-embeddings', '--force', '--yes'], installIo.io, {
|
||||
runtime,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['runtime', 'start', '--feature', 'local-embeddings', '--force'], startIo.io, { runtime }),
|
||||
).resolves.toBe(0);
|
||||
await expect(runKtxCli(['runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['runtime', 'doctor'], doctorIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(0);
|
||||
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
command: 'install',
|
||||
cliVersion: '0.0.0-private',
|
||||
feature: 'local-embeddings',
|
||||
force: true,
|
||||
},
|
||||
installIo.io,
|
||||
);
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
command: 'start',
|
||||
cliVersion: '0.0.0-private',
|
||||
feature: 'local-embeddings',
|
||||
force: true,
|
||||
},
|
||||
startIo.io,
|
||||
);
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
{
|
||||
command: 'stop',
|
||||
cliVersion: '0.0.0-private',
|
||||
},
|
||||
stopIo.io,
|
||||
);
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
{
|
||||
command: 'status',
|
||||
cliVersion: '0.0.0-private',
|
||||
json: true,
|
||||
},
|
||||
statusIo.io,
|
||||
);
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
{
|
||||
command: 'doctor',
|
||||
cliVersion: '0.0.0-private',
|
||||
json: false,
|
||||
},
|
||||
doctorIo.io,
|
||||
);
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
{
|
||||
command: 'prune',
|
||||
cliVersion: '0.0.0-private',
|
||||
dryRun: true,
|
||||
yes: false,
|
||||
},
|
||||
pruneIo.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('routes sl query managed runtime install policies', async () => {
|
||||
const sl = vi.fn(async () => 0);
|
||||
|
||||
const promptIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count'], promptIo.io, { sl }),
|
||||
).resolves.toBe(0);
|
||||
expect(sl).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'query',
|
||||
projectDir: tempDir,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'prompt',
|
||||
query: expect.objectContaining({ measures: ['orders.order_count'], dimensions: [] }),
|
||||
}),
|
||||
promptIo.io,
|
||||
);
|
||||
|
||||
const autoIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count', '--yes'], autoIo.io, {
|
||||
sl,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
expect(sl).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
}),
|
||||
autoIo.io,
|
||||
);
|
||||
|
||||
const noInputIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count', '--no-input'],
|
||||
noInputIo.io,
|
||||
{ sl },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(sl).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'never',
|
||||
}),
|
||||
noInputIo.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects conflicting sl query runtime install flags', async () => {
|
||||
const io = makeIo();
|
||||
const sl = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count', '--yes', '--no-input'],
|
||||
io.io,
|
||||
{ sl },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(sl).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
|
||||
});
|
||||
|
||||
it('exposes demo under setup help instead of root help', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
|
|
@ -179,6 +338,7 @@ describe('runKtxCli', () => {
|
|||
skipAgents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.0.0-private',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -439,6 +599,8 @@ describe('runKtxCli', () => {
|
|||
executeQueries: false,
|
||||
memoryCapture: false,
|
||||
memoryModel: undefined,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'prompt',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -757,6 +919,8 @@ describe('runKtxCli', () => {
|
|||
adapter: 'fake',
|
||||
sourceDir: tempDir,
|
||||
databaseIntrospectionUrl: undefined,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'never',
|
||||
debugLlmRequestFile: `${tempDir}/debug.jsonl`,
|
||||
outputMode: 'json',
|
||||
inputMode: 'disabled',
|
||||
|
|
@ -770,6 +934,60 @@ describe('runKtxCli', () => {
|
|||
expect(ingestReplayHelpIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('routes ingest managed runtime install policies', async () => {
|
||||
const autoIo = makeIo();
|
||||
const conflictIo = makeIo();
|
||||
const ingest = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--adapter',
|
||||
'looker',
|
||||
'--yes',
|
||||
],
|
||||
autoIo.io,
|
||||
{ ingest },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--adapter',
|
||||
'looker',
|
||||
'--yes',
|
||||
'--no-input',
|
||||
],
|
||||
conflictIo.io,
|
||||
{ ingest },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(ingest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'run',
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
}),
|
||||
autoIo.io,
|
||||
);
|
||||
expect(conflictIo.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
|
||||
});
|
||||
|
||||
it('dispatches public connection through the existing connection implementation', async () => {
|
||||
const testIo = makeIo();
|
||||
const connection = vi.fn(async () => 0);
|
||||
|
|
@ -870,6 +1088,7 @@ describe('runKtxCli', () => {
|
|||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
cliVersion: '0.0.0-private',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
|
|
@ -977,6 +1196,7 @@ describe('runKtxCli', () => {
|
|||
projectDir: '/tmp/project',
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.0.0-private',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
databaseDrivers: ['postgres'],
|
||||
|
|
@ -1239,6 +1459,8 @@ describe('runKtxCli', () => {
|
|||
queryFile: '/tmp/query.json',
|
||||
execute: true,
|
||||
maxRows: 100,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'prompt',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -1287,6 +1509,104 @@ describe('runKtxCli', () => {
|
|||
expect(helpIo.stdout()).not.toContain('agent ');
|
||||
});
|
||||
|
||||
it('routes hidden agent SL query managed runtime policies', async () => {
|
||||
const autoIo = makeIo();
|
||||
const neverIo = makeIo();
|
||||
const conflictIo = makeIo();
|
||||
const agent = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'agent',
|
||||
'sl',
|
||||
'query',
|
||||
'--json',
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--query-file',
|
||||
'/tmp/query.json',
|
||||
'--yes',
|
||||
],
|
||||
autoIo.io,
|
||||
{ agent },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'agent',
|
||||
'sl',
|
||||
'query',
|
||||
'--json',
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--query-file',
|
||||
'/tmp/query.json',
|
||||
'--no-input',
|
||||
],
|
||||
neverIo.io,
|
||||
{ agent },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'agent',
|
||||
'sl',
|
||||
'query',
|
||||
'--json',
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--query-file',
|
||||
'/tmp/query.json',
|
||||
'--yes',
|
||||
'--no-input',
|
||||
],
|
||||
conflictIo.io,
|
||||
{ agent },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(agent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
command: 'sl-query',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
queryFile: '/tmp/query.json',
|
||||
execute: false,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
autoIo.io,
|
||||
);
|
||||
expect(agent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
command: 'sl-query',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
queryFile: '/tmp/query.json',
|
||||
execute: false,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'never',
|
||||
},
|
||||
neverIo.io,
|
||||
);
|
||||
expect(conflictIo.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
|
||||
});
|
||||
|
||||
it('prints semantic-layer hybrid search metadata from the hidden agent sl list command', async () => {
|
||||
const agent = vi.fn(async (args, io) => {
|
||||
expect(args).toEqual({
|
||||
|
|
@ -1797,11 +2117,48 @@ describe('runKtxCli', () => {
|
|||
detectRelationships: false,
|
||||
dryRun: false,
|
||||
databaseIntrospectionUrl: undefined,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'prompt',
|
||||
},
|
||||
testIo.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('routes scan managed runtime install policies', async () => {
|
||||
const autoIo = makeIo();
|
||||
const neverIo = makeIo();
|
||||
const conflictIo = makeIo();
|
||||
const scan = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse', '--yes'], autoIo.io, { scan }))
|
||||
.resolves.toBe(0);
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse', '--no-input'], neverIo.io, { scan }))
|
||||
.resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse', '--yes', '--no-input'], conflictIo.io, {
|
||||
scan,
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(scan).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
command: 'run',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
}),
|
||||
autoIo.io,
|
||||
);
|
||||
expect(scan).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
command: 'run',
|
||||
runtimeInstallPolicy: 'never',
|
||||
}),
|
||||
neverIo.io,
|
||||
);
|
||||
expect(conflictIo.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
|
||||
});
|
||||
|
||||
it('dispatches serve public command options through Commander', async () => {
|
||||
const serveIo = makeIo();
|
||||
const serveStdio = vi.fn(async () => 0);
|
||||
|
|
@ -1836,10 +2193,65 @@ describe('runKtxCli', () => {
|
|||
executeQueries: true,
|
||||
memoryCapture: true,
|
||||
memoryModel: 'openai/gpt-5.2',
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'prompt',
|
||||
});
|
||||
expect(serveIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('routes serve managed runtime install policies', async () => {
|
||||
const autoIo = makeIo();
|
||||
const neverIo = makeIo();
|
||||
const conflictIo = makeIo();
|
||||
const serveStdio = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['serve', '--mcp', 'stdio', '--project-dir', tempDir, '--semantic-compute', '--yes'], autoIo.io, {
|
||||
serveStdio,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['serve', '--mcp', 'stdio', '--project-dir', tempDir, '--semantic-compute', '--no-input'], neverIo.io, {
|
||||
serveStdio,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['serve', '--mcp', 'stdio', '--project-dir', tempDir, '--semantic-compute', '--yes', '--no-input'],
|
||||
conflictIo.io,
|
||||
{ serveStdio },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(serveStdio).toHaveBeenNthCalledWith(1, {
|
||||
mcp: 'stdio',
|
||||
projectDir: tempDir,
|
||||
userId: 'local',
|
||||
semanticCompute: true,
|
||||
semanticComputeUrl: undefined,
|
||||
databaseIntrospectionUrl: undefined,
|
||||
executeQueries: false,
|
||||
memoryCapture: false,
|
||||
memoryModel: undefined,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
});
|
||||
expect(serveStdio).toHaveBeenNthCalledWith(2, {
|
||||
mcp: 'stdio',
|
||||
projectDir: tempDir,
|
||||
userId: 'local',
|
||||
semanticCompute: true,
|
||||
semanticComputeUrl: undefined,
|
||||
databaseIntrospectionUrl: undefined,
|
||||
executeQueries: false,
|
||||
memoryCapture: false,
|
||||
memoryModel: undefined,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'never',
|
||||
});
|
||||
expect(conflictIo.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
|
||||
});
|
||||
|
||||
it('prints dev help for bare dev commands', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { profileMark } from './startup-profile.js';
|
|||
|
||||
export {
|
||||
getKtxCliPackageInfo,
|
||||
packageInfoFromJson,
|
||||
runInitForCommander,
|
||||
runKtxCli,
|
||||
type KtxCliDeps,
|
||||
|
|
@ -42,6 +43,26 @@ export type {
|
|||
KtxSetupSourceType,
|
||||
} from './setup-sources.js';
|
||||
export { runKtxSetupSourcesStep } from './setup-sources.js';
|
||||
export { runKtxRuntime, type KtxRuntimeArgs, type KtxRuntimeDeps } from './runtime.js';
|
||||
export {
|
||||
allocateDaemonPort,
|
||||
readManagedPythonDaemonStatus,
|
||||
startManagedPythonDaemon,
|
||||
stopManagedPythonDaemon,
|
||||
} from './managed-python-daemon.js';
|
||||
export type {
|
||||
ManagedPythonDaemonStartResult,
|
||||
ManagedPythonDaemonState,
|
||||
ManagedPythonDaemonStatus,
|
||||
ManagedPythonDaemonStopResult,
|
||||
} from './managed-python-daemon.js';
|
||||
export {
|
||||
ensureManagedLocalEmbeddingsDaemon,
|
||||
managedLocalEmbeddingHealthConfig,
|
||||
managedLocalEmbeddingProjectConfig,
|
||||
type ManagedLocalEmbeddingsDaemon,
|
||||
type ManagedLocalEmbeddingsOptions,
|
||||
} from './managed-local-embeddings.js';
|
||||
export type { KtxMemoryFlowTuiIo, MemoryFlowTuiLiveSession } from './memory-flow-tui.js';
|
||||
export {
|
||||
renderMemoryFlowTui,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
type RunLocalIngestOptions,
|
||||
type SourceAdapter,
|
||||
} from '@ktx/context/ingest';
|
||||
import { ktxLocalStateDbPath, loadKtxProject } from '@ktx/context/project';
|
||||
import { initKtxProject, ktxLocalStateDbPath, loadKtxProject } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type KtxIngestArgs, runKtxIngest } from './ingest.js';
|
||||
import {
|
||||
|
|
@ -644,6 +644,59 @@ describe('runKtxIngest', () => {
|
|||
adapters: createdAdapters,
|
||||
adapter: 'fake',
|
||||
connectionId: 'warehouse',
|
||||
pullConfigOptions: {
|
||||
databaseIntrospectionUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes managed daemon options to adapters and pull-config options when no explicit daemon URL is set', async () => {
|
||||
const projectDir = join(tempDir, 'managed-daemon-ingest-project');
|
||||
await initKtxProject({ projectDir, projectName: 'managed-daemon-ingest-project' });
|
||||
await writeWarehouseConfig(projectDir);
|
||||
const createdAdapters: SourceAdapter[] = [
|
||||
{ source: 'fake', skillNames: [], detect: async () => true, chunk: async () => ({ workUnits: [] }) },
|
||||
];
|
||||
const createAdapters = vi.fn(() => createdAdapters as never);
|
||||
const runLocal = vi.fn(async (input: RunLocalIngestOptions) =>
|
||||
completedLocalBundleRun(input, input.jobId ?? 'local-job-1'),
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxIngest(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'fake',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
outputMode: 'plain',
|
||||
} satisfies KtxIngestArgs,
|
||||
io.io,
|
||||
{
|
||||
createAdapters,
|
||||
runLocalIngest: runLocal,
|
||||
jobIdFactory: () => 'local-job-1',
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const expectedManagedDaemon = {
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io: io.io,
|
||||
};
|
||||
expect(createAdapters).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), {
|
||||
managedDaemon: expectedManagedDaemon,
|
||||
});
|
||||
expect(runLocal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pullConfigOptions: {
|
||||
managedDaemon: expectedManagedDaemon,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import { readIngestReportSnapshotFile } from './ingest-report-file.js';
|
||||
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
|
||||
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
|
||||
import { type KtxMemoryFlowStdin, renderMemoryFlowInteractively } from './memory-flow-interactive.js';
|
||||
import {
|
||||
type KtxMemoryFlowTuiIo,
|
||||
|
|
@ -40,6 +41,8 @@ export type KtxIngestArgs =
|
|||
adapter: string;
|
||||
sourceDir?: string;
|
||||
databaseIntrospectionUrl?: string;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
debugLlmRequestFile?: string;
|
||||
outputMode: KtxIngestOutputMode;
|
||||
inputMode?: KtxIngestInputMode;
|
||||
|
|
@ -256,6 +259,20 @@ function initialRunMemoryFlowInput(
|
|||
};
|
||||
}
|
||||
|
||||
function managedDaemonOptionsForIngestRun(
|
||||
args: Extract<KtxIngestArgs, { command: 'run' }>,
|
||||
io: KtxIngestIo,
|
||||
) {
|
||||
if (args.databaseIntrospectionUrl || !args.cliVersion || !args.runtimeInstallPolicy) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
cliVersion: args.cliVersion,
|
||||
installPolicy: args.runtimeInstallPolicy,
|
||||
io,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeReportRecord(
|
||||
report: IngestReportSnapshot,
|
||||
outputMode: KtxIngestOutputMode,
|
||||
|
|
@ -311,9 +328,11 @@ export async function runKtxIngest(
|
|||
const createAdapters = deps.createAdapters ?? createKtxCliLocalIngestAdapters;
|
||||
const executeLocalIngest = deps.runLocalIngest ?? runLocalIngest;
|
||||
const localIngestOptions = deps.localIngestOptions ?? {};
|
||||
const managedDaemon = managedDaemonOptionsForIngestRun(args, io);
|
||||
const adapterOptions = {
|
||||
...(localIngestOptions.pullConfigOptions ?? {}),
|
||||
...(args.databaseIntrospectionUrl ? { databaseIntrospectionUrl: args.databaseIntrospectionUrl } : {}),
|
||||
...(managedDaemon ? { managedDaemon } : {}),
|
||||
...(args.adapter === 'historic-sql' ? { historicSqlConnectionId: args.connectionId } : {}),
|
||||
};
|
||||
if (args.adapter === 'metabase' && args.sourceDir) {
|
||||
|
|
@ -380,6 +399,7 @@ export async function runKtxIngest(
|
|||
trigger: 'manual_resync',
|
||||
jobId,
|
||||
...localIngestOptions,
|
||||
pullConfigOptions: adapterOptions,
|
||||
...(args.debugLlmRequestFile ? { llmDebugRequestFile: args.debugLlmRequestFile } : {}),
|
||||
...(memoryFlow ? { memoryFlow } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ import {
|
|||
} from '@ktx/context/ingest';
|
||||
import type { KtxLocalProject } from '@ktx/context/project';
|
||||
import { createHttpSqlAnalysisPort } from '@ktx/context/sql-analysis';
|
||||
import {
|
||||
createManagedDaemonLookerTableIdentifierParser,
|
||||
createManagedDaemonSqlAnalysisPort,
|
||||
managedDaemonDatabaseIntrospectionOptions,
|
||||
type ManagedPythonCoreDaemonOptions,
|
||||
} from './managed-python-http.js';
|
||||
|
||||
function hasSnowflakeDriver(connection: unknown): boolean {
|
||||
return (
|
||||
|
|
@ -29,13 +35,55 @@ function hasSnowflakeDriver(connection: unknown): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function ktxCliDaemonDatabaseIntrospectionOptions(
|
||||
options: KtxCliLocalIngestAdaptersOptions,
|
||||
): DefaultLocalIngestAdaptersOptions['databaseIntrospection'] {
|
||||
if (options.databaseIntrospectionUrl || options.databaseIntrospection?.requestJson || !options.managedDaemon) {
|
||||
return options.databaseIntrospection;
|
||||
}
|
||||
return {
|
||||
...(options.databaseIntrospection ?? {}),
|
||||
...managedDaemonDatabaseIntrospectionOptions(options.managedDaemon),
|
||||
};
|
||||
}
|
||||
|
||||
function ktxCliLookerOptions(
|
||||
options: KtxCliLocalIngestAdaptersOptions,
|
||||
): DefaultLocalIngestAdaptersOptions['looker'] {
|
||||
const looker = options.looker;
|
||||
if (looker?.parser || looker?.daemonBaseUrl || process.env.KTX_DAEMON_URL || !options.managedDaemon) {
|
||||
return looker;
|
||||
}
|
||||
return {
|
||||
...(looker ?? {}),
|
||||
parser: createManagedDaemonLookerTableIdentifierParser(options.managedDaemon),
|
||||
};
|
||||
}
|
||||
|
||||
function ktxCliHistoricSqlAnalysis(options: KtxCliLocalIngestAdaptersOptions) {
|
||||
if (options.sqlAnalysisUrl) {
|
||||
return createHttpSqlAnalysisPort({ baseUrl: options.sqlAnalysisUrl });
|
||||
}
|
||||
if (process.env.KTX_SQL_ANALYSIS_URL) {
|
||||
return createHttpSqlAnalysisPort({ baseUrl: process.env.KTX_SQL_ANALYSIS_URL });
|
||||
}
|
||||
if (process.env.KTX_DAEMON_URL) {
|
||||
return createHttpSqlAnalysisPort({ baseUrl: process.env.KTX_DAEMON_URL });
|
||||
}
|
||||
if (options.managedDaemon) {
|
||||
return createManagedDaemonSqlAnalysisPort(options.managedDaemon);
|
||||
}
|
||||
return createHttpSqlAnalysisPort({ baseUrl: 'http://127.0.0.1:8765' });
|
||||
}
|
||||
|
||||
function createKtxCliLiveDatabaseIntrospection(
|
||||
project: KtxLocalProject,
|
||||
options: DefaultLocalIngestAdaptersOptions = {},
|
||||
options: KtxCliLocalIngestAdaptersOptions = {},
|
||||
): LiveDatabaseIntrospectionPort {
|
||||
const databaseIntrospection = ktxCliDaemonDatabaseIntrospectionOptions(options);
|
||||
const daemon = createDaemonLiveDatabaseIntrospection({
|
||||
connections: project.config.connections,
|
||||
...options.databaseIntrospection,
|
||||
...databaseIntrospection,
|
||||
...(options.databaseIntrospectionUrl ? { baseUrl: options.databaseIntrospectionUrl } : {}),
|
||||
});
|
||||
const sqlite = createSqliteLiveDatabaseIntrospection({
|
||||
|
|
@ -95,9 +143,10 @@ function createKtxCliLiveDatabaseIntrospection(
|
|||
};
|
||||
}
|
||||
|
||||
interface KtxCliLocalIngestAdaptersOptions extends DefaultLocalIngestAdaptersOptions {
|
||||
export interface KtxCliLocalIngestAdaptersOptions extends DefaultLocalIngestAdaptersOptions {
|
||||
historicSqlConnectionId?: string;
|
||||
sqlAnalysisUrl?: string;
|
||||
managedDaemon?: ManagedPythonCoreDaemonOptions;
|
||||
}
|
||||
|
||||
function isEnabledPostgresHistoricSqlConnection(connection: KtxPostgresConnectionConfig | undefined): boolean {
|
||||
|
|
@ -145,13 +194,7 @@ function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCli
|
|||
return undefined;
|
||||
}
|
||||
return {
|
||||
sqlAnalysis: createHttpSqlAnalysisPort({
|
||||
baseUrl:
|
||||
options.sqlAnalysisUrl ??
|
||||
process.env.KTX_SQL_ANALYSIS_URL ??
|
||||
process.env.KTX_DAEMON_URL ??
|
||||
'http://127.0.0.1:8765',
|
||||
}),
|
||||
sqlAnalysis: ktxCliHistoricSqlAnalysis(options),
|
||||
postgresQueryClient: createEphemeralPostgresHistoricSqlClient(project, connectionId),
|
||||
postgresBaselineRootDir: join(project.projectDir, '.ktx/cache/historic-sql'),
|
||||
};
|
||||
|
|
@ -164,6 +207,8 @@ export function createKtxCliLocalIngestAdapters(
|
|||
const historicSql = historicSqlOptionsForLocalRun(project, options);
|
||||
const base = createDefaultLocalIngestAdapters(project, {
|
||||
...options,
|
||||
databaseIntrospection: ktxCliDaemonDatabaseIntrospectionOptions(options),
|
||||
looker: ktxCliLookerOptions(options),
|
||||
...(historicSql ? { historicSql } : {}),
|
||||
});
|
||||
const liveDatabase = new LiveDatabaseSourceAdapter({
|
||||
|
|
|
|||
180
packages/cli/src/managed-local-embeddings.test.ts
Normal file
180
packages/cli/src/managed-local-embeddings.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
MANAGED_SENTENCE_TRANSFORMERS_BASE_URL,
|
||||
MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV,
|
||||
} from '@ktx/context';
|
||||
import {
|
||||
ensureManagedLocalEmbeddingsDaemon,
|
||||
managedLocalEmbeddingHealthConfig,
|
||||
managedLocalEmbeddingProjectConfig,
|
||||
} from './managed-local-embeddings.js';
|
||||
import type { ManagedPythonCommandRuntime } from './managed-python-command.js';
|
||||
import type { ManagedPythonDaemonStartResult } from './managed-python-daemon.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function runtime(): ManagedPythonCommandRuntime {
|
||||
return {
|
||||
layout: {
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: '/runtime',
|
||||
versionDir: '/runtime/0.2.0',
|
||||
venvDir: '/runtime/0.2.0/.venv',
|
||||
manifestPath: '/runtime/0.2.0/manifest.json',
|
||||
installLogPath: '/runtime/0.2.0/install.log',
|
||||
assetDir: '/assets/python',
|
||||
assetManifestPath: '/assets/python/manifest.json',
|
||||
pythonPath: '/runtime/0.2.0/.venv/bin/python',
|
||||
daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
daemonStatePath: '/runtime/0.2.0/daemon.json',
|
||||
daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
|
||||
daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
|
||||
},
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
cliVersion: '0.2.0',
|
||||
installedAt: '2026-05-11T00:00:00.000Z',
|
||||
asset: {
|
||||
schemaVersion: 1,
|
||||
distributionName: 'kaelio-ktx',
|
||||
normalizedName: 'kaelio_ktx',
|
||||
version: '0.2.0',
|
||||
wheel: {
|
||||
file: 'kaelio_ktx-0.2.0-py3-none-any.whl',
|
||||
sha256: 'a'.repeat(64),
|
||||
bytes: 123,
|
||||
},
|
||||
},
|
||||
features: ['core', 'local-embeddings'],
|
||||
python: {
|
||||
executable: '/runtime/0.2.0/.venv/bin/python',
|
||||
daemonExecutable: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
},
|
||||
installLog: '/runtime/0.2.0/install.log',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function daemonResult(status: 'started' | 'reused' = 'reused'): ManagedPythonDaemonStartResult {
|
||||
return {
|
||||
status,
|
||||
layout: runtime().layout,
|
||||
baseUrl: 'http://127.0.0.1:61234',
|
||||
state: {
|
||||
schemaVersion: 1,
|
||||
pid: 12345,
|
||||
host: '127.0.0.1',
|
||||
port: 61234,
|
||||
version: '0.2.0',
|
||||
features: ['core', 'local-embeddings'],
|
||||
startedAt: '2026-05-11T00:00:00.000Z',
|
||||
stdoutLog: '/runtime/0.2.0/daemon.stdout.log',
|
||||
stderrLog: '/runtime/0.2.0/daemon.stderr.log',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('managedLocalEmbeddingProjectConfig', () => {
|
||||
it('uses a stable managed runtime marker instead of a random daemon port', () => {
|
||||
expect(
|
||||
managedLocalEmbeddingProjectConfig({
|
||||
model: 'all-MiniLM-L6-v2',
|
||||
dimensions: 384,
|
||||
}),
|
||||
).toEqual({
|
||||
backend: 'sentence-transformers',
|
||||
model: 'all-MiniLM-L6-v2',
|
||||
dimensions: 384,
|
||||
sentenceTransformers: {
|
||||
base_url: MANAGED_SENTENCE_TRANSFORMERS_BASE_URL,
|
||||
pathPrefix: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('managedLocalEmbeddingHealthConfig', () => {
|
||||
it('uses the active managed daemon URL for the immediate health check', () => {
|
||||
expect(
|
||||
managedLocalEmbeddingHealthConfig({
|
||||
baseUrl: 'http://127.0.0.1:61234',
|
||||
model: 'all-MiniLM-L6-v2',
|
||||
dimensions: 384,
|
||||
}),
|
||||
).toEqual({
|
||||
backend: 'sentence-transformers',
|
||||
model: 'all-MiniLM-L6-v2',
|
||||
dimensions: 384,
|
||||
sentenceTransformers: { baseURL: 'http://127.0.0.1:61234', pathPrefix: '' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureManagedLocalEmbeddingsDaemon', () => {
|
||||
it('ensures the local-embeddings feature and starts the managed daemon', async () => {
|
||||
const io = makeIo();
|
||||
const ensureRuntime = vi.fn(async () => runtime());
|
||||
const startDaemon = vi.fn(async () => daemonResult('started'));
|
||||
|
||||
await expect(
|
||||
ensureManagedLocalEmbeddingsDaemon({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io: io.io,
|
||||
ensureRuntime,
|
||||
startDaemon,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
baseUrl: 'http://127.0.0.1:61234',
|
||||
env: {
|
||||
[MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV]: 'http://127.0.0.1:61234',
|
||||
},
|
||||
});
|
||||
|
||||
expect(ensureRuntime).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io: io.io,
|
||||
feature: 'local-embeddings',
|
||||
});
|
||||
expect(startDaemon).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
features: ['local-embeddings'],
|
||||
force: false,
|
||||
});
|
||||
expect(io.stderr()).toContain('Started KTX local embeddings daemon: http://127.0.0.1:61234');
|
||||
});
|
||||
|
||||
it('reuses an already running daemon without reporting a new start', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await ensureManagedLocalEmbeddingsDaemon({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'prompt',
|
||||
io: io.io,
|
||||
ensureRuntime: vi.fn(async () => runtime()),
|
||||
startDaemon: vi.fn(async () => daemonResult('reused')),
|
||||
});
|
||||
|
||||
expect(io.stderr()).toContain('Using KTX local embeddings daemon: http://127.0.0.1:61234');
|
||||
});
|
||||
});
|
||||
95
packages/cli/src/managed-local-embeddings.ts
Normal file
95
packages/cli/src/managed-local-embeddings.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import {
|
||||
MANAGED_SENTENCE_TRANSFORMERS_BASE_URL,
|
||||
MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV,
|
||||
} from '@ktx/context';
|
||||
import type { KtxProjectEmbeddingConfig } from '@ktx/context/project';
|
||||
import type { KtxEmbeddingConfig } from '@ktx/llm';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import {
|
||||
ensureManagedPythonCommandRuntime,
|
||||
type KtxManagedPythonInstallPolicy,
|
||||
type ManagedPythonCommandRuntime,
|
||||
} from './managed-python-command.js';
|
||||
import { startManagedPythonDaemon, type ManagedPythonDaemonStartResult } from './managed-python-daemon.js';
|
||||
|
||||
export interface ManagedLocalEmbeddingsDaemon {
|
||||
baseUrl: string;
|
||||
env: Record<typeof MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV, string>;
|
||||
}
|
||||
|
||||
export interface ManagedLocalEmbeddingsOptions {
|
||||
cliVersion: string;
|
||||
installPolicy: KtxManagedPythonInstallPolicy;
|
||||
io: KtxCliIo;
|
||||
ensureRuntime?: (options: {
|
||||
cliVersion: string;
|
||||
installPolicy: KtxManagedPythonInstallPolicy;
|
||||
io: KtxCliIo;
|
||||
feature: 'local-embeddings';
|
||||
}) => Promise<ManagedPythonCommandRuntime>;
|
||||
startDaemon?: (options: {
|
||||
cliVersion: string;
|
||||
features: ['local-embeddings'];
|
||||
force: boolean;
|
||||
}) => Promise<ManagedPythonDaemonStartResult>;
|
||||
}
|
||||
|
||||
export function managedLocalEmbeddingProjectConfig(input: {
|
||||
model: string;
|
||||
dimensions: number;
|
||||
}): KtxProjectEmbeddingConfig {
|
||||
return {
|
||||
backend: 'sentence-transformers',
|
||||
model: input.model,
|
||||
dimensions: input.dimensions,
|
||||
sentenceTransformers: {
|
||||
base_url: MANAGED_SENTENCE_TRANSFORMERS_BASE_URL,
|
||||
pathPrefix: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function managedLocalEmbeddingHealthConfig(input: {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
dimensions: number;
|
||||
}): KtxEmbeddingConfig {
|
||||
return {
|
||||
backend: 'sentence-transformers',
|
||||
model: input.model,
|
||||
dimensions: input.dimensions,
|
||||
sentenceTransformers: {
|
||||
baseURL: input.baseUrl,
|
||||
pathPrefix: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureManagedLocalEmbeddingsDaemon(
|
||||
options: ManagedLocalEmbeddingsOptions,
|
||||
): Promise<ManagedLocalEmbeddingsDaemon> {
|
||||
const ensureRuntime = options.ensureRuntime ?? ensureManagedPythonCommandRuntime;
|
||||
const startDaemon = options.startDaemon ?? startManagedPythonDaemon;
|
||||
|
||||
await ensureRuntime({
|
||||
cliVersion: options.cliVersion,
|
||||
installPolicy: options.installPolicy,
|
||||
io: options.io,
|
||||
feature: 'local-embeddings',
|
||||
});
|
||||
const daemon = await startDaemon({
|
||||
cliVersion: options.cliVersion,
|
||||
features: ['local-embeddings'],
|
||||
force: false,
|
||||
});
|
||||
|
||||
const verb = daemon.status === 'started' ? 'Started' : 'Using';
|
||||
options.io.stderr.write(`${verb} KTX local embeddings daemon: ${daemon.baseUrl}\n`);
|
||||
|
||||
return {
|
||||
baseUrl: daemon.baseUrl,
|
||||
env: {
|
||||
[MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV]: daemon.baseUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
224
packages/cli/src/managed-python-command.test.ts
Normal file
224
packages/cli/src/managed-python-command.test.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createManagedPythonSemanticLayerComputePort,
|
||||
managedRuntimeInstallCommand,
|
||||
runtimeInstallPolicyFromFlags,
|
||||
} from './managed-python-command.js';
|
||||
import type {
|
||||
InstalledKtxRuntimeManifest,
|
||||
KtxRuntimeFeature,
|
||||
ManagedPythonRuntimeInstallResult,
|
||||
ManagedPythonRuntimeLayout,
|
||||
ManagedPythonRuntimeStatus,
|
||||
} from './managed-python-runtime.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function layout(): ManagedPythonRuntimeLayout {
|
||||
return {
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: '/runtime',
|
||||
versionDir: '/runtime/0.2.0',
|
||||
venvDir: '/runtime/0.2.0/.venv',
|
||||
manifestPath: '/runtime/0.2.0/manifest.json',
|
||||
installLogPath: '/runtime/0.2.0/install.log',
|
||||
assetDir: '/assets/python',
|
||||
assetManifestPath: '/assets/python/manifest.json',
|
||||
pythonPath: '/runtime/0.2.0/.venv/bin/python',
|
||||
daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
daemonStatePath: '/runtime/0.2.0/daemon.json',
|
||||
daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
|
||||
daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
|
||||
};
|
||||
}
|
||||
|
||||
function manifest(features: KtxRuntimeFeature[] = ['core']): InstalledKtxRuntimeManifest {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
cliVersion: '0.2.0',
|
||||
installedAt: '2026-05-11T00:00:00.000Z',
|
||||
asset: {
|
||||
schemaVersion: 1,
|
||||
distributionName: 'kaelio-ktx',
|
||||
normalizedName: 'kaelio_ktx',
|
||||
version: '0.2.0',
|
||||
wheel: {
|
||||
file: 'kaelio_ktx-0.2.0-py3-none-any.whl',
|
||||
sha256: 'a'.repeat(64),
|
||||
bytes: 123,
|
||||
},
|
||||
},
|
||||
features,
|
||||
python: {
|
||||
executable: '/runtime/0.2.0/.venv/bin/python',
|
||||
daemonExecutable: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
},
|
||||
installLog: '/runtime/0.2.0/install.log',
|
||||
};
|
||||
}
|
||||
|
||||
function readyStatus(features: KtxRuntimeFeature[] = ['core']): ManagedPythonRuntimeStatus {
|
||||
return {
|
||||
kind: 'ready',
|
||||
detail: 'Runtime ready at /runtime/0.2.0',
|
||||
layout: layout(),
|
||||
manifest: manifest(features),
|
||||
};
|
||||
}
|
||||
|
||||
function missingStatus(): ManagedPythonRuntimeStatus {
|
||||
return {
|
||||
kind: 'missing',
|
||||
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
|
||||
layout: layout(),
|
||||
};
|
||||
}
|
||||
|
||||
function installResult(features: KtxRuntimeFeature[] = ['core']): ManagedPythonRuntimeInstallResult {
|
||||
const installedManifest = manifest(features);
|
||||
return {
|
||||
status: 'installed',
|
||||
layout: layout(),
|
||||
asset: {
|
||||
manifest: installedManifest.asset,
|
||||
wheelPath: '/assets/python/kaelio_ktx-0.2.0-py3-none-any.whl',
|
||||
},
|
||||
manifest: installedManifest,
|
||||
};
|
||||
}
|
||||
|
||||
describe('managedRuntimeInstallCommand', () => {
|
||||
it('prints the exact command for each managed runtime feature', () => {
|
||||
expect(managedRuntimeInstallCommand('core')).toBe('ktx runtime install --yes');
|
||||
expect(managedRuntimeInstallCommand('local-embeddings')).toBe(
|
||||
'ktx runtime install --feature local-embeddings --yes',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtimeInstallPolicyFromFlags', () => {
|
||||
it('maps command flags to managed runtime install policies', () => {
|
||||
expect(runtimeInstallPolicyFromFlags({})).toBe('prompt');
|
||||
expect(runtimeInstallPolicyFromFlags({ yes: false })).toBe('prompt');
|
||||
expect(runtimeInstallPolicyFromFlags({ yes: true })).toBe('auto');
|
||||
expect(runtimeInstallPolicyFromFlags({ input: false })).toBe('never');
|
||||
});
|
||||
|
||||
it('rejects conflicting runtime install flags', () => {
|
||||
expect(() => runtimeInstallPolicyFromFlags({ yes: true, input: false })).toThrow(
|
||||
'Choose only one runtime install mode: --yes or --no-input',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createManagedPythonSemanticLayerComputePort', () => {
|
||||
it('uses the managed ktx-daemon executable when the runtime is ready', 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);
|
||||
|
||||
expect(createPythonCompute).toHaveBeenCalledWith({
|
||||
command: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
args: [],
|
||||
});
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('fails with a preparation command 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 runtime install --yes');
|
||||
|
||||
expect(installRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('installs the core runtime without prompting when policy is auto', async () => {
|
||||
const io = makeIo();
|
||||
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,
|
||||
}),
|
||||
).resolves.toBe(compute);
|
||||
|
||||
expect(installRuntime).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
features: ['core'],
|
||||
force: false,
|
||||
});
|
||||
expect(io.stderr()).toContain('Installing KTX Python runtime (core) with uv');
|
||||
expect(io.stderr()).toContain('KTX Python runtime ready: /runtime/0.2.0');
|
||||
});
|
||||
|
||||
it('prompts before installing when policy is prompt', async () => {
|
||||
const io = makeIo();
|
||||
const confirmInstall = vi.fn(async () => true);
|
||||
const installRuntime = vi.fn(async () => installResult());
|
||||
|
||||
await createManagedPythonSemanticLayerComputePort({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'prompt',
|
||||
io: io.io,
|
||||
readStatus: vi.fn(async () => missingStatus()),
|
||||
installRuntime,
|
||||
createPythonCompute: vi.fn(() => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() })),
|
||||
confirmInstall,
|
||||
});
|
||||
|
||||
expect(confirmInstall).toHaveBeenCalledWith(
|
||||
'KTX needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?',
|
||||
);
|
||||
expect(installRuntime).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
features: ['core'],
|
||||
force: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
135
packages/cli/src/managed-python-command.ts
Normal file
135
packages/cli/src/managed-python-command.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { cancel, confirm, isCancel } from '@clack/prompts';
|
||||
import { createPythonSemanticLayerComputePort, type KtxSemanticLayerComputePort } from '@ktx/context/daemon';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import {
|
||||
installManagedPythonRuntime,
|
||||
readManagedPythonRuntimeStatus,
|
||||
type InstalledKtxRuntimeManifest,
|
||||
type KtxRuntimeFeature,
|
||||
type ManagedPythonRuntimeInstallOptions,
|
||||
type ManagedPythonRuntimeInstallResult,
|
||||
type ManagedPythonRuntimeLayout,
|
||||
type ManagedPythonRuntimeLayoutOptions,
|
||||
type ManagedPythonRuntimeStatus,
|
||||
} from './managed-python-runtime.js';
|
||||
|
||||
export type KtxManagedPythonInstallPolicy = 'prompt' | 'auto' | 'never';
|
||||
|
||||
export function runtimeInstallPolicyFromFlags(options: {
|
||||
yes?: boolean;
|
||||
input?: boolean;
|
||||
}): KtxManagedPythonInstallPolicy {
|
||||
if (options.yes === true && options.input === false) {
|
||||
throw new Error('Choose only one runtime install mode: --yes or --no-input');
|
||||
}
|
||||
if (options.yes === true) {
|
||||
return 'auto';
|
||||
}
|
||||
return options.input === false ? 'never' : 'prompt';
|
||||
}
|
||||
|
||||
export interface ManagedPythonCommandRuntime {
|
||||
layout: ManagedPythonRuntimeLayout;
|
||||
manifest: InstalledKtxRuntimeManifest;
|
||||
}
|
||||
|
||||
export interface ManagedPythonCommandDeps {
|
||||
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
|
||||
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
|
||||
confirmInstall?: (message: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ManagedPythonCommandOptions extends ManagedPythonCommandDeps {
|
||||
cliVersion: string;
|
||||
installPolicy: KtxManagedPythonInstallPolicy;
|
||||
io: KtxCliIo;
|
||||
feature?: KtxRuntimeFeature;
|
||||
}
|
||||
|
||||
export interface ManagedPythonSemanticLayerComputeOptions extends ManagedPythonCommandOptions {
|
||||
createPythonCompute?: typeof createPythonSemanticLayerComputePort;
|
||||
}
|
||||
|
||||
export function managedRuntimeInstallCommand(feature: KtxRuntimeFeature): string {
|
||||
return feature === 'local-embeddings'
|
||||
? 'ktx runtime install --feature local-embeddings --yes'
|
||||
: 'ktx runtime install --yes';
|
||||
}
|
||||
|
||||
function installPrompt(feature: KtxRuntimeFeature): string {
|
||||
const label = feature === 'local-embeddings' ? 'local embeddings Python runtime' : 'core Python runtime';
|
||||
return `KTX needs to install the ${label}. This downloads Python dependencies with uv. Continue?`;
|
||||
}
|
||||
|
||||
function runtimeRequiredMessage(feature: KtxRuntimeFeature): string {
|
||||
return `KTX Python runtime is required for this command. Run: ${managedRuntimeInstallCommand(feature)}`;
|
||||
}
|
||||
|
||||
function hasFeature(manifest: InstalledKtxRuntimeManifest, feature: KtxRuntimeFeature): boolean {
|
||||
return manifest.features.includes(feature);
|
||||
}
|
||||
|
||||
async function defaultConfirmInstall(message: string): Promise<boolean> {
|
||||
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
|
||||
return false;
|
||||
}
|
||||
const response = await confirm({ message, initialValue: true });
|
||||
if (isCancel(response)) {
|
||||
cancel('Runtime installation cancelled.');
|
||||
return false;
|
||||
}
|
||||
return response === true;
|
||||
}
|
||||
|
||||
export async function ensureManagedPythonCommandRuntime(
|
||||
options: ManagedPythonCommandOptions,
|
||||
): Promise<ManagedPythonCommandRuntime> {
|
||||
const feature = options.feature ?? 'core';
|
||||
const readStatus = options.readStatus ?? readManagedPythonRuntimeStatus;
|
||||
const installRuntime = options.installRuntime ?? installManagedPythonRuntime;
|
||||
const status = await readStatus({ cliVersion: options.cliVersion });
|
||||
|
||||
if (status.kind === 'ready' && status.manifest && hasFeature(status.manifest, feature)) {
|
||||
return { layout: status.layout, manifest: status.manifest };
|
||||
}
|
||||
|
||||
if (options.installPolicy === 'never') {
|
||||
throw new Error(runtimeRequiredMessage(feature));
|
||||
}
|
||||
|
||||
if (options.installPolicy === 'prompt') {
|
||||
const confirmInstall = options.confirmInstall ?? defaultConfirmInstall;
|
||||
const confirmed = await confirmInstall(installPrompt(feature));
|
||||
if (!confirmed) {
|
||||
throw new Error(`KTX Python runtime installation was cancelled. Run: ${managedRuntimeInstallCommand(feature)}`);
|
||||
}
|
||||
}
|
||||
|
||||
options.io.stderr.write(`Installing KTX Python runtime (${feature}) with uv...\n`);
|
||||
const installed = await installRuntime({
|
||||
cliVersion: options.cliVersion,
|
||||
features: [feature],
|
||||
force: false,
|
||||
});
|
||||
options.io.stderr.write(`KTX Python runtime ready: ${installed.layout.versionDir}\n`);
|
||||
return { layout: installed.layout, manifest: installed.manifest };
|
||||
}
|
||||
|
||||
export async 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 } : {}),
|
||||
});
|
||||
const createPythonCompute = options.createPythonCompute ?? createPythonSemanticLayerComputePort;
|
||||
return createPythonCompute({
|
||||
command: runtime.manifest.python.daemonExecutable,
|
||||
args: [],
|
||||
});
|
||||
}
|
||||
239
packages/cli/src/managed-python-daemon.test.ts
Normal file
239
packages/cli/src/managed-python-daemon.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
readManagedPythonDaemonStatus,
|
||||
startManagedPythonDaemon,
|
||||
stopManagedPythonDaemon,
|
||||
type ManagedPythonDaemonChild,
|
||||
type ManagedPythonDaemonFetch,
|
||||
type ManagedPythonDaemonSpawn,
|
||||
type ManagedPythonDaemonState,
|
||||
} from './managed-python-daemon.js';
|
||||
import type {
|
||||
InstalledKtxRuntimeManifest,
|
||||
ManagedPythonRuntimeInstallResult,
|
||||
ManagedPythonRuntimeLayout,
|
||||
} from './managed-python-runtime.js';
|
||||
|
||||
function layout(root: string): ManagedPythonRuntimeLayout {
|
||||
return {
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(root, 'runtime'),
|
||||
versionDir: join(root, 'runtime', '0.2.0'),
|
||||
venvDir: join(root, 'runtime', '0.2.0', '.venv'),
|
||||
manifestPath: join(root, 'runtime', '0.2.0', 'manifest.json'),
|
||||
installLogPath: join(root, 'runtime', '0.2.0', 'install.log'),
|
||||
assetDir: join(root, 'assets', 'python'),
|
||||
assetManifestPath: join(root, 'assets', 'python', 'manifest.json'),
|
||||
pythonPath: join(root, 'runtime', '0.2.0', '.venv', 'bin', 'python'),
|
||||
daemonPath: join(root, 'runtime', '0.2.0', '.venv', 'bin', 'ktx-daemon'),
|
||||
daemonStatePath: join(root, 'runtime', '0.2.0', 'daemon.json'),
|
||||
daemonStdoutPath: join(root, 'runtime', '0.2.0', 'daemon.stdout.log'),
|
||||
daemonStderrPath: join(root, 'runtime', '0.2.0', 'daemon.stderr.log'),
|
||||
};
|
||||
}
|
||||
|
||||
function manifest(root: string, features: Array<'core' | 'local-embeddings'> = ['core']): InstalledKtxRuntimeManifest {
|
||||
const runtimeLayout = layout(root);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
cliVersion: '0.2.0',
|
||||
installedAt: '2026-05-11T00:00:00.000Z',
|
||||
asset: {
|
||||
schemaVersion: 1,
|
||||
distributionName: 'kaelio-ktx',
|
||||
normalizedName: 'kaelio_ktx',
|
||||
version: '0.2.0',
|
||||
wheel: {
|
||||
file: 'kaelio_ktx-0.2.0-py3-none-any.whl',
|
||||
sha256: 'a'.repeat(64),
|
||||
bytes: 123,
|
||||
},
|
||||
},
|
||||
features,
|
||||
python: {
|
||||
executable: runtimeLayout.pythonPath,
|
||||
daemonExecutable: runtimeLayout.daemonPath,
|
||||
},
|
||||
installLog: runtimeLayout.installLogPath,
|
||||
};
|
||||
}
|
||||
|
||||
function installResult(root: string, features: Array<'core' | 'local-embeddings'> = ['core']): ManagedPythonRuntimeInstallResult {
|
||||
return {
|
||||
status: 'ready',
|
||||
layout: layout(root),
|
||||
asset: {
|
||||
manifest: manifest(root, features).asset,
|
||||
wheelPath: join(root, 'assets', 'python', 'kaelio_ktx-0.2.0-py3-none-any.whl'),
|
||||
},
|
||||
manifest: manifest(root, features),
|
||||
};
|
||||
}
|
||||
|
||||
function makeFetch(version = '0.2.0'): ManagedPythonDaemonFetch {
|
||||
return vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ status: 'healthy', version }),
|
||||
text: async () => '',
|
||||
}));
|
||||
}
|
||||
|
||||
function makeSpawn(pid = 4242): ManagedPythonDaemonSpawn {
|
||||
return vi.fn((_command, _args, _options): ManagedPythonDaemonChild => ({
|
||||
pid,
|
||||
unref: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
function runningState(root: string, overrides: Partial<ManagedPythonDaemonState> = {}): ManagedPythonDaemonState {
|
||||
const runtimeLayout = layout(root);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
pid: 4242,
|
||||
host: '127.0.0.1',
|
||||
port: 58731,
|
||||
version: '0.2.0',
|
||||
features: ['core'],
|
||||
startedAt: '2026-05-11T00:00:00.000Z',
|
||||
stdoutLog: runtimeLayout.daemonStdoutPath,
|
||||
stderrLog: runtimeLayout.daemonStderrPath,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('managed Python daemon lifecycle', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-managed-daemon-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports stopped when no daemon state exists', async () => {
|
||||
const status = await readManagedPythonDaemonStatus({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
processAlive: vi.fn(() => false),
|
||||
fetch: makeFetch(),
|
||||
});
|
||||
|
||||
expect(status.kind).toBe('stopped');
|
||||
expect(status.detail).toContain('No daemon state');
|
||||
});
|
||||
|
||||
it('starts ktx-daemon serve-http, waits for health, and writes state', async () => {
|
||||
const spawnDaemon = makeSpawn(5555);
|
||||
const installRuntime = vi.fn(async () => installResult(tempDir));
|
||||
|
||||
const result = await startManagedPythonDaemon({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
features: ['core'],
|
||||
installRuntime,
|
||||
spawnDaemon,
|
||||
fetch: makeFetch(),
|
||||
allocatePort: vi.fn(async () => 61234),
|
||||
now: () => new Date('2026-05-11T00:00:00.000Z'),
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('started');
|
||||
expect(result.baseUrl).toBe('http://127.0.0.1:61234');
|
||||
expect(installRuntime).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
features: ['core'],
|
||||
force: false,
|
||||
});
|
||||
expect(spawnDaemon).toHaveBeenCalledWith(
|
||||
layout(tempDir).daemonPath,
|
||||
['serve-http', '--host', '127.0.0.1', '--port', '61234'],
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
env: expect.objectContaining({ KTX_DAEMON_VERSION: '0.2.0' }),
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(await readFile(layout(tempDir).daemonStatePath, 'utf8'))).toMatchObject({
|
||||
pid: 5555,
|
||||
port: 61234,
|
||||
version: '0.2.0',
|
||||
features: ['core'],
|
||||
stdoutLog: layout(tempDir).daemonStdoutPath,
|
||||
stderrLog: layout(tempDir).daemonStderrPath,
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses a healthy daemon with the requested feature set', async () => {
|
||||
await mkdir(layout(tempDir).versionDir, { recursive: true });
|
||||
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
|
||||
const spawnDaemon = makeSpawn(9999);
|
||||
|
||||
const result = await startManagedPythonDaemon({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
features: ['core'],
|
||||
installRuntime: vi.fn(async () => installResult(tempDir)),
|
||||
spawnDaemon,
|
||||
fetch: makeFetch(),
|
||||
processAlive: vi.fn(() => true),
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('reused');
|
||||
expect(result.baseUrl).toBe('http://127.0.0.1:58731');
|
||||
expect(spawnDaemon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts a fresh daemon when the previous state is stale', async () => {
|
||||
await mkdir(layout(tempDir).versionDir, { recursive: true });
|
||||
await writeFile(
|
||||
layout(tempDir).daemonStatePath,
|
||||
`${JSON.stringify(runningState(tempDir, { version: '0.1.0' }), null, 2)}\n`,
|
||||
);
|
||||
|
||||
const result = await startManagedPythonDaemon({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
features: ['core'],
|
||||
installRuntime: vi.fn(async () => installResult(tempDir)),
|
||||
spawnDaemon: makeSpawn(6666),
|
||||
fetch: makeFetch(),
|
||||
processAlive: vi.fn(() => true),
|
||||
killProcess: vi.fn(),
|
||||
allocatePort: vi.fn(async () => 61235),
|
||||
now: () => new Date('2026-05-11T00:00:00.000Z'),
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('started');
|
||||
expect(JSON.parse(await readFile(layout(tempDir).daemonStatePath, 'utf8'))).toMatchObject({
|
||||
pid: 6666,
|
||||
port: 61235,
|
||||
version: '0.2.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops a recorded daemon and removes the state file', async () => {
|
||||
await mkdir(layout(tempDir).versionDir, { recursive: true });
|
||||
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
|
||||
const killProcess = vi.fn();
|
||||
|
||||
const result = await stopManagedPythonDaemon({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
processAlive: vi.fn(() => true),
|
||||
killProcess,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('stopped');
|
||||
expect(killProcess).toHaveBeenCalledWith(4242);
|
||||
await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
397
packages/cli/src/managed-python-daemon.ts
Normal file
397
packages/cli/src/managed-python-daemon.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
installManagedPythonRuntime,
|
||||
managedPythonRuntimeLayout,
|
||||
runtimeFeatureSchema,
|
||||
type KtxRuntimeFeature,
|
||||
type ManagedPythonRuntimeInstallOptions,
|
||||
type ManagedPythonRuntimeInstallResult,
|
||||
type ManagedPythonRuntimeLayout,
|
||||
type ManagedPythonRuntimeLayoutOptions,
|
||||
} from './managed-python-runtime.js';
|
||||
|
||||
export interface ManagedPythonDaemonState {
|
||||
schemaVersion: 1;
|
||||
pid: number;
|
||||
host: '127.0.0.1';
|
||||
port: number;
|
||||
version: string;
|
||||
features: KtxRuntimeFeature[];
|
||||
startedAt: string;
|
||||
stdoutLog: string;
|
||||
stderrLog: string;
|
||||
}
|
||||
|
||||
export type ManagedPythonDaemonStatus =
|
||||
| { kind: 'stopped'; detail: string; layout: ManagedPythonRuntimeLayout }
|
||||
| { kind: 'running'; detail: string; layout: ManagedPythonRuntimeLayout; state: ManagedPythonDaemonState; baseUrl: string }
|
||||
| { kind: 'stale'; detail: string; layout: ManagedPythonRuntimeLayout; state?: ManagedPythonDaemonState };
|
||||
|
||||
export interface ManagedPythonDaemonStartResult {
|
||||
status: 'started' | 'reused';
|
||||
layout: ManagedPythonRuntimeLayout;
|
||||
state: ManagedPythonDaemonState;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface ManagedPythonDaemonStopResult {
|
||||
status: 'stopped' | 'already-stopped';
|
||||
layout: ManagedPythonRuntimeLayout;
|
||||
state?: ManagedPythonDaemonState;
|
||||
}
|
||||
|
||||
export interface ManagedPythonDaemonChild {
|
||||
pid?: number;
|
||||
unref(): void;
|
||||
}
|
||||
|
||||
export type ManagedPythonDaemonSpawn = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: {
|
||||
detached: boolean;
|
||||
stdio: ['ignore', number, number];
|
||||
env: NodeJS.ProcessEnv;
|
||||
},
|
||||
) => ManagedPythonDaemonChild;
|
||||
|
||||
export type ManagedPythonDaemonFetch = (
|
||||
url: string,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json(): Promise<unknown>;
|
||||
text(): Promise<string>;
|
||||
}>;
|
||||
|
||||
export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLayoutOptions {
|
||||
features: KtxRuntimeFeature[];
|
||||
force?: boolean;
|
||||
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
|
||||
spawnDaemon?: ManagedPythonDaemonSpawn;
|
||||
fetch?: ManagedPythonDaemonFetch;
|
||||
allocatePort?: () => Promise<number>;
|
||||
processAlive?: (pid: number) => boolean;
|
||||
killProcess?: (pid: number) => void;
|
||||
now?: () => Date;
|
||||
startupTimeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export interface ManagedPythonDaemonStatusOptions extends ManagedPythonRuntimeLayoutOptions {
|
||||
fetch?: ManagedPythonDaemonFetch;
|
||||
processAlive?: (pid: number) => boolean;
|
||||
}
|
||||
|
||||
export interface ManagedPythonDaemonStopOptions extends ManagedPythonRuntimeLayoutOptions {
|
||||
processAlive?: (pid: number) => boolean;
|
||||
killProcess?: (pid: number) => void;
|
||||
}
|
||||
|
||||
const daemonStateSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
pid: z.number().int().positive(),
|
||||
host: z.literal('127.0.0.1'),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
version: z.string().min(1),
|
||||
features: z.array(runtimeFeatureSchema).min(1),
|
||||
startedAt: z.string().min(1),
|
||||
stdoutLog: z.string().min(1),
|
||||
stderrLog: z.string().min(1),
|
||||
});
|
||||
|
||||
function normalizeFeatures(features: KtxRuntimeFeature[]): KtxRuntimeFeature[] {
|
||||
const requested = new Set<KtxRuntimeFeature>(['core', ...features]);
|
||||
return runtimeFeatureSchema.options.filter((feature) => requested.has(feature));
|
||||
}
|
||||
|
||||
function hasFeatures(state: ManagedPythonDaemonState, features: KtxRuntimeFeature[]): boolean {
|
||||
return normalizeFeatures(features).every((feature) => state.features.includes(feature));
|
||||
}
|
||||
|
||||
function defaultFetch(url: string): ReturnType<ManagedPythonDaemonFetch> {
|
||||
return fetch(url) as ReturnType<ManagedPythonDaemonFetch>;
|
||||
}
|
||||
|
||||
function defaultProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultKillProcess(pid: number): void {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
if (code !== 'ESRCH') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSpawnDaemon(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<ManagedPythonDaemonSpawn>[2],
|
||||
): ManagedPythonDaemonChild {
|
||||
return spawn(command, args, options);
|
||||
}
|
||||
|
||||
function baseUrl(state: Pick<ManagedPythonDaemonState, 'host' | 'port'>): string {
|
||||
return `http://${state.host}:${state.port}`;
|
||||
}
|
||||
|
||||
async function readState(path: string): Promise<ManagedPythonDaemonState | undefined> {
|
||||
try {
|
||||
return daemonStateSchema.parse(JSON.parse(await readFile(path, 'utf8')) as unknown);
|
||||
} catch (error) {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
if (code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeState(path: string, state: ManagedPythonDaemonState): Promise<void> {
|
||||
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function healthOk(input: {
|
||||
state: ManagedPythonDaemonState;
|
||||
cliVersion: string;
|
||||
fetch: ManagedPythonDaemonFetch;
|
||||
}): Promise<{ ok: true } | { ok: false; detail: string }> {
|
||||
try {
|
||||
const response = await input.fetch(`${baseUrl(input.state)}/health`);
|
||||
if (!response.ok) {
|
||||
return { ok: false, detail: `Health check returned HTTP ${response.status}: ${await response.text()}` };
|
||||
}
|
||||
const body = (await response.json()) as unknown;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return { ok: false, detail: 'Health check returned non-object JSON' };
|
||||
}
|
||||
const record = body as Record<string, unknown>;
|
||||
if (record.status !== 'healthy') {
|
||||
return { ok: false, detail: `Health check returned status ${String(record.status)}` };
|
||||
}
|
||||
if (record.version !== input.cliVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: `Daemon version ${String(record.version)} does not match CLI ${input.cliVersion}`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function readManagedPythonDaemonStatus(
|
||||
options: ManagedPythonDaemonStatusOptions,
|
||||
): Promise<ManagedPythonDaemonStatus> {
|
||||
const layout = managedPythonRuntimeLayout(options);
|
||||
let state: ManagedPythonDaemonState | undefined;
|
||||
try {
|
||||
state = await readState(layout.daemonStatePath);
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: 'stale',
|
||||
detail: `Daemon state is invalid: ${error instanceof Error ? error.message : String(error)}`,
|
||||
layout,
|
||||
};
|
||||
}
|
||||
if (!state) {
|
||||
return { kind: 'stopped', detail: `No daemon state at ${layout.daemonStatePath}`, layout };
|
||||
}
|
||||
if (state.version !== options.cliVersion) {
|
||||
return {
|
||||
kind: 'stale',
|
||||
detail: `Daemon is for CLI ${state.version}, current CLI is ${options.cliVersion}`,
|
||||
layout,
|
||||
state,
|
||||
};
|
||||
}
|
||||
const processAlive = options.processAlive ?? defaultProcessAlive;
|
||||
if (!processAlive(state.pid)) {
|
||||
return { kind: 'stale', detail: `Daemon process ${state.pid} is not running`, layout, state };
|
||||
}
|
||||
const health = await healthOk({
|
||||
state,
|
||||
cliVersion: options.cliVersion,
|
||||
fetch: options.fetch ?? defaultFetch,
|
||||
});
|
||||
if (!health.ok) {
|
||||
return { kind: 'stale', detail: health.detail, layout, state };
|
||||
}
|
||||
return { kind: 'running', detail: `Daemon running at ${baseUrl(state)}`, layout, state, baseUrl: baseUrl(state) };
|
||||
}
|
||||
|
||||
export async function allocateDaemonPort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
server.close(() => {
|
||||
if (address && typeof address === 'object') {
|
||||
resolve(address.port);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Failed to allocate a daemon port'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealth(input: {
|
||||
state: ManagedPythonDaemonState;
|
||||
cliVersion: string;
|
||||
fetch: ManagedPythonDaemonFetch;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + input.timeoutMs;
|
||||
let lastDetail = 'daemon did not answer health checks';
|
||||
while (Date.now() <= deadline) {
|
||||
const health = await healthOk({
|
||||
state: input.state,
|
||||
cliVersion: input.cliVersion,
|
||||
fetch: input.fetch,
|
||||
});
|
||||
if (health.ok) {
|
||||
return;
|
||||
}
|
||||
lastDetail = health.detail;
|
||||
await delay(input.pollIntervalMs);
|
||||
}
|
||||
throw new Error(`KTX Python daemon failed to start: ${lastDetail}. stderr: ${input.state.stderrLog}`);
|
||||
}
|
||||
|
||||
async function removeState(layout: ManagedPythonRuntimeLayout): Promise<void> {
|
||||
await rm(layout.daemonStatePath, { force: true });
|
||||
}
|
||||
|
||||
async function stopRecordedDaemon(input: {
|
||||
layout: ManagedPythonRuntimeLayout;
|
||||
state: ManagedPythonDaemonState;
|
||||
processAlive: (pid: number) => boolean;
|
||||
killProcess: (pid: number) => void;
|
||||
}): Promise<void> {
|
||||
if (input.processAlive(input.state.pid)) {
|
||||
input.killProcess(input.state.pid);
|
||||
}
|
||||
await removeState(input.layout);
|
||||
}
|
||||
|
||||
export async function startManagedPythonDaemon(
|
||||
options: ManagedPythonDaemonStartOptions,
|
||||
): Promise<ManagedPythonDaemonStartResult> {
|
||||
const features = normalizeFeatures(options.features);
|
||||
const installRuntime = options.installRuntime ?? installManagedPythonRuntime;
|
||||
const layoutOverrides = {
|
||||
...(options.runtimeRoot !== undefined ? { runtimeRoot: options.runtimeRoot } : {}),
|
||||
...(options.assetDir !== undefined ? { assetDir: options.assetDir } : {}),
|
||||
...(options.platform !== undefined ? { platform: options.platform } : {}),
|
||||
...(options.env !== undefined ? { env: options.env } : {}),
|
||||
...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
|
||||
};
|
||||
const layout = managedPythonRuntimeLayout({ cliVersion: options.cliVersion, ...layoutOverrides });
|
||||
const processAlive = options.processAlive ?? defaultProcessAlive;
|
||||
const killProcess = options.killProcess ?? defaultKillProcess;
|
||||
const fetchImpl = options.fetch ?? defaultFetch;
|
||||
|
||||
const status = await readManagedPythonDaemonStatus({
|
||||
cliVersion: options.cliVersion,
|
||||
...layoutOverrides,
|
||||
fetch: fetchImpl,
|
||||
processAlive,
|
||||
});
|
||||
if (options.force !== true && status.kind === 'running' && hasFeatures(status.state, features)) {
|
||||
return { status: 'reused', layout, state: status.state, baseUrl: status.baseUrl };
|
||||
}
|
||||
if ('state' in status && status.state) {
|
||||
await stopRecordedDaemon({ layout, state: status.state, processAlive, killProcess });
|
||||
} else {
|
||||
await removeState(layout);
|
||||
}
|
||||
|
||||
const installed = await installRuntime({
|
||||
cliVersion: options.cliVersion,
|
||||
...layoutOverrides,
|
||||
features,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await mkdir(layout.versionDir, { recursive: true });
|
||||
const stdout = await open(layout.daemonStdoutPath, 'a');
|
||||
const stderr = await open(layout.daemonStderrPath, 'a');
|
||||
try {
|
||||
const port = await (options.allocatePort ?? allocateDaemonPort)();
|
||||
const spawnDaemon = options.spawnDaemon ?? defaultSpawnDaemon;
|
||||
const child = spawnDaemon(
|
||||
installed.manifest.python.daemonExecutable,
|
||||
['serve-http', '--host', '127.0.0.1', '--port', String(port)],
|
||||
{
|
||||
detached: true,
|
||||
stdio: ['ignore', stdout.fd, stderr.fd],
|
||||
env: {
|
||||
...process.env,
|
||||
KTX_DAEMON_VERSION: options.cliVersion,
|
||||
},
|
||||
},
|
||||
);
|
||||
child.unref();
|
||||
if (!child.pid) {
|
||||
throw new Error(`KTX Python daemon did not report a pid. stderr: ${layout.daemonStderrPath}`);
|
||||
}
|
||||
const state: ManagedPythonDaemonState = {
|
||||
schemaVersion: 1,
|
||||
pid: child.pid,
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
version: options.cliVersion,
|
||||
features: installed.manifest.features,
|
||||
startedAt: (options.now ?? (() => new Date()))().toISOString(),
|
||||
stdoutLog: layout.daemonStdoutPath,
|
||||
stderrLog: layout.daemonStderrPath,
|
||||
};
|
||||
await waitForHealth({
|
||||
state,
|
||||
cliVersion: options.cliVersion,
|
||||
fetch: fetchImpl,
|
||||
timeoutMs: options.startupTimeoutMs ?? 10_000,
|
||||
pollIntervalMs: options.pollIntervalMs ?? 100,
|
||||
});
|
||||
await writeState(layout.daemonStatePath, state);
|
||||
return { status: 'started', layout, state, baseUrl: baseUrl(state) };
|
||||
} finally {
|
||||
await stdout.close();
|
||||
await stderr.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopManagedPythonDaemon(
|
||||
options: ManagedPythonDaemonStopOptions,
|
||||
): Promise<ManagedPythonDaemonStopResult> {
|
||||
const layout = managedPythonRuntimeLayout(options);
|
||||
const state = await readState(layout.daemonStatePath);
|
||||
if (!state) {
|
||||
return { status: 'already-stopped', layout };
|
||||
}
|
||||
await stopRecordedDaemon({
|
||||
layout,
|
||||
state,
|
||||
processAlive: options.processAlive ?? defaultProcessAlive,
|
||||
killProcess: options.killProcess ?? defaultKillProcess,
|
||||
});
|
||||
return { status: 'stopped', layout, state };
|
||||
}
|
||||
171
packages/cli/src/managed-python-http.test.ts
Normal file
171
packages/cli/src/managed-python-http.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createManagedDaemonHttpJsonRunner,
|
||||
createManagedDaemonLookerTableIdentifierParser,
|
||||
createManagedDaemonSqlAnalysisPort,
|
||||
createManagedPythonDaemonBaseUrlResolver,
|
||||
managedDaemonDatabaseIntrospectionOptions,
|
||||
} from './managed-python-http.js';
|
||||
|
||||
function io() {
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: vi.fn() },
|
||||
stderr: { write: (chunk: string) => (stderr += chunk) },
|
||||
},
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createManagedPythonDaemonBaseUrlResolver', () => {
|
||||
it('ensures the core runtime, starts the daemon, reports the URL, and caches the result', async () => {
|
||||
const testIo = io();
|
||||
const ensureRuntime = vi.fn(async () => ({
|
||||
layout: {} as never,
|
||||
manifest: {} as never,
|
||||
}));
|
||||
const startDaemon = vi.fn(async () => ({
|
||||
status: 'started' as const,
|
||||
layout: {} as never,
|
||||
state: { pid: 1234 } as never,
|
||||
baseUrl: 'http://127.0.0.1:61234',
|
||||
}));
|
||||
const resolveBaseUrl = createManagedPythonDaemonBaseUrlResolver({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io: testIo.io,
|
||||
ensureRuntime,
|
||||
startDaemon,
|
||||
});
|
||||
|
||||
await expect(resolveBaseUrl()).resolves.toBe('http://127.0.0.1:61234');
|
||||
await expect(resolveBaseUrl()).resolves.toBe('http://127.0.0.1:61234');
|
||||
|
||||
expect(ensureRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(ensureRuntime).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io: testIo.io,
|
||||
feature: 'core',
|
||||
});
|
||||
expect(startDaemon).toHaveBeenCalledTimes(1);
|
||||
expect(startDaemon).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
features: ['core'],
|
||||
force: false,
|
||||
});
|
||||
expect(testIo.stderr()).toContain('Started KTX Python daemon: http://127.0.0.1:61234');
|
||||
});
|
||||
|
||||
it('reports daemon reuse without reinstalling after the first resolved URL', async () => {
|
||||
const testIo = io();
|
||||
const ensureRuntime = vi.fn(async () => ({
|
||||
layout: {} as never,
|
||||
manifest: {} as never,
|
||||
}));
|
||||
const startDaemon = vi.fn(async () => ({
|
||||
status: 'reused' as const,
|
||||
layout: {} as never,
|
||||
state: { pid: 1234 } as never,
|
||||
baseUrl: 'http://127.0.0.1:61234',
|
||||
}));
|
||||
const resolveBaseUrl = createManagedPythonDaemonBaseUrlResolver({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'never',
|
||||
io: testIo.io,
|
||||
ensureRuntime,
|
||||
startDaemon,
|
||||
});
|
||||
|
||||
await expect(resolveBaseUrl()).resolves.toBe('http://127.0.0.1:61234');
|
||||
await expect(resolveBaseUrl()).resolves.toBe('http://127.0.0.1:61234');
|
||||
|
||||
expect(ensureRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(startDaemon).toHaveBeenCalledTimes(1);
|
||||
expect(testIo.stderr()).toContain('Using existing KTX Python daemon: http://127.0.0.1:61234');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createManagedDaemonHttpJsonRunner', () => {
|
||||
it('resolves the managed base URL lazily for each HTTP JSON request', async () => {
|
||||
const postJson = vi.fn(async () => ({ ok: true }));
|
||||
const runner = createManagedDaemonHttpJsonRunner({
|
||||
resolveBaseUrl: async () => 'http://127.0.0.1:61234',
|
||||
postJson,
|
||||
});
|
||||
|
||||
await expect(runner('/sql/parse-table-identifier', { items: [] })).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(postJson).toHaveBeenCalledWith('http://127.0.0.1:61234', '/sql/parse-table-identifier', { items: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('managed daemon ingest ports', () => {
|
||||
it('creates a Looker table parser backed by the managed daemon runner', async () => {
|
||||
const requestJson = vi.fn(async () => ({
|
||||
results: {
|
||||
'model.explore': {
|
||||
ok: true,
|
||||
catalog: 'warehouse',
|
||||
schema: 'public',
|
||||
name: 'orders',
|
||||
canonical_table: 'public.orders',
|
||||
},
|
||||
},
|
||||
}));
|
||||
const parser = createManagedDaemonLookerTableIdentifierParser({ requestJson });
|
||||
|
||||
await expect(
|
||||
parser.parse([{ key: 'model.explore', sql_table_name: 'public.orders', dialect: 'postgres' }]),
|
||||
).resolves.toEqual({
|
||||
'model.explore': {
|
||||
ok: true,
|
||||
catalog: 'warehouse',
|
||||
schema: 'public',
|
||||
name: 'orders',
|
||||
canonical_table: 'public.orders',
|
||||
},
|
||||
});
|
||||
expect(requestJson).toHaveBeenCalledWith('/sql/parse-table-identifier', {
|
||||
items: [{ key: 'model.explore', sql_table_name: 'public.orders', dialect: 'postgres' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a SQL analysis port backed by the managed daemon runner', async () => {
|
||||
const requestJson = vi.fn(async () => ({
|
||||
fingerprint: 'select-orders',
|
||||
normalized_sql: 'SELECT * FROM public.orders WHERE id = ?',
|
||||
tables_touched: ['public.orders'],
|
||||
literal_slots: [{ position: 1, type: 'number', example_value: '42' }],
|
||||
}));
|
||||
const sqlAnalysis = createManagedDaemonSqlAnalysisPort({ requestJson });
|
||||
|
||||
await expect(sqlAnalysis.analyzeForFingerprint('SELECT * FROM public.orders WHERE id = 42', 'postgres')).resolves
|
||||
.toEqual({
|
||||
fingerprint: 'select-orders',
|
||||
normalizedSql: 'SELECT * FROM public.orders WHERE id = ?',
|
||||
tablesTouched: ['public.orders'],
|
||||
literalSlots: [{ position: 1, type: 'number', exampleValue: '42' }],
|
||||
});
|
||||
expect(requestJson).toHaveBeenCalledWith('/api/sql/analyze-for-fingerprint', {
|
||||
sql: 'SELECT * FROM public.orders WHERE id = 42',
|
||||
dialect: 'postgres',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns live-database daemon request options backed by the managed runner', async () => {
|
||||
const requestJson = vi.fn(async () => ({
|
||||
connection_id: 'warehouse',
|
||||
tables: [],
|
||||
}));
|
||||
const options = managedDaemonDatabaseIntrospectionOptions({ requestJson });
|
||||
expect(options.requestJson).toBeDefined();
|
||||
|
||||
await expect(options.requestJson?.('/database/introspect', { connection_id: 'warehouse' })).resolves.toEqual({
|
||||
connection_id: 'warehouse',
|
||||
tables: [],
|
||||
});
|
||||
expect(requestJson).toHaveBeenCalledWith('/database/introspect', { connection_id: 'warehouse' });
|
||||
});
|
||||
});
|
||||
194
packages/cli/src/managed-python-http.ts
Normal file
194
packages/cli/src/managed-python-http.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
import {
|
||||
createDaemonLookerTableIdentifierParser,
|
||||
type DaemonLiveDatabaseIntrospectionOptions,
|
||||
type KtxDaemonDatabaseHttpJsonRunner,
|
||||
type KtxDaemonTableIdentifierHttpJsonRunner,
|
||||
type LookerTableIdentifierParser,
|
||||
} from '@ktx/context/ingest';
|
||||
import {
|
||||
createHttpSqlAnalysisPort,
|
||||
type KtxSqlAnalysisHttpJsonRunner,
|
||||
type SqlAnalysisPort,
|
||||
} from '@ktx/context/sql-analysis';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import {
|
||||
ensureManagedPythonCommandRuntime,
|
||||
type KtxManagedPythonInstallPolicy,
|
||||
type ManagedPythonCommandRuntime,
|
||||
} from './managed-python-command.js';
|
||||
import { startManagedPythonDaemon, type ManagedPythonDaemonStartResult } from './managed-python-daemon.js';
|
||||
|
||||
export type ManagedPythonHttpJsonRunner = (
|
||||
path: string,
|
||||
payload: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown>>;
|
||||
|
||||
export type ManagedPythonHttpPostJson = (
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
payload: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown>>;
|
||||
|
||||
export interface ManagedPythonCoreDaemonOptions {
|
||||
cliVersion: string;
|
||||
installPolicy: KtxManagedPythonInstallPolicy;
|
||||
io: KtxCliIo;
|
||||
ensureRuntime?: (options: {
|
||||
cliVersion: string;
|
||||
installPolicy: KtxManagedPythonInstallPolicy;
|
||||
io: KtxCliIo;
|
||||
feature: 'core';
|
||||
}) => Promise<ManagedPythonCommandRuntime>;
|
||||
startDaemon?: (options: {
|
||||
cliVersion: string;
|
||||
features: ['core'];
|
||||
force: false;
|
||||
}) => Promise<ManagedPythonDaemonStartResult>;
|
||||
}
|
||||
|
||||
export type ManagedPythonDaemonHttpOptions =
|
||||
| {
|
||||
requestJson: ManagedPythonHttpJsonRunner;
|
||||
}
|
||||
| {
|
||||
resolveBaseUrl: () => Promise<string>;
|
||||
postJson?: ManagedPythonHttpPostJson;
|
||||
}
|
||||
| (ManagedPythonCoreDaemonOptions & {
|
||||
postJson?: ManagedPythonHttpPostJson;
|
||||
});
|
||||
|
||||
function normalizedBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
}
|
||||
|
||||
function parseJsonObject(raw: string, path: string): Record<string, unknown> {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`KTX managed daemon HTTP ${path} returned non-object JSON`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function postManagedDaemonJson(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const target = new URL(path.replace(/^\//, ''), normalizedBaseUrl(baseUrl));
|
||||
const body = JSON.stringify(payload);
|
||||
const client = target.protocol === 'https:' ? httpsRequest : httpRequest;
|
||||
const request = client(
|
||||
target,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
const statusCode = response.statusCode ?? 0;
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
reject(new Error(`KTX managed daemon HTTP ${path} failed with ${statusCode}: ${text}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(parseJsonObject(text, path));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
export function createManagedPythonDaemonBaseUrlResolver(
|
||||
options: ManagedPythonCoreDaemonOptions,
|
||||
): () => Promise<string> {
|
||||
let cachedBaseUrl: string | undefined;
|
||||
|
||||
return async () => {
|
||||
if (cachedBaseUrl) {
|
||||
return cachedBaseUrl;
|
||||
}
|
||||
|
||||
const ensureRuntime = options.ensureRuntime ?? ensureManagedPythonCommandRuntime;
|
||||
const startDaemon = options.startDaemon ?? startManagedPythonDaemon;
|
||||
await ensureRuntime({
|
||||
cliVersion: options.cliVersion,
|
||||
installPolicy: options.installPolicy,
|
||||
io: options.io,
|
||||
feature: 'core',
|
||||
});
|
||||
const daemon = await startDaemon({
|
||||
cliVersion: options.cliVersion,
|
||||
features: ['core'],
|
||||
force: false,
|
||||
});
|
||||
const verb = daemon.status === 'started' ? 'Started' : 'Using existing';
|
||||
options.io.stderr.write(`${verb} KTX Python daemon: ${daemon.baseUrl}\n`);
|
||||
cachedBaseUrl = daemon.baseUrl;
|
||||
return cachedBaseUrl;
|
||||
};
|
||||
}
|
||||
|
||||
function isRequestJsonOnly(options: ManagedPythonDaemonHttpOptions): options is { requestJson: ManagedPythonHttpJsonRunner } {
|
||||
return 'requestJson' in options;
|
||||
}
|
||||
|
||||
function isResolveBaseUrlOnly(
|
||||
options: ManagedPythonDaemonHttpOptions,
|
||||
): options is { resolveBaseUrl: () => Promise<string>; postJson?: ManagedPythonHttpPostJson } {
|
||||
return 'resolveBaseUrl' in options;
|
||||
}
|
||||
|
||||
export function createManagedDaemonHttpJsonRunner(
|
||||
options: ManagedPythonDaemonHttpOptions,
|
||||
): ManagedPythonHttpJsonRunner {
|
||||
if (isRequestJsonOnly(options)) {
|
||||
return options.requestJson;
|
||||
}
|
||||
const resolveBaseUrl = isResolveBaseUrlOnly(options)
|
||||
? options.resolveBaseUrl
|
||||
: createManagedPythonDaemonBaseUrlResolver(options);
|
||||
const postJson = options.postJson ?? postManagedDaemonJson;
|
||||
|
||||
return async (path, payload) => postJson(await resolveBaseUrl(), path, payload);
|
||||
}
|
||||
|
||||
export function createManagedDaemonLookerTableIdentifierParser(
|
||||
options: ManagedPythonDaemonHttpOptions,
|
||||
): LookerTableIdentifierParser {
|
||||
return createDaemonLookerTableIdentifierParser({
|
||||
baseUrl: 'http://127.0.0.1:0',
|
||||
requestJson: createManagedDaemonHttpJsonRunner(options) as KtxDaemonTableIdentifierHttpJsonRunner,
|
||||
});
|
||||
}
|
||||
|
||||
export function createManagedDaemonSqlAnalysisPort(options: ManagedPythonDaemonHttpOptions): SqlAnalysisPort {
|
||||
return createHttpSqlAnalysisPort({
|
||||
baseUrl: 'http://127.0.0.1:0',
|
||||
requestJson: createManagedDaemonHttpJsonRunner(options) as KtxSqlAnalysisHttpJsonRunner,
|
||||
});
|
||||
}
|
||||
|
||||
export function managedDaemonDatabaseIntrospectionOptions(
|
||||
options: ManagedPythonDaemonHttpOptions,
|
||||
): Pick<DaemonLiveDatabaseIntrospectionOptions, 'requestJson'> {
|
||||
return {
|
||||
requestJson: createManagedDaemonHttpJsonRunner(options) as KtxDaemonDatabaseHttpJsonRunner,
|
||||
};
|
||||
}
|
||||
479
packages/cli/src/managed-python-runtime.test.ts
Normal file
479
packages/cli/src/managed-python-runtime.test.ts
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
MISSING_UV_RUNTIME_INSTALL_MESSAGE,
|
||||
doctorManagedPythonRuntime,
|
||||
installManagedPythonRuntime,
|
||||
managedPythonRuntimeLayout,
|
||||
pruneManagedPythonRuntimes,
|
||||
readManagedPythonRuntimeStatus,
|
||||
verifyRuntimeAsset,
|
||||
type ManagedPythonRuntimeExec,
|
||||
} from './managed-python-runtime.js';
|
||||
|
||||
async function writeAsset(root: string, contents = 'wheel-bytes') {
|
||||
const assetDir = join(root, 'assets', 'python');
|
||||
await mkdir(assetDir, { recursive: true });
|
||||
const wheelPath = join(assetDir, 'kaelio_ktx-0.1.0-py3-none-any.whl');
|
||||
await writeFile(wheelPath, contents);
|
||||
await writeFile(
|
||||
join(assetDir, 'manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
distributionName: 'kaelio-ktx',
|
||||
normalizedName: 'kaelio_ktx',
|
||||
version: '0.1.0',
|
||||
wheel: {
|
||||
file: 'kaelio_ktx-0.1.0-py3-none-any.whl',
|
||||
sha256: createHash('sha256').update(contents).digest('hex'),
|
||||
bytes: Buffer.byteLength(contents),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return { assetDir, wheelPath };
|
||||
}
|
||||
|
||||
describe('managedPythonRuntimeLayout', () => {
|
||||
it('uses the macOS application-support runtime root', () => {
|
||||
const layout = managedPythonRuntimeLayout({
|
||||
cliVersion: '0.2.0',
|
||||
platform: 'darwin',
|
||||
env: {},
|
||||
homeDir: '/Users/alex',
|
||||
assetDir: '/repo/packages/cli/assets/python',
|
||||
});
|
||||
|
||||
expect(layout.runtimeRoot).toBe('/Users/alex/Library/Application Support/kaelio/ktx/runtime');
|
||||
expect(layout.versionDir).toBe('/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0');
|
||||
expect(layout.venvDir).toBe('/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/.venv');
|
||||
expect(layout.pythonPath).toBe(
|
||||
'/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/.venv/bin/python',
|
||||
);
|
||||
expect(layout.daemonPath).toBe(
|
||||
'/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
);
|
||||
expect(layout.daemonStatePath).toBe(
|
||||
'/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/daemon.json',
|
||||
);
|
||||
expect(layout.daemonStdoutPath).toBe(
|
||||
'/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/daemon.stdout.log',
|
||||
);
|
||||
expect(layout.daemonStderrPath).toBe(
|
||||
'/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/daemon.stderr.log',
|
||||
);
|
||||
expect(layout.assetManifestPath).toBe('/repo/packages/cli/assets/python/manifest.json');
|
||||
});
|
||||
|
||||
it('honors KTX_RUNTIME_ROOT before platform defaults', () => {
|
||||
const layout = managedPythonRuntimeLayout({
|
||||
cliVersion: '0.2.0',
|
||||
platform: 'darwin',
|
||||
env: { KTX_RUNTIME_ROOT: '/tmp/ktx-runtime' },
|
||||
homeDir: '/Users/alex',
|
||||
assetDir: '/repo/packages/cli/assets/python',
|
||||
});
|
||||
|
||||
expect(layout.runtimeRoot).toBe('/tmp/ktx-runtime');
|
||||
expect(layout.versionDir).toBe('/tmp/ktx-runtime/0.2.0');
|
||||
});
|
||||
|
||||
it('honors XDG_DATA_HOME on Linux', () => {
|
||||
const layout = managedPythonRuntimeLayout({
|
||||
cliVersion: '0.2.0',
|
||||
platform: 'linux',
|
||||
env: { XDG_DATA_HOME: '/var/xdg' },
|
||||
homeDir: '/home/alex',
|
||||
assetDir: '/repo/packages/cli/assets/python',
|
||||
});
|
||||
|
||||
expect(layout.runtimeRoot).toBe('/var/xdg/kaelio/ktx/runtime');
|
||||
expect(layout.versionDir).toBe('/var/xdg/kaelio/ktx/runtime/0.2.0');
|
||||
});
|
||||
|
||||
it('uses LocalAppData on Windows', () => {
|
||||
const layout = managedPythonRuntimeLayout({
|
||||
cliVersion: '0.2.0',
|
||||
platform: 'win32',
|
||||
env: { LOCALAPPDATA: 'C:\\Users\\Alex\\AppData\\Local' },
|
||||
homeDir: 'C:\\Users\\Alex',
|
||||
assetDir: 'C:\\repo\\packages\\cli\\assets\\python',
|
||||
});
|
||||
|
||||
expect(layout.runtimeRoot).toBe('C:\\Users\\Alex\\AppData\\Local/Kaelio/KTX/runtime');
|
||||
expect(layout.pythonPath).toBe('C:\\Users\\Alex\\AppData\\Local/Kaelio/KTX/runtime/0.2.0/.venv/Scripts/python.exe');
|
||||
expect(layout.daemonPath).toBe('C:\\Users\\Alex\\AppData\\Local/Kaelio/KTX/runtime/0.2.0/.venv/Scripts/ktx-daemon.exe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyRuntimeAsset', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-asset-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads the manifest and verifies the wheel checksum', async () => {
|
||||
const { assetDir, wheelPath } = await writeAsset(tempDir, 'valid-wheel');
|
||||
|
||||
const asset = await verifyRuntimeAsset({ assetDir });
|
||||
|
||||
expect(asset.manifest.distributionName).toBe('kaelio-ktx');
|
||||
expect(asset.manifest.normalizedName).toBe('kaelio_ktx');
|
||||
expect(asset.wheelPath).toBe(wheelPath);
|
||||
});
|
||||
|
||||
it('rejects a wheel whose checksum does not match the manifest', async () => {
|
||||
const { assetDir, wheelPath } = await writeAsset(tempDir, 'original');
|
||||
await writeFile(wheelPath, 'tampered');
|
||||
|
||||
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
|
||||
/Bundled Python runtime wheel checksum mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unsafe wheel filename in the manifest', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'valid-wheel');
|
||||
await writeFile(
|
||||
join(assetDir, 'manifest.json'),
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
distributionName: 'kaelio-ktx',
|
||||
normalizedName: 'kaelio_ktx',
|
||||
version: '0.1.0',
|
||||
wheel: {
|
||||
file: '../kaelio_ktx-0.1.0-py3-none-any.whl',
|
||||
sha256: 'a'.repeat(64),
|
||||
bytes: 1,
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(/Unsafe runtime wheel filename/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installManagedPythonRuntime', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-install-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates a venv, installs the core wheel, and writes a manifest', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||||
});
|
||||
|
||||
const result = await installManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
features: ['core'],
|
||||
exec,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('installed');
|
||||
expect(commands).toEqual([
|
||||
{ command: 'uv', args: ['--version'] },
|
||||
{ command: 'uv', args: ['venv', result.layout.venvDir] },
|
||||
{
|
||||
command: 'uv',
|
||||
args: ['pip', 'install', '--python', result.layout.pythonPath, result.asset.wheelPath],
|
||||
},
|
||||
]);
|
||||
const manifest = JSON.parse(await readFile(result.layout.manifestPath, 'utf8')) as {
|
||||
cliVersion: string;
|
||||
features: string[];
|
||||
python: { executable: string; daemonExecutable: string };
|
||||
};
|
||||
expect(manifest.cliVersion).toBe('0.2.0');
|
||||
expect(manifest.features).toEqual(['core']);
|
||||
expect(manifest.python.executable).toBe(result.layout.pythonPath);
|
||||
expect(manifest.python.daemonExecutable).toBe(result.layout.daemonPath);
|
||||
});
|
||||
|
||||
it('installs the local-embeddings extra when requested', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'embedding-wheel');
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||||
});
|
||||
|
||||
const result = await installManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
features: ['local-embeddings'],
|
||||
exec,
|
||||
});
|
||||
|
||||
expect(commands.at(-1)).toEqual({
|
||||
command: 'uv',
|
||||
args: ['pip', 'install', '--python', result.layout.pythonPath, `${result.asset.wheelPath}[local-embeddings]`],
|
||||
});
|
||||
const manifest = JSON.parse(await readFile(result.layout.manifestPath, 'utf8')) as { features: string[] };
|
||||
expect(manifest.features).toEqual(['core', 'local-embeddings']);
|
||||
});
|
||||
|
||||
it('fails with the hard-prerequisite message when uv is missing', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
throw new Error('spawn uv ENOENT');
|
||||
});
|
||||
|
||||
await expect(
|
||||
installManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
features: ['core'],
|
||||
exec,
|
||||
}),
|
||||
).rejects.toThrow(MISSING_UV_RUNTIME_INSTALL_MESSAGE);
|
||||
|
||||
expect(commands).toEqual([{ command: 'uv', args: ['--version'] }]);
|
||||
});
|
||||
|
||||
it('reuses an existing compatible runtime when force is false', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
}));
|
||||
|
||||
const first = await installManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
features: ['core'],
|
||||
exec,
|
||||
});
|
||||
await mkdir(join(first.layout.venvDir, 'bin'), { recursive: true });
|
||||
await writeFile(first.layout.pythonPath, '#!/usr/bin/env python\n');
|
||||
await writeFile(first.layout.daemonPath, '#!/usr/bin/env python\n');
|
||||
|
||||
const second = await installManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
features: ['core'],
|
||||
exec,
|
||||
});
|
||||
|
||||
expect(second.status).toBe('ready');
|
||||
expect(exec).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('keeps failed install logs in the versioned runtime directory', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
if (command === 'uv' && args[0] === 'venv') {
|
||||
throw Object.assign(new Error('uv venv failed'), { stdout: 'creating\n', stderr: 'bad python\n' });
|
||||
}
|
||||
return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||||
});
|
||||
|
||||
await expect(
|
||||
installManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
features: ['core'],
|
||||
exec,
|
||||
}),
|
||||
).rejects.toThrow(/Python runtime install failed/);
|
||||
|
||||
const log = await readFile(join(tempDir, 'runtime', '0.2.0', 'install.log'), 'utf8');
|
||||
expect(log).toContain('$ uv venv');
|
||||
expect(log).toContain('bad python');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readManagedPythonRuntimeStatus', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-status-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports missing before install', async () => {
|
||||
const status = await readManagedPythonRuntimeStatus({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir: join(tempDir, 'assets', 'python'),
|
||||
});
|
||||
|
||||
expect(status.kind).toBe('missing');
|
||||
expect(status.detail).toContain('No runtime manifest');
|
||||
});
|
||||
|
||||
it('reports ready when manifest and executables exist', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
}));
|
||||
const install = await installManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
features: ['core'],
|
||||
exec,
|
||||
});
|
||||
await mkdir(join(install.layout.venvDir, 'bin'), { recursive: true });
|
||||
await writeFile(install.layout.pythonPath, '#!/usr/bin/env python\n');
|
||||
await writeFile(install.layout.daemonPath, '#!/usr/bin/env python\n');
|
||||
|
||||
const status = await readManagedPythonRuntimeStatus({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
});
|
||||
|
||||
expect(status.kind).toBe('ready');
|
||||
expect(status.manifest?.features).toEqual(['core']);
|
||||
});
|
||||
|
||||
it('reports broken when an executable is missing', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
}));
|
||||
await installManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
features: ['core'],
|
||||
exec,
|
||||
});
|
||||
|
||||
const status = await readManagedPythonRuntimeStatus({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
});
|
||||
|
||||
expect(status.kind).toBe('broken');
|
||||
expect(status.detail).toContain('Missing Python executable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('doctorManagedPythonRuntime', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-doctor-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('checks uv, bundled assets, and installed runtime status', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
}));
|
||||
|
||||
const checks = await doctorManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
exec,
|
||||
});
|
||||
|
||||
expect(checks.map((check) => [check.id, check.status])).toEqual([
|
||||
['uv', 'pass'],
|
||||
['asset', 'pass'],
|
||||
['runtime', 'fail'],
|
||||
]);
|
||||
expect(checks[2]?.fix).toBe('Run: ktx runtime install --yes');
|
||||
});
|
||||
|
||||
it('reports uv as a hard prerequisite when uv is missing', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async () => {
|
||||
throw new Error('spawn uv ENOENT');
|
||||
});
|
||||
|
||||
const checks = await doctorManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
exec,
|
||||
});
|
||||
|
||||
expect(checks[0]).toEqual({
|
||||
id: 'uv',
|
||||
label: 'uv',
|
||||
status: 'fail',
|
||||
detail: MISSING_UV_RUNTIME_INSTALL_MESSAGE,
|
||||
fix: 'Install uv, make sure it is on PATH, and run: ktx runtime install --yes',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneManagedPythonRuntimes', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-prune-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('removes stale version directories and keeps the current version', async () => {
|
||||
const runtimeRoot = join(tempDir, 'runtime');
|
||||
await mkdir(join(runtimeRoot, '0.1.0'), { recursive: true });
|
||||
await mkdir(join(runtimeRoot, '0.2.0'), { recursive: true });
|
||||
await writeFile(join(runtimeRoot, 'README.txt'), 'not a runtime directory\n');
|
||||
|
||||
const result = await pruneManagedPythonRuntimes({ cliVersion: '0.2.0', runtimeRoot });
|
||||
|
||||
expect(result.removed).toEqual([join(runtimeRoot, '0.1.0')]);
|
||||
expect(result.kept).toEqual([join(runtimeRoot, '0.2.0')]);
|
||||
await expect(stat(join(runtimeRoot, '0.1.0'))).rejects.toThrow();
|
||||
expect(await readdir(runtimeRoot)).toEqual(['0.2.0', 'README.txt']);
|
||||
});
|
||||
|
||||
it('supports dry-run without deleting stale directories', async () => {
|
||||
const runtimeRoot = join(tempDir, 'runtime');
|
||||
await mkdir(join(runtimeRoot, '0.1.0'), { recursive: true });
|
||||
await mkdir(join(runtimeRoot, '0.2.0'), { recursive: true });
|
||||
|
||||
const result = await pruneManagedPythonRuntimes({ cliVersion: '0.2.0', runtimeRoot, dryRun: true });
|
||||
|
||||
expect(result.removed).toEqual([]);
|
||||
expect(result.stale).toEqual([join(runtimeRoot, '0.1.0')]);
|
||||
expect(await readdir(runtimeRoot)).toEqual(['0.1.0', '0.2.0']);
|
||||
});
|
||||
});
|
||||
444
packages/cli/src/managed-python-runtime.ts
Normal file
444
packages/cli/src/managed-python-runtime.ts
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { access, appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { z } from 'zod';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const runtimeFeatureSchema = z.enum(['core', 'local-embeddings']);
|
||||
export type KtxRuntimeFeature = z.infer<typeof runtimeFeatureSchema>;
|
||||
|
||||
const runtimeAssetManifestSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
distributionName: z.literal('kaelio-ktx'),
|
||||
normalizedName: z.literal('kaelio_ktx'),
|
||||
version: z.string().min(1),
|
||||
wheel: z.object({
|
||||
file: z.string().min(1),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type KtxRuntimeAssetManifest = z.infer<typeof runtimeAssetManifestSchema>;
|
||||
|
||||
const installedRuntimeManifestSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
cliVersion: z.string().min(1),
|
||||
installedAt: z.string().min(1),
|
||||
asset: runtimeAssetManifestSchema,
|
||||
features: z.array(runtimeFeatureSchema).min(1),
|
||||
python: z.object({
|
||||
executable: z.string().min(1),
|
||||
daemonExecutable: z.string().min(1),
|
||||
}),
|
||||
installLog: z.string().min(1),
|
||||
});
|
||||
|
||||
export type InstalledKtxRuntimeManifest = z.infer<typeof installedRuntimeManifestSchema>;
|
||||
|
||||
export interface ManagedPythonRuntimeLayoutOptions {
|
||||
cliVersion: string;
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
runtimeRoot?: string;
|
||||
assetDir?: string;
|
||||
}
|
||||
|
||||
export interface ManagedPythonRuntimeLayout {
|
||||
cliVersion: string;
|
||||
runtimeRoot: string;
|
||||
versionDir: string;
|
||||
venvDir: string;
|
||||
manifestPath: string;
|
||||
installLogPath: string;
|
||||
assetDir: string;
|
||||
assetManifestPath: string;
|
||||
pythonPath: string;
|
||||
daemonPath: string;
|
||||
daemonStatePath: string;
|
||||
daemonStdoutPath: string;
|
||||
daemonStderrPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedRuntimeAsset {
|
||||
manifest: KtxRuntimeAssetManifest;
|
||||
wheelPath: string;
|
||||
}
|
||||
|
||||
export type ManagedPythonRuntimeExec = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
export interface ManagedPythonRuntimeInstallOptions extends ManagedPythonRuntimeLayoutOptions {
|
||||
features: KtxRuntimeFeature[];
|
||||
force?: boolean;
|
||||
exec?: ManagedPythonRuntimeExec;
|
||||
}
|
||||
|
||||
export interface ManagedPythonRuntimeInstallResult {
|
||||
status: 'ready' | 'installed';
|
||||
layout: ManagedPythonRuntimeLayout;
|
||||
asset: ManagedRuntimeAsset;
|
||||
manifest: InstalledKtxRuntimeManifest;
|
||||
}
|
||||
|
||||
export type ManagedPythonRuntimeStatusKind = 'missing' | 'ready' | 'mismatched' | 'broken';
|
||||
|
||||
export interface ManagedPythonRuntimeStatus {
|
||||
kind: ManagedPythonRuntimeStatusKind;
|
||||
detail: string;
|
||||
layout: ManagedPythonRuntimeLayout;
|
||||
manifest?: InstalledKtxRuntimeManifest;
|
||||
}
|
||||
|
||||
export interface ManagedPythonRuntimeDoctorCheck {
|
||||
id: 'uv' | 'asset' | 'runtime';
|
||||
label: string;
|
||||
status: 'pass' | 'fail';
|
||||
detail: string;
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
export interface ManagedPythonRuntimePruneResult {
|
||||
runtimeRoot: string;
|
||||
stale: string[];
|
||||
kept: string[];
|
||||
removed: string[];
|
||||
}
|
||||
|
||||
export const MISSING_UV_RUNTIME_INSTALL_MESSAGE =
|
||||
'uv is required to install the KTX Python runtime. KTX does not download uv automatically. Install uv, make sure it is on PATH, and retry: ktx runtime install --yes';
|
||||
|
||||
function defaultAssetDir(): string {
|
||||
return fileURLToPath(new URL('../assets/python/', import.meta.url));
|
||||
}
|
||||
|
||||
function runtimeRootFor(input: Required<Pick<ManagedPythonRuntimeLayoutOptions, 'platform' | 'env' | 'homeDir'>>): string {
|
||||
if (input.env.KTX_RUNTIME_ROOT) {
|
||||
return input.env.KTX_RUNTIME_ROOT;
|
||||
}
|
||||
if (input.platform === 'darwin') {
|
||||
return join(input.homeDir, 'Library', 'Application Support', 'kaelio', 'ktx', 'runtime');
|
||||
}
|
||||
if (input.platform === 'win32') {
|
||||
return join(input.env.LOCALAPPDATA ?? join(input.homeDir, 'AppData', 'Local'), 'Kaelio', 'KTX', 'runtime');
|
||||
}
|
||||
return join(input.env.XDG_DATA_HOME ?? join(input.homeDir, '.local', 'share'), 'kaelio', 'ktx', 'runtime');
|
||||
}
|
||||
|
||||
function executablePath(venvDir: string, platform: NodeJS.Platform, name: string): string {
|
||||
if (platform === 'win32') {
|
||||
return join(venvDir, 'Scripts', `${name}.exe`);
|
||||
}
|
||||
return join(venvDir, 'bin', name);
|
||||
}
|
||||
|
||||
export function managedPythonRuntimeLayout(options: ManagedPythonRuntimeLayoutOptions): ManagedPythonRuntimeLayout {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? homedir();
|
||||
const runtimeRoot = options.runtimeRoot ?? runtimeRootFor({ platform, env, homeDir });
|
||||
const versionDir = join(runtimeRoot, options.cliVersion);
|
||||
const venvDir = join(versionDir, '.venv');
|
||||
const assetDir = options.assetDir ?? defaultAssetDir();
|
||||
|
||||
return {
|
||||
cliVersion: options.cliVersion,
|
||||
runtimeRoot,
|
||||
versionDir,
|
||||
venvDir,
|
||||
manifestPath: join(versionDir, 'manifest.json'),
|
||||
installLogPath: join(versionDir, 'install.log'),
|
||||
assetDir,
|
||||
assetManifestPath: join(assetDir, 'manifest.json'),
|
||||
pythonPath: executablePath(venvDir, platform, 'python'),
|
||||
daemonPath: executablePath(venvDir, platform, 'ktx-daemon'),
|
||||
daemonStatePath: join(versionDir, 'daemon.json'),
|
||||
daemonStdoutPath: join(versionDir, 'daemon.stdout.log'),
|
||||
daemonStderrPath: join(versionDir, 'daemon.stderr.log'),
|
||||
};
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeWheelFilename(file: string): void {
|
||||
if (file !== basename(file) || file.includes('/') || file.includes('\\')) {
|
||||
throw new Error(`Unsafe runtime wheel filename in bundled manifest: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonFile(path: string): Promise<unknown> {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as unknown;
|
||||
}
|
||||
|
||||
export async function verifyRuntimeAsset(input: { assetDir: string }): Promise<ManagedRuntimeAsset> {
|
||||
const manifestPath = join(input.assetDir, 'manifest.json');
|
||||
const manifest = runtimeAssetManifestSchema.parse(await readJsonFile(manifestPath));
|
||||
assertSafeWheelFilename(manifest.wheel.file);
|
||||
const wheelPath = join(input.assetDir, manifest.wheel.file);
|
||||
const wheel = await readFile(wheelPath);
|
||||
const sha256 = createHash('sha256').update(wheel).digest('hex');
|
||||
if (sha256 !== manifest.wheel.sha256 || wheel.byteLength !== manifest.wheel.bytes) {
|
||||
throw new Error(`Bundled Python runtime wheel checksum mismatch: ${wheelPath}`);
|
||||
}
|
||||
return { manifest, wheelPath };
|
||||
}
|
||||
|
||||
function normalizeFeatures(features: KtxRuntimeFeature[]): KtxRuntimeFeature[] {
|
||||
const requested = new Set<KtxRuntimeFeature>(['core', ...features]);
|
||||
return runtimeFeatureSchema.options.filter((feature) => requested.has(feature));
|
||||
}
|
||||
|
||||
async function readInstalledManifest(path: string): Promise<InstalledKtxRuntimeManifest | undefined> {
|
||||
if (!(await pathExists(path))) {
|
||||
return undefined;
|
||||
}
|
||||
return installedRuntimeManifestSchema.parse(await readJsonFile(path));
|
||||
}
|
||||
|
||||
function hasFeatures(manifest: InstalledKtxRuntimeManifest, features: KtxRuntimeFeature[]): boolean {
|
||||
return normalizeFeatures(features).every((feature) => manifest.features.includes(feature));
|
||||
}
|
||||
|
||||
async function defaultExec(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024 * 20,
|
||||
});
|
||||
return { stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
function errorOutput(error: unknown): { stdout: string; stderr: string } {
|
||||
const value = error as { stdout?: unknown; stderr?: unknown };
|
||||
return {
|
||||
stdout: typeof value.stdout === 'string' ? value.stdout : '',
|
||||
stderr: typeof value.stderr === 'string' ? value.stderr : '',
|
||||
};
|
||||
}
|
||||
|
||||
async function runLogged(input: {
|
||||
exec: ManagedPythonRuntimeExec;
|
||||
logPath: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
}): Promise<{ stdout: string; stderr: string }> {
|
||||
await appendFile(input.logPath, `$ ${input.command} ${input.args.join(' ')}\n`);
|
||||
try {
|
||||
const result = await input.exec(input.command, input.args, { cwd: input.cwd });
|
||||
if (result.stdout) {
|
||||
await appendFile(input.logPath, result.stdout.endsWith('\n') ? result.stdout : `${result.stdout}\n`);
|
||||
}
|
||||
if (result.stderr) {
|
||||
await appendFile(input.logPath, result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const output = errorOutput(error);
|
||||
if (output.stdout) {
|
||||
await appendFile(input.logPath, output.stdout.endsWith('\n') ? output.stdout : `${output.stdout}\n`);
|
||||
}
|
||||
if (output.stderr) {
|
||||
await appendFile(input.logPath, output.stderr.endsWith('\n') ? output.stderr : `${output.stderr}\n`);
|
||||
}
|
||||
throw new Error(`Python runtime install failed. Install log: ${input.logPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUv(exec: ManagedPythonRuntimeExec): Promise<string> {
|
||||
try {
|
||||
const result = await exec('uv', ['--version']);
|
||||
return result.stdout.trim() || 'uv available';
|
||||
} catch {
|
||||
throw new Error(MISSING_UV_RUNTIME_INSTALL_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
export async function installManagedPythonRuntime(
|
||||
options: ManagedPythonRuntimeInstallOptions,
|
||||
): Promise<ManagedPythonRuntimeInstallResult> {
|
||||
const layout = managedPythonRuntimeLayout(options);
|
||||
const exec = options.exec ?? defaultExec;
|
||||
const features = normalizeFeatures(options.features);
|
||||
const asset = await verifyRuntimeAsset({ assetDir: layout.assetDir });
|
||||
const existing = await readInstalledManifest(layout.manifestPath);
|
||||
if (
|
||||
options.force !== true &&
|
||||
existing &&
|
||||
existing.cliVersion === options.cliVersion &&
|
||||
existing.asset.wheel.sha256 === asset.manifest.wheel.sha256 &&
|
||||
hasFeatures(existing, features) &&
|
||||
(await pathExists(existing.python.executable)) &&
|
||||
(await pathExists(existing.python.daemonExecutable))
|
||||
) {
|
||||
return { status: 'ready', layout, asset, manifest: existing };
|
||||
}
|
||||
|
||||
await rm(layout.versionDir, { recursive: true, force: true });
|
||||
await mkdir(layout.versionDir, { recursive: true });
|
||||
await writeFile(layout.installLogPath, '');
|
||||
await ensureUv(exec);
|
||||
await runLogged({ exec, logPath: layout.installLogPath, command: 'uv', args: ['venv', layout.venvDir] });
|
||||
const wheelSpec = features.includes('local-embeddings') ? `${asset.wheelPath}[local-embeddings]` : asset.wheelPath;
|
||||
await runLogged({
|
||||
exec,
|
||||
logPath: layout.installLogPath,
|
||||
command: 'uv',
|
||||
args: ['pip', 'install', '--python', layout.pythonPath, wheelSpec],
|
||||
});
|
||||
|
||||
const manifest: InstalledKtxRuntimeManifest = {
|
||||
schemaVersion: 1,
|
||||
cliVersion: options.cliVersion,
|
||||
installedAt: new Date().toISOString(),
|
||||
asset: asset.manifest,
|
||||
features,
|
||||
python: {
|
||||
executable: layout.pythonPath,
|
||||
daemonExecutable: layout.daemonPath,
|
||||
},
|
||||
installLog: layout.installLogPath,
|
||||
};
|
||||
await writeFile(layout.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
return { status: 'installed', layout, asset, manifest };
|
||||
}
|
||||
|
||||
export async function readManagedPythonRuntimeStatus(
|
||||
options: ManagedPythonRuntimeLayoutOptions,
|
||||
): Promise<ManagedPythonRuntimeStatus> {
|
||||
const layout = managedPythonRuntimeLayout(options);
|
||||
let manifest: InstalledKtxRuntimeManifest | undefined;
|
||||
try {
|
||||
manifest = await readInstalledManifest(layout.manifestPath);
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: 'broken',
|
||||
detail: `Runtime manifest is invalid: ${error instanceof Error ? error.message : String(error)}`,
|
||||
layout,
|
||||
};
|
||||
}
|
||||
if (!manifest) {
|
||||
return { kind: 'missing', detail: `No runtime manifest at ${layout.manifestPath}`, layout };
|
||||
}
|
||||
if (manifest.cliVersion !== options.cliVersion) {
|
||||
return {
|
||||
kind: 'mismatched',
|
||||
detail: `Runtime is for CLI ${manifest.cliVersion}, current CLI is ${options.cliVersion}`,
|
||||
layout,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (!(await pathExists(manifest.python.executable))) {
|
||||
return { kind: 'broken', detail: `Missing Python executable: ${manifest.python.executable}`, layout, manifest };
|
||||
}
|
||||
if (!(await pathExists(manifest.python.daemonExecutable))) {
|
||||
return { kind: 'broken', detail: `Missing ktx-daemon executable: ${manifest.python.daemonExecutable}`, layout, manifest };
|
||||
}
|
||||
return { kind: 'ready', detail: `Runtime ready at ${layout.versionDir}`, layout, manifest };
|
||||
}
|
||||
|
||||
function check(
|
||||
status: ManagedPythonRuntimeDoctorCheck['status'],
|
||||
input: Omit<ManagedPythonRuntimeDoctorCheck, 'status'>,
|
||||
): ManagedPythonRuntimeDoctorCheck {
|
||||
return { status, ...input };
|
||||
}
|
||||
|
||||
export async function doctorManagedPythonRuntime(
|
||||
options: ManagedPythonRuntimeLayoutOptions & { exec?: ManagedPythonRuntimeExec },
|
||||
): Promise<ManagedPythonRuntimeDoctorCheck[]> {
|
||||
const exec = options.exec ?? defaultExec;
|
||||
const checks: ManagedPythonRuntimeDoctorCheck[] = [];
|
||||
try {
|
||||
const version = await ensureUv(exec);
|
||||
checks.push(check('pass', { id: 'uv', label: 'uv', detail: version }));
|
||||
} catch (error) {
|
||||
checks.push(
|
||||
check('fail', {
|
||||
id: 'uv',
|
||||
label: 'uv',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
fix: 'Install uv, make sure it is on PATH, and run: ktx runtime install --yes',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const asset = await verifyRuntimeAsset({ assetDir: managedPythonRuntimeLayout(options).assetDir });
|
||||
checks.push(check('pass', { id: 'asset', label: 'Bundled Python wheel', detail: asset.wheelPath }));
|
||||
} catch (error) {
|
||||
checks.push(
|
||||
check('fail', {
|
||||
id: 'asset',
|
||||
label: 'Bundled Python wheel',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
fix: 'Run: pnpm run artifacts:check',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const status = await readManagedPythonRuntimeStatus(options);
|
||||
checks.push(
|
||||
check(status.kind === 'ready' ? 'pass' : 'fail', {
|
||||
id: 'runtime',
|
||||
label: 'Managed Python runtime',
|
||||
detail: status.detail,
|
||||
...(status.kind === 'ready' ? {} : { fix: 'Run: ktx runtime install --yes' }),
|
||||
}),
|
||||
);
|
||||
return checks;
|
||||
}
|
||||
|
||||
export async function pruneManagedPythonRuntimes(options: {
|
||||
cliVersion: string;
|
||||
runtimeRoot: string;
|
||||
dryRun?: boolean;
|
||||
}): Promise<ManagedPythonRuntimePruneResult> {
|
||||
if (!(await pathExists(options.runtimeRoot))) {
|
||||
return { runtimeRoot: options.runtimeRoot, stale: [], kept: [], removed: [] };
|
||||
}
|
||||
const entries = await readdir(options.runtimeRoot);
|
||||
const stale: string[] = [];
|
||||
const kept: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const path = join(options.runtimeRoot, entry);
|
||||
const info = await stat(path);
|
||||
if (!info.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (entry === options.cliVersion) {
|
||||
kept.push(path);
|
||||
} else {
|
||||
stale.push(path);
|
||||
}
|
||||
}
|
||||
const removed: string[] = [];
|
||||
if (options.dryRun !== true) {
|
||||
for (const path of stale) {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
removed.push(path);
|
||||
}
|
||||
}
|
||||
return { runtimeRoot: options.runtimeRoot, stale, kept, removed };
|
||||
}
|
||||
315
packages/cli/src/runtime.test.ts
Normal file
315
packages/cli/src/runtime.test.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
ManagedPythonDaemonStartResult,
|
||||
ManagedPythonDaemonStopResult,
|
||||
} from './managed-python-daemon.js';
|
||||
import type {
|
||||
ManagedPythonRuntimeDoctorCheck,
|
||||
ManagedPythonRuntimeInstallResult,
|
||||
ManagedPythonRuntimeStatus,
|
||||
} from './managed-python-runtime.js';
|
||||
import { runKtxRuntime, type KtxRuntimeDeps } from './runtime.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runKtxRuntime', () => {
|
||||
it('installs the requested runtime feature and prints the manifest path', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
installRuntime: vi.fn(async (): Promise<ManagedPythonRuntimeInstallResult> => ({
|
||||
status: 'installed',
|
||||
layout: {
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: '/runtime',
|
||||
versionDir: '/runtime/0.2.0',
|
||||
venvDir: '/runtime/0.2.0/.venv',
|
||||
manifestPath: '/runtime/0.2.0/manifest.json',
|
||||
installLogPath: '/runtime/0.2.0/install.log',
|
||||
assetDir: '/assets/python',
|
||||
assetManifestPath: '/assets/python/manifest.json',
|
||||
pythonPath: '/runtime/0.2.0/.venv/bin/python',
|
||||
daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
daemonStatePath: '/runtime/0.2.0/daemon.json',
|
||||
daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
|
||||
daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
|
||||
},
|
||||
asset: {
|
||||
wheelPath: '/assets/python/kaelio_ktx-0.1.0-py3-none-any.whl',
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
distributionName: 'kaelio-ktx',
|
||||
normalizedName: 'kaelio_ktx',
|
||||
version: '0.1.0',
|
||||
wheel: {
|
||||
file: 'kaelio_ktx-0.1.0-py3-none-any.whl',
|
||||
sha256: 'a'.repeat(64),
|
||||
bytes: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
cliVersion: '0.2.0',
|
||||
installedAt: '2026-05-11T00:00:00.000Z',
|
||||
asset: {
|
||||
schemaVersion: 1,
|
||||
distributionName: 'kaelio-ktx',
|
||||
normalizedName: 'kaelio_ktx',
|
||||
version: '0.1.0',
|
||||
wheel: {
|
||||
file: 'kaelio_ktx-0.1.0-py3-none-any.whl',
|
||||
sha256: 'a'.repeat(64),
|
||||
bytes: 10,
|
||||
},
|
||||
},
|
||||
features: ['core', 'local-embeddings'],
|
||||
python: {
|
||||
executable: '/runtime/0.2.0/.venv/bin/python',
|
||||
daemonExecutable: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
},
|
||||
installLog: '/runtime/0.2.0/install.log',
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runKtxRuntime(
|
||||
{ command: 'install', cliVersion: '0.2.0', feature: 'local-embeddings', force: true },
|
||||
io.io,
|
||||
deps,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(deps.installRuntime).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
features: ['local-embeddings'],
|
||||
force: true,
|
||||
});
|
||||
expect(io.stdout()).toContain('Installed KTX Python runtime');
|
||||
expect(io.stdout()).toContain('features: core, local-embeddings');
|
||||
expect(io.stdout()).toContain('manifest: /runtime/0.2.0/manifest.json');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('starts the managed Python daemon and prints the base URL', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
startDaemon: vi.fn(async (): Promise<ManagedPythonDaemonStartResult> => ({
|
||||
status: 'started',
|
||||
baseUrl: 'http://127.0.0.1:61234',
|
||||
layout: {
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: '/runtime',
|
||||
versionDir: '/runtime/0.2.0',
|
||||
venvDir: '/runtime/0.2.0/.venv',
|
||||
manifestPath: '/runtime/0.2.0/manifest.json',
|
||||
installLogPath: '/runtime/0.2.0/install.log',
|
||||
assetDir: '/assets/python',
|
||||
assetManifestPath: '/assets/python/manifest.json',
|
||||
pythonPath: '/runtime/0.2.0/.venv/bin/python',
|
||||
daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
daemonStatePath: '/runtime/0.2.0/daemon.json',
|
||||
daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
|
||||
daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
|
||||
},
|
||||
state: {
|
||||
schemaVersion: 1,
|
||||
pid: 4242,
|
||||
host: '127.0.0.1',
|
||||
port: 61234,
|
||||
version: '0.2.0',
|
||||
features: ['core', 'local-embeddings'],
|
||||
startedAt: '2026-05-11T00:00:00.000Z',
|
||||
stdoutLog: '/runtime/0.2.0/daemon.stdout.log',
|
||||
stderrLog: '/runtime/0.2.0/daemon.stderr.log',
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runKtxRuntime(
|
||||
{ command: 'start', cliVersion: '0.2.0', feature: 'local-embeddings', force: true },
|
||||
io.io,
|
||||
deps,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(deps.startDaemon).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
features: ['local-embeddings'],
|
||||
force: true,
|
||||
});
|
||||
expect(io.stdout()).toContain('Started KTX Python daemon');
|
||||
expect(io.stdout()).toContain('url: http://127.0.0.1:61234');
|
||||
expect(io.stdout()).toContain('pid: 4242');
|
||||
expect(io.stdout()).toContain('features: core, local-embeddings');
|
||||
expect(io.stdout()).toContain('stderr: /runtime/0.2.0/daemon.stderr.log');
|
||||
});
|
||||
|
||||
it('stops the managed Python daemon', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
stopDaemon: vi.fn(async (): Promise<ManagedPythonDaemonStopResult> => ({
|
||||
status: 'stopped',
|
||||
layout: {
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: '/runtime',
|
||||
versionDir: '/runtime/0.2.0',
|
||||
venvDir: '/runtime/0.2.0/.venv',
|
||||
manifestPath: '/runtime/0.2.0/manifest.json',
|
||||
installLogPath: '/runtime/0.2.0/install.log',
|
||||
assetDir: '/assets/python',
|
||||
assetManifestPath: '/assets/python/manifest.json',
|
||||
pythonPath: '/runtime/0.2.0/.venv/bin/python',
|
||||
daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
daemonStatePath: '/runtime/0.2.0/daemon.json',
|
||||
daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
|
||||
daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
|
||||
},
|
||||
state: {
|
||||
schemaVersion: 1,
|
||||
pid: 4242,
|
||||
host: '127.0.0.1',
|
||||
port: 61234,
|
||||
version: '0.2.0',
|
||||
features: ['core'],
|
||||
startedAt: '2026-05-11T00:00:00.000Z',
|
||||
stdoutLog: '/runtime/0.2.0/daemon.stdout.log',
|
||||
stderrLog: '/runtime/0.2.0/daemon.stderr.log',
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0' }, io.io, deps)).resolves.toBe(0);
|
||||
|
||||
expect(deps.stopDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
|
||||
expect(io.stdout()).toContain('Stopped KTX Python daemon');
|
||||
expect(io.stdout()).toContain('pid: 4242');
|
||||
});
|
||||
|
||||
it('prints runtime status as JSON', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
readStatus: vi.fn(async (): Promise<ManagedPythonRuntimeStatus> => ({
|
||||
kind: 'missing',
|
||||
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
|
||||
layout: {
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: '/runtime',
|
||||
versionDir: '/runtime/0.2.0',
|
||||
venvDir: '/runtime/0.2.0/.venv',
|
||||
manifestPath: '/runtime/0.2.0/manifest.json',
|
||||
installLogPath: '/runtime/0.2.0/install.log',
|
||||
assetDir: '/assets/python',
|
||||
assetManifestPath: '/assets/python/manifest.json',
|
||||
pythonPath: '/runtime/0.2.0/.venv/bin/python',
|
||||
daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
daemonStatePath: '/runtime/0.2.0/daemon.json',
|
||||
daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
|
||||
daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'status', cliVersion: '0.2.0', json: true }, io.io, deps)).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toMatchObject({
|
||||
kind: 'missing',
|
||||
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
|
||||
layout: { runtimeRoot: '/runtime' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns failure for doctor when any check fails', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
doctorRuntime: vi.fn(async (): Promise<ManagedPythonRuntimeDoctorCheck[]> => [
|
||||
{ id: 'uv', label: 'uv', status: 'pass', detail: 'uv 0.9.5' },
|
||||
{
|
||||
id: 'runtime',
|
||||
label: 'Managed Python runtime',
|
||||
status: 'fail',
|
||||
detail: 'No runtime manifest',
|
||||
fix: 'Run: ktx runtime install --yes',
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'doctor', cliVersion: '0.2.0', json: false }, io.io, deps)).resolves.toBe(1);
|
||||
|
||||
expect(io.stdout()).toContain('PASS uv: uv 0.9.5');
|
||||
expect(io.stdout()).toContain('FAIL Managed Python runtime: No runtime manifest');
|
||||
expect(io.stdout()).toContain('Fix: Run: ktx runtime install --yes');
|
||||
});
|
||||
|
||||
it('requires --yes before pruning stale runtime directories', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
pruneRuntime: vi.fn(async () => {
|
||||
throw new Error('should not prune without --yes');
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'prune', cliVersion: '0.2.0', dryRun: false, yes: false }, io.io, deps))
|
||||
.resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('Refusing to prune without --yes');
|
||||
expect(deps.pruneRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints stale directories during prune dry-run', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
readStatus: vi.fn(async (): Promise<ManagedPythonRuntimeStatus> => ({
|
||||
kind: 'missing',
|
||||
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
|
||||
layout: {
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: '/runtime',
|
||||
versionDir: '/runtime/0.2.0',
|
||||
venvDir: '/runtime/0.2.0/.venv',
|
||||
manifestPath: '/runtime/0.2.0/manifest.json',
|
||||
installLogPath: '/runtime/0.2.0/install.log',
|
||||
assetDir: '/assets/python',
|
||||
assetManifestPath: '/assets/python/manifest.json',
|
||||
pythonPath: '/runtime/0.2.0/.venv/bin/python',
|
||||
daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
daemonStatePath: '/runtime/0.2.0/daemon.json',
|
||||
daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
|
||||
daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
|
||||
},
|
||||
})),
|
||||
pruneRuntime: vi.fn(async () => ({
|
||||
runtimeRoot: '/runtime',
|
||||
stale: ['/runtime/0.1.0'],
|
||||
kept: ['/runtime/0.2.0'],
|
||||
removed: [],
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'prune', cliVersion: '0.2.0', dryRun: true, yes: false }, io.io, deps))
|
||||
.resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Stale KTX Python runtimes');
|
||||
expect(io.stdout()).toContain('/runtime/0.1.0');
|
||||
});
|
||||
});
|
||||
187
packages/cli/src/runtime.ts
Normal file
187
packages/cli/src/runtime.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import {
|
||||
startManagedPythonDaemon,
|
||||
stopManagedPythonDaemon,
|
||||
type ManagedPythonDaemonStartResult,
|
||||
type ManagedPythonDaemonStopResult,
|
||||
} from './managed-python-daemon.js';
|
||||
import {
|
||||
doctorManagedPythonRuntime,
|
||||
installManagedPythonRuntime,
|
||||
pruneManagedPythonRuntimes,
|
||||
readManagedPythonRuntimeStatus,
|
||||
type KtxRuntimeFeature,
|
||||
type ManagedPythonRuntimeDoctorCheck,
|
||||
type ManagedPythonRuntimeInstallOptions,
|
||||
type ManagedPythonRuntimeInstallResult,
|
||||
type ManagedPythonRuntimeLayoutOptions,
|
||||
type ManagedPythonRuntimePruneResult,
|
||||
type ManagedPythonRuntimeStatus,
|
||||
} from './managed-python-runtime.js';
|
||||
|
||||
export type KtxRuntimeArgs =
|
||||
| { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
|
||||
| { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
|
||||
| { command: 'stop'; cliVersion: string }
|
||||
| { command: 'status'; cliVersion: string; json: boolean }
|
||||
| { command: 'doctor'; cliVersion: string; json: boolean }
|
||||
| { command: 'prune'; cliVersion: string; dryRun: boolean; yes: boolean };
|
||||
|
||||
export interface KtxRuntimeDeps {
|
||||
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
|
||||
startDaemon?: (options: {
|
||||
cliVersion: string;
|
||||
features: KtxRuntimeFeature[];
|
||||
force?: boolean;
|
||||
}) => Promise<ManagedPythonDaemonStartResult>;
|
||||
stopDaemon?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopResult>;
|
||||
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
|
||||
doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeDoctorCheck[]>;
|
||||
pruneRuntime?: (options: {
|
||||
cliVersion: string;
|
||||
runtimeRoot: string;
|
||||
dryRun?: boolean;
|
||||
}) => Promise<ManagedPythonRuntimePruneResult>;
|
||||
}
|
||||
|
||||
function writeJson(io: KtxCliIo, value: unknown): void {
|
||||
io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function writeInstallResult(io: KtxCliIo, result: ManagedPythonRuntimeInstallResult): void {
|
||||
const verb = result.status === 'ready' ? 'Using existing' : 'Installed';
|
||||
io.stdout.write(`${verb} KTX Python runtime\n`);
|
||||
io.stdout.write(`version: ${result.manifest.cliVersion}\n`);
|
||||
io.stdout.write(`features: ${result.manifest.features.join(', ')}\n`);
|
||||
io.stdout.write(`python: ${result.manifest.python.executable}\n`);
|
||||
io.stdout.write(`daemon: ${result.manifest.python.daemonExecutable}\n`);
|
||||
io.stdout.write(`manifest: ${result.layout.manifestPath}\n`);
|
||||
io.stdout.write(`install log: ${result.layout.installLogPath}\n`);
|
||||
}
|
||||
|
||||
function writeDaemonStart(io: KtxCliIo, result: ManagedPythonDaemonStartResult): void {
|
||||
const verb = result.status === 'reused' ? 'Using existing' : 'Started';
|
||||
io.stdout.write(`${verb} KTX Python daemon\n`);
|
||||
io.stdout.write(`url: ${result.baseUrl}\n`);
|
||||
io.stdout.write(`pid: ${result.state.pid}\n`);
|
||||
io.stdout.write(`version: ${result.state.version}\n`);
|
||||
io.stdout.write(`features: ${result.state.features.join(', ')}\n`);
|
||||
io.stdout.write(`state: ${result.layout.daemonStatePath}\n`);
|
||||
io.stdout.write(`stdout: ${result.state.stdoutLog}\n`);
|
||||
io.stdout.write(`stderr: ${result.state.stderrLog}\n`);
|
||||
}
|
||||
|
||||
function writeDaemonStop(io: KtxCliIo, result: ManagedPythonDaemonStopResult): void {
|
||||
if (result.status === 'already-stopped') {
|
||||
io.stdout.write('KTX Python daemon already stopped\n');
|
||||
return;
|
||||
}
|
||||
io.stdout.write('Stopped KTX Python daemon\n');
|
||||
io.stdout.write(`pid: ${result.state?.pid ?? 'unknown'}\n`);
|
||||
io.stdout.write(`state: ${result.layout.daemonStatePath}\n`);
|
||||
}
|
||||
|
||||
function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void {
|
||||
io.stdout.write('KTX Python runtime\n');
|
||||
io.stdout.write(`status: ${status.kind}\n`);
|
||||
io.stdout.write(`detail: ${status.detail}\n`);
|
||||
io.stdout.write(`runtime root: ${status.layout.runtimeRoot}\n`);
|
||||
io.stdout.write(`version dir: ${status.layout.versionDir}\n`);
|
||||
if (status.manifest) {
|
||||
io.stdout.write(`features: ${status.manifest.features.join(', ')}\n`);
|
||||
io.stdout.write(`python: ${status.manifest.python.executable}\n`);
|
||||
io.stdout.write(`daemon: ${status.manifest.python.daemonExecutable}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeDoctor(io: KtxCliIo, checks: ManagedPythonRuntimeDoctorCheck[]): void {
|
||||
io.stdout.write('KTX Python runtime doctor\n');
|
||||
for (const check of checks) {
|
||||
io.stdout.write(`${check.status.toUpperCase()} ${check.label}: ${check.detail}\n`);
|
||||
if (check.fix) {
|
||||
io.stdout.write(` Fix: ${check.fix}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writePrune(io: KtxCliIo, result: ManagedPythonRuntimePruneResult, dryRun: boolean): void {
|
||||
if (result.stale.length === 0) {
|
||||
io.stdout.write(`No stale KTX Python runtimes found under ${result.runtimeRoot}\n`);
|
||||
return;
|
||||
}
|
||||
io.stdout.write(dryRun ? 'Stale KTX Python runtimes\n' : 'Removed stale KTX Python runtimes\n');
|
||||
for (const path of dryRun ? result.stale : result.removed) {
|
||||
io.stdout.write(`${path}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runKtxRuntime(
|
||||
args: KtxRuntimeArgs,
|
||||
io: KtxCliIo = process,
|
||||
deps: KtxRuntimeDeps = {},
|
||||
): Promise<number> {
|
||||
try {
|
||||
if (args.command === 'install') {
|
||||
const installRuntime = deps.installRuntime ?? installManagedPythonRuntime;
|
||||
const result = await installRuntime({
|
||||
cliVersion: args.cliVersion,
|
||||
features: [args.feature],
|
||||
force: args.force,
|
||||
});
|
||||
writeInstallResult(io, result);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'start') {
|
||||
const startDaemon = deps.startDaemon ?? startManagedPythonDaemon;
|
||||
const result = await startDaemon({
|
||||
cliVersion: args.cliVersion,
|
||||
features: [args.feature],
|
||||
force: args.force,
|
||||
});
|
||||
writeDaemonStart(io, result);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'stop') {
|
||||
const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon;
|
||||
const result = await stopDaemon({ cliVersion: args.cliVersion });
|
||||
writeDaemonStop(io, result);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'status') {
|
||||
const readStatus = deps.readStatus ?? readManagedPythonRuntimeStatus;
|
||||
const status = await readStatus({ cliVersion: args.cliVersion });
|
||||
if (args.json) {
|
||||
writeJson(io, status);
|
||||
} else {
|
||||
writeStatus(io, status);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'doctor') {
|
||||
const doctorRuntime = deps.doctorRuntime ?? doctorManagedPythonRuntime;
|
||||
const checks = await doctorRuntime({ cliVersion: args.cliVersion });
|
||||
if (args.json) {
|
||||
writeJson(io, { checks });
|
||||
} else {
|
||||
writeDoctor(io, checks);
|
||||
}
|
||||
return checks.some((check) => check.status === 'fail') ? 1 : 0;
|
||||
}
|
||||
if (!args.dryRun && !args.yes) {
|
||||
io.stderr.write('Refusing to prune without --yes. Preview with: ktx runtime prune --dry-run\n');
|
||||
return 1;
|
||||
}
|
||||
const status = await (deps.readStatus ?? readManagedPythonRuntimeStatus)({ cliVersion: args.cliVersion });
|
||||
const pruneRuntime = deps.pruneRuntime ?? pruneManagedPythonRuntimes;
|
||||
const result = await pruneRuntime({
|
||||
cliVersion: args.cliVersion,
|
||||
runtimeRoot: status.layout.runtimeRoot,
|
||||
dryRun: args.dryRun,
|
||||
});
|
||||
writePrune(io, result, args.dryRun);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -356,6 +356,49 @@ describe('runKtxScan', () => {
|
|||
expect(io.stdout()).not.toContain('/~');
|
||||
});
|
||||
|
||||
it('passes managed daemon options to local ingest adapters when no explicit daemon URL is set', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const createLocalIngestAdapters = vi.fn(() => []);
|
||||
const runLocalScan = vi.fn(
|
||||
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
|
||||
runId: 'scan-run-1',
|
||||
status: 'done',
|
||||
done: true,
|
||||
connectionId: 'warehouse',
|
||||
mode: 'structural',
|
||||
dryRun: false,
|
||||
syncId: 'sync-1',
|
||||
report,
|
||||
}),
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxScan(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
connectionId: 'warehouse',
|
||||
mode: 'structural',
|
||||
detectRelationships: false,
|
||||
dryRun: false,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
io.io,
|
||||
{ runLocalScan, createLocalIngestAdapters },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(createLocalIngestAdapters).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir }), {
|
||||
managedDaemon: {
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io: io.io,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('explains warnings, capability gaps, and relationships in human scan summaries', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const runLocalScan = vi.fn(
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import type { KtxCliIo } from './index.js';
|
||||
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
|
||||
import { createKtxCliScanConnector } from './local-scan-connectors.js';
|
||||
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
|
||||
profileMark('module:scan');
|
||||
|
|
@ -46,6 +47,8 @@ export type KtxScanArgs =
|
|||
detectRelationships: boolean;
|
||||
dryRun: boolean;
|
||||
databaseIntrospectionUrl?: string;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
}
|
||||
| { command: 'status'; projectDir: string; runId: string }
|
||||
| { command: 'report'; projectDir: string; runId: string; json: boolean }
|
||||
|
|
@ -220,6 +223,17 @@ function warningLine(warning: KtxScanWarning): string {
|
|||
return `${warning.code}: ${location}${warning.message}`;
|
||||
}
|
||||
|
||||
function managedDaemonOptionsForScanRun(args: Extract<KtxScanArgs, { command: 'run' }>, io: KtxCliIo) {
|
||||
if (args.databaseIntrospectionUrl || !args.cliVersion || !args.runtimeInstallPolicy) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
cliVersion: args.cliVersion,
|
||||
installPolicy: args.runtimeInstallPolicy,
|
||||
io,
|
||||
};
|
||||
}
|
||||
|
||||
function writeNeedsAttention(report: KtxScanReport, io: KtxCliIo): void {
|
||||
io.stdout.write('\nNeeds attention\n');
|
||||
if (report.warnings.length === 0 && report.capabilityGaps.length === 0) {
|
||||
|
|
@ -704,6 +718,7 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
|
|||
return 0;
|
||||
}
|
||||
|
||||
const managedDaemon = managedDaemonOptionsForScanRun(args, io);
|
||||
const connector =
|
||||
args.mode !== 'structural' || args.detectRelationships
|
||||
? await createKtxCliScanConnector(project, args.connectionId)
|
||||
|
|
@ -720,7 +735,8 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
|
|||
databaseIntrospectionUrl: args.databaseIntrospectionUrl,
|
||||
connector,
|
||||
adapters: (deps.createLocalIngestAdapters ?? createKtxCliLocalIngestAdapters)(project, {
|
||||
databaseIntrospectionUrl: args.databaseIntrospectionUrl,
|
||||
...(args.databaseIntrospectionUrl ? { databaseIntrospectionUrl: args.databaseIntrospectionUrl } : {}),
|
||||
...(managedDaemon ? { managedDaemon } : {}),
|
||||
}),
|
||||
progress,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,19 @@ import { initKtxProject } from '@ktx/context/project';
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { runKtxServeStdio } from './serve.js';
|
||||
|
||||
function makeManagedRuntimeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: (chunk: string) => (stdout += chunk) },
|
||||
stderr: { write: (chunk: string) => (stderr += chunk) },
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runKtxServeStdio', () => {
|
||||
it('loads the project, creates local ports, and connects the server to stdio', async () => {
|
||||
const connect = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
@ -149,6 +162,9 @@ describe('runKtxServeStdio', () => {
|
|||
expect.objectContaining({
|
||||
localIngest: expect.objectContaining({
|
||||
adapters: expect.any(Array),
|
||||
pullConfigOptions: {
|
||||
databaseIntrospectionUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
}),
|
||||
localScan: expect.objectContaining({
|
||||
adapters: createdAdapters,
|
||||
|
|
@ -161,6 +177,63 @@ describe('runKtxServeStdio', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('passes managed daemon options to MCP local ingest adapters and pull-config options', async () => {
|
||||
const project = { projectDir: '/tmp/ktx-project', config: { connections: {} } } as never;
|
||||
const adapters: SourceAdapter[] = [
|
||||
{ source: 'looker', skillNames: [], detect: async () => true, chunk: async () => ({ workUnits: [] }) },
|
||||
];
|
||||
const createIngestAdapters = vi.fn(() => adapters);
|
||||
const createContextTools = vi.fn(() => ({ connections: { list: async () => [] } }));
|
||||
const managedRuntimeIo = makeManagedRuntimeIo();
|
||||
|
||||
await expect(
|
||||
runKtxServeStdio(
|
||||
{
|
||||
mcp: 'stdio',
|
||||
projectDir: '/tmp/ktx-project',
|
||||
userId: 'agent',
|
||||
semanticCompute: false,
|
||||
semanticComputeUrl: undefined,
|
||||
databaseIntrospectionUrl: undefined,
|
||||
executeQueries: false,
|
||||
memoryCapture: false,
|
||||
memoryModel: undefined,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
{
|
||||
loadProject: async () => project,
|
||||
createContextTools,
|
||||
createIngestAdapters,
|
||||
managedRuntimeIo: managedRuntimeIo.io,
|
||||
createServer: vi.fn(() => ({ connect: vi.fn(async () => undefined) }) as never),
|
||||
createTransport: vi.fn(() => ({}) as never),
|
||||
stderr: { write: vi.fn() },
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const expectedManagedDaemon = {
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io: managedRuntimeIo.io,
|
||||
};
|
||||
expect(createIngestAdapters).toHaveBeenCalledWith(project, {
|
||||
managedDaemon: expectedManagedDaemon,
|
||||
});
|
||||
expect(createContextTools).toHaveBeenCalledWith(
|
||||
project,
|
||||
expect.objectContaining({
|
||||
localIngest: expect.objectContaining({
|
||||
adapters,
|
||||
pullConfigOptions: {
|
||||
managedDaemon: expectedManagedDaemon,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses CLI-native local ingest adapters for standalone scan tools', async () => {
|
||||
const project = { projectDir: '/tmp/ktx-project', config: { connections: {} } } as never;
|
||||
const createContextTools = vi.fn(() => ({}) as never);
|
||||
|
|
@ -241,6 +314,53 @@ describe('runKtxServeStdio', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('uses managed semantic compute when MCP semantic compute has no explicit HTTP URL', async () => {
|
||||
const project = { projectDir: '/tmp/ktx-project', config: { connections: {} } } as never;
|
||||
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
|
||||
const createManagedSemanticLayerCompute = vi.fn(async () => semanticLayerCompute);
|
||||
const createContextTools = vi.fn(() => ({ connections: { list: async () => [] } }));
|
||||
const managedRuntimeIo = makeManagedRuntimeIo();
|
||||
|
||||
await expect(
|
||||
runKtxServeStdio(
|
||||
{
|
||||
mcp: 'stdio',
|
||||
projectDir: '/tmp/ktx-project',
|
||||
userId: 'agent',
|
||||
semanticCompute: true,
|
||||
semanticComputeUrl: undefined,
|
||||
databaseIntrospectionUrl: undefined,
|
||||
executeQueries: false,
|
||||
memoryCapture: false,
|
||||
memoryModel: undefined,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
{
|
||||
loadProject: async () => project,
|
||||
createContextTools,
|
||||
createManagedSemanticLayerCompute,
|
||||
managedRuntimeIo: managedRuntimeIo.io,
|
||||
createServer: vi.fn(() => ({ connect: vi.fn(async () => undefined) }) as never),
|
||||
createTransport: vi.fn(() => ({}) as never),
|
||||
stderr: { write: vi.fn() },
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(createManagedSemanticLayerCompute).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io: managedRuntimeIo.io,
|
||||
});
|
||||
expect(createContextTools).toHaveBeenCalledWith(
|
||||
project,
|
||||
expect.objectContaining({
|
||||
semanticLayerCompute,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the HTTP semantic compute port when a daemon URL is provided', async () => {
|
||||
const project = { projectDir: '/tmp/ktx-project', config: { connections: {} } } as never;
|
||||
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { createLocalKtxLlmProviderFromConfig } from '@ktx/context';
|
|||
import { createDefaultLocalQueryExecutor, type KtxSqlQueryExecutorPort } from '@ktx/context/connections';
|
||||
import {
|
||||
createHttpSemanticLayerComputePort,
|
||||
createPythonSemanticLayerComputePort,
|
||||
type KtxSemanticLayerComputePort,
|
||||
} from '@ktx/context/daemon';
|
||||
import { createDefaultLocalIngestAdapters, type LocalIngestMcpOptions } from '@ktx/context/ingest';
|
||||
|
|
@ -15,8 +14,14 @@ import { createLocalProjectMemoryCapture, type MemoryCaptureService } from '@ktx
|
|||
import { type KtxLocalProject, loadKtxProject } from '@ktx/context/project';
|
||||
import type { LocalScanMcpOptions } from '@ktx/context/scan';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
|
||||
import { createKtxCliScanConnector } from './local-scan-connectors.js';
|
||||
import {
|
||||
createManagedPythonSemanticLayerComputePort,
|
||||
type KtxManagedPythonInstallPolicy,
|
||||
} from './managed-python-command.js';
|
||||
import type { ManagedPythonCoreDaemonOptions } from './managed-python-http.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
|
||||
profileMark('module:serve');
|
||||
|
|
@ -31,6 +36,8 @@ export interface KtxServeArgs {
|
|||
executeQueries: boolean;
|
||||
memoryCapture: boolean;
|
||||
memoryModel?: string;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
}
|
||||
|
||||
interface KtxServeIo {
|
||||
|
|
@ -48,8 +55,10 @@ interface KtxServeDeps {
|
|||
loadProject?: typeof loadKtxProject;
|
||||
createContextTools?: (project: KtxLocalProject, options?: LocalProjectContextToolOptions) => KtxMcpContextPorts;
|
||||
createSemanticLayerCompute?: () => KtxSemanticLayerComputePort;
|
||||
createManagedSemanticLayerCompute?: typeof createManagedPythonSemanticLayerComputePort;
|
||||
managedRuntimeIo?: KtxCliIo;
|
||||
createHttpSemanticLayerCompute?: (baseUrl: string) => KtxSemanticLayerComputePort;
|
||||
createIngestAdapters?: typeof createDefaultLocalIngestAdapters;
|
||||
createIngestAdapters?: typeof createKtxCliLocalIngestAdapters;
|
||||
createQueryExecutor?: () => KtxSqlQueryExecutorPort;
|
||||
createMemoryCapture?: typeof createLocalProjectMemoryCapture;
|
||||
createServer?: typeof createDefaultKtxMcpServer;
|
||||
|
|
@ -57,6 +66,51 @@ interface KtxServeDeps {
|
|||
stderr?: KtxServeIo['stderr'];
|
||||
}
|
||||
|
||||
function requiredManagedRuntimeCliVersion(args: KtxServeArgs): string {
|
||||
if (!args.cliVersion) {
|
||||
throw new Error('Managed Python semantic compute requires a CLI version.');
|
||||
}
|
||||
return args.cliVersion;
|
||||
}
|
||||
|
||||
function managedDaemonOptionsForServe(
|
||||
args: KtxServeArgs,
|
||||
deps: KtxServeDeps,
|
||||
): ManagedPythonCoreDaemonOptions | undefined {
|
||||
if (args.databaseIntrospectionUrl || !args.cliVersion) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
cliVersion: args.cliVersion,
|
||||
installPolicy: args.runtimeInstallPolicy ?? 'prompt',
|
||||
io: deps.managedRuntimeIo ?? process,
|
||||
};
|
||||
}
|
||||
|
||||
async function createServeSemanticLayerCompute(
|
||||
args: KtxServeArgs,
|
||||
deps: KtxServeDeps,
|
||||
): Promise<KtxSemanticLayerComputePort | undefined> {
|
||||
if (!args.semanticCompute) {
|
||||
return undefined;
|
||||
}
|
||||
if (args.semanticComputeUrl) {
|
||||
return (deps.createHttpSemanticLayerCompute ?? ((baseUrl) => createHttpSemanticLayerComputePort({ baseUrl })))(
|
||||
args.semanticComputeUrl,
|
||||
);
|
||||
}
|
||||
if (deps.createSemanticLayerCompute) {
|
||||
return deps.createSemanticLayerCompute();
|
||||
}
|
||||
const createManagedSemanticLayerCompute =
|
||||
deps.createManagedSemanticLayerCompute ?? createManagedPythonSemanticLayerComputePort;
|
||||
return createManagedSemanticLayerCompute({
|
||||
cliVersion: requiredManagedRuntimeCliVersion(args),
|
||||
installPolicy: args.runtimeInstallPolicy ?? 'prompt',
|
||||
io: deps.managedRuntimeIo ?? process,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runKtxServeStdio(args: KtxServeArgs, deps: KtxServeDeps = {}): Promise<number> {
|
||||
const loadProjectFn = deps.loadProject ?? loadKtxProject;
|
||||
const createContextToolsFn = deps.createContextTools ?? createLocalProjectMcpContextPorts;
|
||||
|
|
@ -65,20 +119,17 @@ export async function runKtxServeStdio(args: KtxServeArgs, deps: KtxServeDeps =
|
|||
const stderr = deps.stderr ?? process.stderr;
|
||||
|
||||
const project = await loadProjectFn({ projectDir: args.projectDir });
|
||||
const semanticLayerCompute = args.semanticCompute
|
||||
? args.semanticComputeUrl
|
||||
? (deps.createHttpSemanticLayerCompute ?? ((baseUrl) => createHttpSemanticLayerComputePort({ baseUrl })))(
|
||||
args.semanticComputeUrl,
|
||||
)
|
||||
: (deps.createSemanticLayerCompute ?? createPythonSemanticLayerComputePort)()
|
||||
: undefined;
|
||||
const semanticLayerCompute = await createServeSemanticLayerCompute(args, deps);
|
||||
const queryExecutor = args.executeQueries
|
||||
? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)()
|
||||
: undefined;
|
||||
const createIngestAdapters = deps.createIngestAdapters ?? createKtxCliLocalIngestAdapters;
|
||||
const localAdapters = createIngestAdapters(project, {
|
||||
databaseIntrospectionUrl: args.databaseIntrospectionUrl,
|
||||
});
|
||||
const managedDaemon = managedDaemonOptionsForServe(args, deps);
|
||||
const localAdapterOptions = {
|
||||
...(args.databaseIntrospectionUrl ? { databaseIntrospectionUrl: args.databaseIntrospectionUrl } : {}),
|
||||
...(managedDaemon ? { managedDaemon } : {}),
|
||||
};
|
||||
const localAdapters = createIngestAdapters(project, localAdapterOptions);
|
||||
const llmProvider = args.memoryCapture
|
||||
? (createLocalKtxLlmProviderFromConfig(project.config.llm) ?? undefined)
|
||||
: undefined;
|
||||
|
|
@ -90,6 +141,7 @@ export async function runKtxServeStdio(args: KtxServeArgs, deps: KtxServeDeps =
|
|||
: undefined;
|
||||
const localIngest: LocalIngestMcpOptions = {
|
||||
adapters: localAdapters,
|
||||
pullConfigOptions: localAdapterOptions,
|
||||
...(semanticLayerCompute ? { semanticLayerCompute } : {}),
|
||||
...(queryExecutor ? { queryExecutor } : {}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,6 +46,15 @@ function makePromptAdapter(options: {
|
|||
};
|
||||
}
|
||||
|
||||
function managedDaemon(baseUrl = 'http://127.0.0.1:61234') {
|
||||
return {
|
||||
baseUrl,
|
||||
env: {
|
||||
KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL: baseUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('setup embeddings step', () => {
|
||||
let tempDir: string;
|
||||
|
||||
|
|
@ -67,6 +76,8 @@ describe('setup embeddings step', () => {
|
|||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -94,10 +105,12 @@ describe('setup embeddings step', () => {
|
|||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts, env: {}, healthCheck },
|
||||
{ prompts, env: {}, healthCheck, ensureLocalEmbeddings: vi.fn(async () => managedDaemon()) },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
|
|
@ -106,7 +119,7 @@ describe('setup embeddings step', () => {
|
|||
backend: 'sentence-transformers',
|
||||
model: 'all-MiniLM-L6-v2',
|
||||
dimensions: 384,
|
||||
sentenceTransformers: { baseURL: 'http://127.0.0.1:8765', pathPrefix: '' },
|
||||
sentenceTransformers: { baseURL: 'http://127.0.0.1:61234', pathPrefix: '' },
|
||||
});
|
||||
expect(vi.mocked(prompts.select).mock.calls.map((call) => call[0].message)).toEqual([
|
||||
EMBEDDING_OPTION_PROMPT_MESSAGE,
|
||||
|
|
@ -119,30 +132,38 @@ describe('setup embeddings step', () => {
|
|||
const io = makeIo();
|
||||
const healthCheck = vi.fn(async () => ({ ok: true as const }));
|
||||
const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] });
|
||||
const ensureLocalEmbeddings = vi.fn(async () => managedDaemon());
|
||||
|
||||
const result = await runKtxSetupEmbeddingsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts, env: {}, healthCheck },
|
||||
{ prompts, env: {}, healthCheck, ensureLocalEmbeddings },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(ensureLocalEmbeddings).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io: io.io,
|
||||
});
|
||||
expect(healthCheck).toHaveBeenCalledWith({
|
||||
backend: 'sentence-transformers',
|
||||
model: 'all-MiniLM-L6-v2',
|
||||
dimensions: 384,
|
||||
sentenceTransformers: { baseURL: 'http://127.0.0.1:8765', pathPrefix: '' },
|
||||
sentenceTransformers: { baseURL: 'http://127.0.0.1:61234', pathPrefix: '' },
|
||||
});
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.ingest.embeddings).toMatchObject({
|
||||
backend: 'sentence-transformers',
|
||||
model: 'all-MiniLM-L6-v2',
|
||||
dimensions: 384,
|
||||
sentenceTransformers: { base_url: 'http://127.0.0.1:8765', pathPrefix: '' },
|
||||
sentenceTransformers: { base_url: 'managed:local-embeddings', pathPrefix: '' },
|
||||
});
|
||||
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
|
||||
expect(config.setup?.completed_steps).toContain('embeddings');
|
||||
|
|
@ -167,10 +188,12 @@ describe('setup embeddings step', () => {
|
|||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts, env: {}, healthCheck },
|
||||
{ prompts, env: {}, healthCheck, ensureLocalEmbeddings: vi.fn(async () => managedDaemon()) },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
|
|
@ -192,10 +215,12 @@ describe('setup embeddings step', () => {
|
|||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{ env: {}, healthCheck },
|
||||
{ env: {}, healthCheck, ensureLocalEmbeddings: vi.fn(async () => managedDaemon()) },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
|
|
@ -203,30 +228,59 @@ describe('setup embeddings step', () => {
|
|||
backend: 'sentence-transformers',
|
||||
model: 'all-MiniLM-L6-v2',
|
||||
dimensions: 384,
|
||||
sentenceTransformers: { baseURL: 'http://127.0.0.1:8765', pathPrefix: '' },
|
||||
sentenceTransformers: { baseURL: 'http://127.0.0.1:61234', pathPrefix: '' },
|
||||
});
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.ingest.embeddings).toMatchObject({
|
||||
backend: 'sentence-transformers',
|
||||
model: 'all-MiniLM-L6-v2',
|
||||
dimensions: 384,
|
||||
sentenceTransformers: { base_url: 'http://127.0.0.1:8765', pathPrefix: '' },
|
||||
sentenceTransformers: { base_url: 'managed:local-embeddings', pathPrefix: '' },
|
||||
});
|
||||
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
|
||||
expect(config.setup?.completed_steps).toContain('embeddings');
|
||||
});
|
||||
|
||||
it('fails non-interactive local setup when the managed local embeddings runtime is missing', async () => {
|
||||
const io = makeIo();
|
||||
const ensureLocalEmbeddings = vi.fn(async () => {
|
||||
throw new Error(
|
||||
'KTX Python runtime is required for this command. Run: ktx runtime install --feature local-embeddings --yes',
|
||||
);
|
||||
});
|
||||
|
||||
const result = await runKtxSetupEmbeddingsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'never',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{ env: {}, ensureLocalEmbeddings },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(io.stderr()).toContain(
|
||||
'KTX Python runtime is required for this command. Run: ktx runtime install --feature local-embeddings --yes',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not persist embedding completion when the health check fails', async () => {
|
||||
const io = makeIo();
|
||||
const result = await runKtxSetupEmbeddingsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
env: {},
|
||||
ensureLocalEmbeddings: vi.fn(async () => managedDaemon()),
|
||||
healthCheck: vi.fn(async () => ({ ok: false as const, message: '401 invalid api key [redacted]' })),
|
||||
},
|
||||
);
|
||||
|
|
@ -236,7 +290,7 @@ describe('setup embeddings step', () => {
|
|||
expect(config.setup?.completed_steps ?? []).not.toContain('embeddings');
|
||||
expect(config.ingest.embeddings.backend).toBe('deterministic');
|
||||
expect(io.stderr()).toContain('Local embedding health check failed: 401 invalid api key [redacted]');
|
||||
expect(io.stderr()).toContain('ktx-daemon serve-http --host 127.0.0.1 --port 8765');
|
||||
expect(io.stderr()).toContain('Prepare the runtime with: ktx runtime start --feature local-embeddings');
|
||||
expect(io.stderr()).not.toContain('skip for now');
|
||||
});
|
||||
|
||||
|
|
@ -250,6 +304,8 @@ describe('setup embeddings step', () => {
|
|||
inputMode: 'disabled',
|
||||
embeddingBackend: 'openai',
|
||||
embeddingApiKeyEnv: 'OPENAI_API_KEY',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -285,9 +341,20 @@ describe('setup embeddings step', () => {
|
|||
.mockResolvedValueOnce({ ok: true as const });
|
||||
|
||||
const result = await runKtxSetupEmbeddingsStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipEmbeddings: false },
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts, env: { OPENAI_API_KEY: 'sk-openai-test' }, healthCheck },
|
||||
{
|
||||
prompts,
|
||||
env: { OPENAI_API_KEY: 'sk-openai-test' },
|
||||
healthCheck,
|
||||
ensureLocalEmbeddings: vi.fn(async () => managedDaemon()),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
|
|
@ -295,7 +362,7 @@ describe('setup embeddings step', () => {
|
|||
backend: 'sentence-transformers',
|
||||
model: 'all-MiniLM-L6-v2',
|
||||
dimensions: 384,
|
||||
sentenceTransformers: { baseURL: 'http://127.0.0.1:8765', pathPrefix: '' },
|
||||
sentenceTransformers: { baseURL: 'http://127.0.0.1:61234', pathPrefix: '' },
|
||||
});
|
||||
expect(healthCheck).toHaveBeenNthCalledWith(2, {
|
||||
backend: 'openai',
|
||||
|
|
@ -320,7 +387,13 @@ describe('setup embeddings step', () => {
|
|||
|
||||
it('leaves setup incomplete when skipped', async () => {
|
||||
const result = await runKtxSetupEmbeddingsStep(
|
||||
{ projectDir: tempDir, inputMode: 'disabled', skipEmbeddings: true },
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: true,
|
||||
},
|
||||
makeIo().io,
|
||||
);
|
||||
|
||||
|
|
@ -333,9 +406,20 @@ describe('setup embeddings step', () => {
|
|||
it('returns back without writing config when the local health check fails and Back is selected', async () => {
|
||||
const prompts = makePromptAdapter({ selectValues: ['sentence-transformers', 'back'] });
|
||||
const result = await runKtxSetupEmbeddingsStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipEmbeddings: false },
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
makeIo().io,
|
||||
{ prompts, env: {}, healthCheck: vi.fn(async () => ({ ok: false as const, message: 'daemon unavailable' })) },
|
||||
{
|
||||
prompts,
|
||||
env: {},
|
||||
ensureLocalEmbeddings: vi.fn(async () => managedDaemon()),
|
||||
healthCheck: vi.fn(async () => ({ ok: false as const, message: 'daemon unavailable' })),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('back');
|
||||
|
|
@ -371,10 +455,20 @@ describe('setup embeddings step', () => {
|
|||
|
||||
const healthCheck = vi.fn(async () => ({ ok: true as const }));
|
||||
await expect(
|
||||
runKtxSetupEmbeddingsStep({ projectDir: tempDir, inputMode: 'disabled', skipEmbeddings: false }, makeIo().io, {
|
||||
env: { OPENAI_API_KEY: 'sk-openai-test' },
|
||||
healthCheck,
|
||||
}),
|
||||
runKtxSetupEmbeddingsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
makeIo().io,
|
||||
{
|
||||
env: { OPENAI_API_KEY: 'sk-openai-test' },
|
||||
healthCheck,
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({ status: 'ready' });
|
||||
expect(healthCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ import {
|
|||
} from '@ktx/context/project';
|
||||
import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import {
|
||||
ensureManagedLocalEmbeddingsDaemon,
|
||||
managedLocalEmbeddingHealthConfig,
|
||||
managedLocalEmbeddingProjectConfig,
|
||||
type ManagedLocalEmbeddingsDaemon,
|
||||
} from './managed-local-embeddings.js';
|
||||
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
|
||||
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
import { envCredentialReference, writeProjectLocalSecretReference } from './setup-secrets.js';
|
||||
|
|
@ -19,6 +26,8 @@ export type KtxSetupEmbeddingBackend = 'openai' | 'sentence-transformers';
|
|||
export interface KtxSetupEmbeddingsArgs {
|
||||
projectDir: string;
|
||||
inputMode: 'auto' | 'disabled';
|
||||
cliVersion: string;
|
||||
runtimeInstallPolicy: KtxManagedPythonInstallPolicy;
|
||||
embeddingBackend?: KtxSetupEmbeddingBackend;
|
||||
embeddingApiKeyEnv?: string;
|
||||
embeddingApiKeyFile?: string;
|
||||
|
|
@ -44,6 +53,11 @@ export interface KtxSetupEmbeddingsDeps {
|
|||
env?: NodeJS.ProcessEnv;
|
||||
prompts?: KtxSetupEmbeddingsPromptAdapter;
|
||||
healthCheck?: (config: KtxEmbeddingConfig) => Promise<KtxEmbeddingHealthCheckResult>;
|
||||
ensureLocalEmbeddings?: (options: {
|
||||
cliVersion: string;
|
||||
installPolicy: KtxManagedPythonInstallPolicy;
|
||||
io: KtxCliIo;
|
||||
}) => Promise<ManagedLocalEmbeddingsDaemon>;
|
||||
}
|
||||
|
||||
type BackendChoice = KtxSetupEmbeddingBackend | 'back';
|
||||
|
|
@ -62,9 +76,6 @@ const DEFAULTS: Record<
|
|||
};
|
||||
|
||||
const LOCAL_EMBEDDING_BACKEND: KtxSetupEmbeddingBackend = 'sentence-transformers';
|
||||
const LOCAL_EMBEDDING_DAEMON_COMMAND = 'ktx-daemon serve-http --host 127.0.0.1 --port 8765';
|
||||
const LOCAL_EMBEDDING_DAEMON_DEV_COMMAND =
|
||||
'cd ktx && source .venv/bin/activate && uv run ktx-daemon serve-http --host 127.0.0.1 --port 8765';
|
||||
const EMBEDDING_OPTION_PROMPT_CONTEXT =
|
||||
'KTX uses embeddings for semantic search over semantic-layer sources, wiki context, schema metadata, ' +
|
||||
'and relationship evidence.';
|
||||
|
|
@ -302,10 +313,10 @@ async function chooseEmbeddingBackend(
|
|||
function localEmbeddingSetupMessage(message: string): string {
|
||||
return [
|
||||
`Local embedding health check failed: ${message}`,
|
||||
'Local embeddings use the KTX Python daemon. KTX can call ktx-daemon automatically when it is on PATH.',
|
||||
`For repeated inference, start the HTTP daemon in another terminal with: ${LOCAL_EMBEDDING_DAEMON_COMMAND}`,
|
||||
`From the KTX repo, use: ${LOCAL_EMBEDDING_DAEMON_DEV_COMMAND}`,
|
||||
'The first run may download the all-MiniLM-L6-v2 model, so it can take a minute.',
|
||||
'Local embeddings use the KTX-managed Python runtime.',
|
||||
'Prepare the runtime with: ktx runtime start --feature local-embeddings',
|
||||
'Use --yes with setup to install and start the runtime without prompting.',
|
||||
'The first run may download Python packages and the all-MiniLM-L6-v2 model.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -432,12 +443,34 @@ export async function runKtxSetupEmbeddingsStep(
|
|||
credentialValue = credential.value;
|
||||
}
|
||||
|
||||
const healthConfig = buildHealthConfig({
|
||||
backend: selectedBackend,
|
||||
model,
|
||||
dimensions,
|
||||
credentialValue,
|
||||
});
|
||||
let managedLocalEmbeddings: ManagedLocalEmbeddingsDaemon | undefined;
|
||||
if (selectedBackend === LOCAL_EMBEDDING_BACKEND) {
|
||||
const ensureLocalEmbeddings = deps.ensureLocalEmbeddings ?? ensureManagedLocalEmbeddingsDaemon;
|
||||
try {
|
||||
managedLocalEmbeddings = await ensureLocalEmbeddings({
|
||||
cliVersion: args.cliVersion,
|
||||
installPolicy: args.runtimeInstallPolicy,
|
||||
io,
|
||||
});
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return { status: 'failed', projectDir: args.projectDir };
|
||||
}
|
||||
}
|
||||
|
||||
const healthConfig =
|
||||
selectedBackend === LOCAL_EMBEDDING_BACKEND && managedLocalEmbeddings
|
||||
? managedLocalEmbeddingHealthConfig({
|
||||
baseUrl: managedLocalEmbeddings.baseUrl,
|
||||
model,
|
||||
dimensions,
|
||||
})
|
||||
: buildHealthConfig({
|
||||
backend: selectedBackend,
|
||||
model,
|
||||
dimensions,
|
||||
credentialValue,
|
||||
});
|
||||
const progress = startHealthCheckProgress(io, healthCheckStartText(selectedBackend, model, dimensions));
|
||||
let health: KtxEmbeddingHealthCheckResult;
|
||||
try {
|
||||
|
|
@ -450,12 +483,14 @@ export async function runKtxSetupEmbeddingsStep(
|
|||
progress.succeed(`Embedding test passed (${model}, ${dimensions} dimensions)`);
|
||||
await persistEmbeddingConfig(
|
||||
args.projectDir,
|
||||
buildProjectEmbeddingConfig({
|
||||
backend: selectedBackend,
|
||||
model,
|
||||
dimensions,
|
||||
credentialRef,
|
||||
}),
|
||||
selectedBackend === LOCAL_EMBEDDING_BACKEND
|
||||
? managedLocalEmbeddingProjectConfig({ model, dimensions })
|
||||
: buildProjectEmbeddingConfig({
|
||||
backend: selectedBackend,
|
||||
model,
|
||||
dimensions,
|
||||
credentialRef,
|
||||
}),
|
||||
);
|
||||
io.stdout.write(`Embeddings ready: yes (${model}, ${dimensions} dimensions)\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -364,6 +365,7 @@ describe('setup status', () => {
|
|||
skipAgents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -410,6 +412,7 @@ describe('setup status', () => {
|
|||
skipAgents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -434,6 +437,7 @@ describe('setup status', () => {
|
|||
skipAgents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -472,6 +476,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -530,6 +535,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -597,6 +603,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -661,6 +668,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -697,6 +705,7 @@ describe('setup status', () => {
|
|||
skipAgents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -733,6 +742,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -764,6 +774,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -791,6 +802,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -819,6 +831,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
|
|
@ -858,7 +871,8 @@ describe('setup status', () => {
|
|||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
|
|
@ -878,6 +892,8 @@ describe('setup status', () => {
|
|||
expect.objectContaining({
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
embeddingBackend: 'openai',
|
||||
embeddingApiKeyEnv: 'OPENAI_API_KEY',
|
||||
skipEmbeddings: false,
|
||||
|
|
@ -886,6 +902,43 @@ describe('setup status', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('passes no-input runtime policy to the embeddings step', async () => {
|
||||
const io = makeIo();
|
||||
const embeddings = vi.fn(async () => ({ status: 'failed' as const, projectDir: tempDir }));
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
agents: false,
|
||||
agentScope: 'project',
|
||||
agentInstallMode: 'cli',
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
skipDatabases: true,
|
||||
skipSources: true,
|
||||
},
|
||||
io.io,
|
||||
{ embeddings },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(embeddings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'never',
|
||||
}),
|
||||
io.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets Back from embedding setup return to the model step instead of exiting', async () => {
|
||||
const testIo = makeIo();
|
||||
const modelResults = [
|
||||
|
|
@ -905,6 +958,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -952,6 +1006,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -997,6 +1052,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -1037,6 +1093,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
|
|
@ -1084,6 +1141,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
skipDatabases: true,
|
||||
|
|
@ -1130,6 +1188,7 @@ describe('setup status', () => {
|
|||
agents: false,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
skipDatabases: true,
|
||||
|
|
@ -1191,6 +1250,7 @@ describe('setup status', () => {
|
|||
agentInstallMode: 'cli',
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
skipDatabases: true,
|
||||
|
|
@ -1244,6 +1304,7 @@ describe('setup status', () => {
|
|||
agentInstallMode: 'cli',
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
skipDatabases: true,
|
||||
|
|
@ -1277,6 +1338,7 @@ describe('setup status', () => {
|
|||
agents: false,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
skipDatabases: true,
|
||||
|
|
@ -1352,6 +1414,7 @@ describe('setup status', () => {
|
|||
agents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
skipDatabases: false,
|
||||
|
|
@ -1416,6 +1479,7 @@ describe('setup status', () => {
|
|||
agents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
skipDatabases: false,
|
||||
|
|
@ -1509,6 +1573,7 @@ describe('setup status', () => {
|
|||
agents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
skipDatabases: false,
|
||||
|
|
@ -1604,6 +1669,7 @@ describe('setup status', () => {
|
|||
agents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
skipDatabases: false,
|
||||
|
|
@ -1667,6 +1733,7 @@ describe('setup status', () => {
|
|||
agentInstallMode: 'both',
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
skipDatabases: false,
|
||||
|
|
@ -1715,6 +1782,7 @@ describe('setup status', () => {
|
|||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export type KtxSetupArgs =
|
|||
skipAgents?: boolean;
|
||||
inputMode: 'auto' | 'disabled';
|
||||
yes: boolean;
|
||||
cliVersion: string;
|
||||
anthropicApiKeyEnv?: string;
|
||||
anthropicApiKeyFile?: string;
|
||||
anthropicModel?: string;
|
||||
|
|
@ -406,6 +407,13 @@ function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void {
|
|||
io.stderr.write(`Then install agent integration:\n ktx setup --agents --project-dir ${resolve(projectDir)}\n`);
|
||||
}
|
||||
|
||||
function setupRuntimeInstallPolicy(args: Extract<KtxSetupArgs, { command: 'run' }>): 'prompt' | 'auto' | 'never' {
|
||||
if (args.yes) {
|
||||
return 'auto';
|
||||
}
|
||||
return args.inputMode === 'disabled' ? 'never' : 'prompt';
|
||||
}
|
||||
|
||||
export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise<number> {
|
||||
try {
|
||||
return await runKtxSetupInner(args, io, deps);
|
||||
|
|
@ -609,6 +617,8 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
{
|
||||
projectDir: projectResult.projectDir,
|
||||
inputMode: args.inputMode,
|
||||
cliVersion: args.cliVersion,
|
||||
runtimeInstallPolicy: setupRuntimeInstallPolicy(args),
|
||||
...(args.embeddingBackend ? { embeddingBackend: args.embeddingBackend } : {}),
|
||||
...(args.embeddingApiKeyEnv ? { embeddingApiKeyEnv: args.embeddingApiKeyEnv } : {}),
|
||||
...(args.embeddingApiKeyFile ? { embeddingApiKeyFile: args.embeddingApiKeyFile } : {}),
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ joins: []
|
|||
query: { measures: ['orders.order_count'], dimensions: [] },
|
||||
format: 'sql',
|
||||
execute: false,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
{ stdout, stderr },
|
||||
{ loadProject, createSemanticLayerCompute },
|
||||
|
|
@ -139,6 +141,67 @@ joins: []
|
|||
expect(stderr.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates default sl query compute through the managed runtime helper', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
project.config.connections.warehouse = { driver: 'postgres', readonly: true };
|
||||
await project.fileStore.writeFile(
|
||||
'semantic-layer/warehouse/orders.yaml',
|
||||
`name: orders
|
||||
table: public.orders
|
||||
grain: [id]
|
||||
columns:
|
||||
- name: id
|
||||
type: number
|
||||
measures:
|
||||
- name: order_count
|
||||
expr: count(*)
|
||||
joins: []
|
||||
`,
|
||||
'ktx',
|
||||
'ktx@example.com',
|
||||
'Add orders source',
|
||||
);
|
||||
|
||||
const stdout = { write: vi.fn() };
|
||||
const stderr = { write: vi.fn() };
|
||||
const compute = {
|
||||
query: vi.fn(async () => ({
|
||||
sql: 'select count(*) as order_count from public.orders',
|
||||
dialect: 'postgres',
|
||||
columns: [{ name: 'orders.order_count' }],
|
||||
plan: {},
|
||||
})),
|
||||
validateSources: vi.fn(),
|
||||
generateSources: vi.fn(),
|
||||
};
|
||||
const createManagedSemanticLayerCompute = vi.fn(async () => compute);
|
||||
|
||||
await expect(
|
||||
runKtxSl(
|
||||
{
|
||||
command: 'query',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
query: { measures: ['orders.order_count'], dimensions: [] },
|
||||
format: 'sql',
|
||||
execute: false,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
{ stdout, stderr },
|
||||
{ createManagedSemanticLayerCompute },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(createManagedSemanticLayerCompute).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io: { stdout, stderr },
|
||||
});
|
||||
expect(stdout.write).toHaveBeenCalledWith('select count(*) as order_count from public.orders\n');
|
||||
});
|
||||
|
||||
it('executes sl query through the injected query executor', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
|
@ -194,6 +257,8 @@ joins: []
|
|||
format: 'json',
|
||||
execute: true,
|
||||
maxRows: 20,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
{ stdout, stderr },
|
||||
{
|
||||
|
|
@ -292,6 +357,8 @@ joins: []
|
|||
format: 'json',
|
||||
execute: true,
|
||||
maxRows: 20,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
{ stdout, stderr },
|
||||
{ createSemanticLayerCompute },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createDefaultLocalQueryExecutor, type KtxSqlQueryExecutorPort } from '@ktx/context/connections';
|
||||
import { createPythonSemanticLayerComputePort, type KtxSemanticLayerComputePort } from '@ktx/context/daemon';
|
||||
import type { KtxSemanticLayerComputePort } from '@ktx/context/daemon';
|
||||
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
||||
import {
|
||||
compileLocalSlQuery,
|
||||
|
|
@ -9,6 +9,10 @@ import {
|
|||
writeLocalSlSource,
|
||||
type SemanticLayerQueryInput,
|
||||
} from '@ktx/context/sl';
|
||||
import {
|
||||
createManagedPythonSemanticLayerComputePort,
|
||||
type KtxManagedPythonInstallPolicy,
|
||||
} from './managed-python-command.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
|
||||
profileMark('module:sl');
|
||||
|
|
@ -28,6 +32,8 @@ export type KtxSlArgs =
|
|||
format: SlQueryFormat;
|
||||
execute: boolean;
|
||||
maxRows?: number;
|
||||
cliVersion: string;
|
||||
runtimeInstallPolicy: KtxManagedPythonInstallPolicy;
|
||||
};
|
||||
|
||||
interface KtxSlIo {
|
||||
|
|
@ -38,6 +44,11 @@ interface KtxSlIo {
|
|||
interface KtxSlDeps {
|
||||
loadProject?: typeof loadKtxProject;
|
||||
createSemanticLayerCompute?: () => KtxSemanticLayerComputePort;
|
||||
createManagedSemanticLayerCompute?: (options: {
|
||||
cliVersion: string;
|
||||
installPolicy: KtxManagedPythonInstallPolicy;
|
||||
io: KtxSlIo;
|
||||
}) => Promise<KtxSemanticLayerComputePort>;
|
||||
createQueryExecutor?: () => KtxSqlQueryExecutorPort;
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +108,13 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
|||
return 0;
|
||||
}
|
||||
if (args.command === 'query') {
|
||||
const compute = (deps.createSemanticLayerCompute ?? createPythonSemanticLayerComputePort)();
|
||||
const compute = deps.createSemanticLayerCompute
|
||||
? deps.createSemanticLayerCompute()
|
||||
: await (deps.createManagedSemanticLayerCompute ?? createManagedPythonSemanticLayerComputePort)({
|
||||
cliVersion: args.cliVersion,
|
||||
installPolicy: args.runtimeInstallPolicy,
|
||||
io,
|
||||
});
|
||||
const queryExecutor = args.execute ? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)() : undefined;
|
||||
const result = await compileLocalSlQuery(project as KtxLocalProject, {
|
||||
connectionId: args.connectionId,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue