feat(cli): friendly missing-project status and per-project daemon state (#87)

- Block project-aware commands when ktx.yaml is absent and render a
  friendly "run ktx setup" message (plain or JSON) instead of leaking
  ENOENT or "Project: ..." noise.
- Make ktx status project detect the missing config and emit the same
  message via a shared renderMissingProjectMessage helper.
- Move the managed Python daemon state, stdout, and stderr files out of
  the shared runtime root into {projectDir}/.ktx/runtime so multiple
  projects no longer share a single daemon record.
- Simplify the runtime install root to ~/.ktx/runtime on every platform
  and split the daemon-specific paths into managedPythonDaemonLayout,
  threading projectDir through start, stop, and stop-all paths.
This commit is contained in:
Andrey Avtomonov 2026-05-14 14:35:55 +02:00 committed by GitHub
parent 6d7d90571e
commit e28b10454a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 450 additions and 248 deletions

View file

@ -1,3 +1,5 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { Command, InvalidArgumentError } from '@commander-js/extra-typings';
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
import { registerConnectionCommands } from './commands/connection-commands.js';
@ -7,6 +9,7 @@ import { registerSetupCommands } from './commands/setup-commands.js';
import { registerSlCommands } from './commands/sl-commands.js';
import { registerStatusCommands } from './commands/status-commands.js';
import { registerDevCommands } from './dev.js';
import { renderMissingProjectMessage } from './doctor.js';
import { findNearestKtxProjectDir, resolveKtxProjectDir } from './project-resolver.js';
import { profileMark, profileSpan } from './startup-profile.js';
@ -53,6 +56,22 @@ type CommandPathNode = CommandWithGlobalOptions & {
};
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']);
const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']);
const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']);
class KtxProjectMissingAbortError extends Error {
readonly isKtxProjectMissingAbort = true;
constructor() {
super('ktx project missing');
}
}
function isKtxProjectMissingAbortError(error: unknown): error is KtxProjectMissingAbortError {
return (
error instanceof KtxProjectMissingAbortError ||
(typeof error === 'object' && error !== null && (error as { isKtxProjectMissingAbort?: unknown }).isKtxProjectMissingAbort === true)
);
}
const REMOVED_COMMAND_PATHS = new Set([
'scan',
'wiki read',
@ -257,11 +276,60 @@ function writeDebug(io: KtxCliIo, commandContext: CommandWithGlobalOptions, comm
io.stderr.write(`[debug] dispatch=${command}\n`);
}
function ktxYamlExists(projectDir: string): boolean {
return existsSync(join(projectDir, 'ktx.yaml'));
}
function commandRendersMissingProjectMessage(path: string[]): boolean {
if (!isProjectAwareCommand(path)) {
return false;
}
const pathKey = path.join(' ');
const rootCommand = path[1];
if (rootCommand !== undefined && COMMANDS_THAT_CREATE_PROJECT.has(rootCommand)) {
return false;
}
if (COMMANDS_THAT_CREATE_PROJECT.has(pathKey)) {
return false;
}
return true;
}
function requiresExistingProject(path: string[]): boolean {
if (!commandRendersMissingProjectMessage(path)) {
return false;
}
const rootCommand = path[1];
if (rootCommand !== undefined && COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING.has(rootCommand)) {
return false;
}
return true;
}
function writeProjectDir(io: KtxCliIo, commandContext: CommandPathNode): void {
if (!shouldPrintProjectDir(commandContext)) {
return;
}
io.stderr.write(`Project: ${resolveCommandProjectDir(commandContext)}\n`);
const projectDir = resolveCommandProjectDir(commandContext);
if (commandRendersMissingProjectMessage(commandPath(commandContext)) && !ktxYamlExists(projectDir)) {
return;
}
io.stderr.write(`Project: ${projectDir}\n`);
}
function ensureProjectAvailable(io: KtxCliIo, command: CommandPathNode): void {
const path = commandPath(command);
if (!requiresExistingProject(path)) {
return;
}
const projectDir = resolveCommandProjectDir(command);
if (ktxYamlExists(projectDir)) {
return;
}
const options = commandOptions(command);
const outputMode: 'plain' | 'json' = options.json === true ? 'json' : 'plain';
renderMissingProjectMessage(projectDir, outputMode, io);
throw new KtxProjectMissingAbortError();
}
function formatCliError(error: unknown): string {
@ -346,6 +414,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
const program = createBaseProgram(options.packageInfo, options.io);
program.hook('preAction', (_thisCommand, actionCommand) => {
writeProjectDir(options.io, actionCommand as CommandPathNode);
ensureProjectAvailable(options.io, actionCommand as CommandPathNode);
});
const context: KtxCliCommandContext = {
@ -429,6 +498,9 @@ export async function runCommanderKtxCli(
try {
await profileSpan('commander:parseAsync', () => program.parseAsync(argv, { from: 'user' }));
} catch (error) {
if (isKtxProjectMissingAbortError(error)) {
return 1;
}
if (isCommanderExit(error)) {
return error.exitCode === 0 ? 0 : 1;
}

View file

@ -1,5 +1,5 @@
import { type Command, Option } from '@commander-js/extra-typings';
import type { KtxCliCommandContext } from '../cli-program.js';
import { resolveCommandProjectDir, type CommandWithGlobalOptions, type KtxCliCommandContext } from '../cli-program.js';
import type { KtxRuntimeArgs } from '../runtime.js';
type RuntimeFeature = Extract<KtxRuntimeArgs, { command: 'install' }>['feature'];
@ -41,10 +41,11 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
.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 }) => {
.action(async (options: { feature: RuntimeFeature; force?: boolean }, command: CommandWithGlobalOptions) => {
await runRuntimeArgs(context, {
command: 'start',
cliVersion: context.packageInfo.version,
projectDir: resolveCommandProjectDir(command),
feature: options.feature,
force: options.force === true,
});
@ -54,10 +55,11 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
.command('stop')
.description('Stop the KTX-managed Python HTTP daemon')
.option('--all', 'Stop all KTX daemon processes recorded or discoverable on this machine', false)
.action(async (options: { all?: boolean }) => {
.action(async (options: { all?: boolean }, command: CommandWithGlobalOptions) => {
await runRuntimeArgs(context, {
command: 'stop',
cliVersion: context.packageInfo.version,
projectDir: resolveCommandProjectDir(command),
all: options.all === true,
});
});

View file

@ -280,6 +280,50 @@ describe('runKtxDoctor', () => {
});
});
it('prints a friendly message when ktx.yaml is missing at the project dir', async () => {
const originalEnvProjectDir = process.env.KTX_PROJECT_DIR;
process.env.KTX_PROJECT_DIR = tempDir;
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
const out = testIo.stdout();
expect(out).toContain('KTX status');
expect(out).toContain('No KTX project here yet.');
expect(out).toContain('ktx setup');
expect(out).toContain('KTX_PROJECT_DIR');
expect(out).not.toContain('ENOENT');
expect(testIo.stderr()).toBe('');
if (originalEnvProjectDir === undefined) {
delete process.env.KTX_PROJECT_DIR;
} else {
process.env.KTX_PROJECT_DIR = originalEnvProjectDir;
}
});
it('emits a structured JSON error when ktx.yaml is missing and JSON output is requested', async () => {
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
const parsed = JSON.parse(testIo.stdout()) as { error: string; projectDir: string };
expect(parsed.error).toBe('missing_project');
expect(parsed.projectDir).toBe(tempDir);
});
it('runs project checks against a valid ktx.yaml', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
await writeFile(

View file

@ -450,6 +450,48 @@ function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io:
io.stdout.write(renderPlainReport(report, options));
}
export function renderMissingProjectMessage(
projectDir: string,
outputMode: KtxDoctorOutputMode,
io: KtxDoctorIo,
): void {
if (outputMode === 'json') {
io.stdout.write(
`${JSON.stringify(
{
error: 'missing_project',
projectDir,
message: `No ktx.yaml found in ${projectDir}`,
},
null,
2,
)}\n`,
);
return;
}
const useColor = shouldUseColor(io);
const dim = (text: string) => styleDim(useColor, text);
const bold = (text: string) => styleBold(useColor, text);
const abbreviated = abbreviateHome(projectDir) ?? projectDir;
const envProjectDir = process.env.KTX_PROJECT_DIR;
const lines: string[] = [];
lines.push(`${bold('KTX status')} ${dim('·')} ${abbreviated}`);
lines.push('');
lines.push(` No KTX project here yet. ${dim('(ktx.yaml not found)')}`);
lines.push('');
lines.push(` Run ${bold('ktx setup')} to create one.`);
if (envProjectDir !== undefined) {
lines.push(` ${dim(`Or unset KTX_PROJECT_DIR (currently ${envProjectDir}) to use a different directory.`)}`);
} else {
lines.push(` ${dim('Or pass --project-dir to point at an existing project.')}`);
}
lines.push('');
io.stdout.write(lines.join('\n'));
}
export async function runKtxDoctor(
args: KtxDoctorArgs,
io: KtxDoctorIo = process,
@ -460,6 +502,11 @@ export async function runKtxDoctor(
const runSetupChecks = deps.runSetupChecks ?? (() => runSetupDoctorChecks());
if (args.command === 'project') {
const configPath = join(args.projectDir, 'ktx.yaml');
if (!(await defaultPathExists(configPath))) {
renderMissingProjectMessage(args.projectDir, args.outputMode, io);
return 1;
}
const { loadKtxProject } = await import('@ktx/context/project');
const { buildProjectStatus, renderProjectStatus } = await import('./status-project.js');
const project = await loadKtxProject({ projectDir: args.projectDir });

View file

@ -1,4 +1,4 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -102,6 +102,7 @@ describe('runKtxCli', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-'));
await writeFile(join(tempDir, 'ktx.yaml'), 'project: cli-dispatch-fixture\n', 'utf-8');
});
afterEach(async () => {
@ -272,6 +273,7 @@ describe('runKtxCli', () => {
{
command: 'start',
cliVersion: '0.0.0-private',
projectDir: expect.any(String),
feature: 'local-embeddings',
force: true,
},
@ -282,6 +284,7 @@ describe('runKtxCli', () => {
{
command: 'stop',
cliVersion: '0.0.0-private',
projectDir: expect.any(String),
all: false,
},
stopIo.io,
@ -291,6 +294,7 @@ describe('runKtxCli', () => {
{
command: 'stop',
cliVersion: '0.0.0-private',
projectDir: expect.any(String),
all: true,
},
stopAllIo.io,
@ -656,7 +660,7 @@ describe('runKtxCli', () => {
const publicIngest = vi.fn().mockResolvedValue(0);
await expect(
runKtxCli(['--project-dir', '/tmp/project', 'ingest', 'warehouse', '--fast', '--no-input'], testIo.io, {
runKtxCli(['--project-dir', tempDir, 'ingest', 'warehouse', '--fast', '--no-input'], testIo.io, {
publicIngest,
}),
).resolves.toBe(0);
@ -664,7 +668,7 @@ describe('runKtxCli', () => {
expect(publicIngest).toHaveBeenCalledWith(
{
command: 'run',
projectDir: '/tmp/project',
projectDir: tempDir,
targetConnectionId: 'warehouse',
all: false,
json: false,
@ -676,7 +680,7 @@ describe('runKtxCli', () => {
},
testIo.io,
);
expect(testIo.stderr()).toBe('Project: /tmp/project\n');
expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`);
});
it('routes public ingest --all --deep with JSON output', async () => {
@ -684,7 +688,7 @@ describe('runKtxCli', () => {
const publicIngest = vi.fn().mockResolvedValue(0);
await expect(
runKtxCli(['--project-dir', '/tmp/project', 'ingest', '--all', '--deep', '--json'], testIo.io, {
runKtxCli(['--project-dir', tempDir, 'ingest', '--all', '--deep', '--json'], testIo.io, {
publicIngest,
}),
).resolves.toBe(0);
@ -692,7 +696,7 @@ describe('runKtxCli', () => {
expect(publicIngest).toHaveBeenCalledWith(
{
command: 'run',
projectDir: '/tmp/project',
projectDir: tempDir,
all: true,
json: true,
inputMode: 'auto',
@ -727,7 +731,7 @@ describe('runKtxCli', () => {
const publicIngest = vi.fn(async () => 0);
await expect(
runKtxCli(['--project-dir', '/tmp/project', 'ingest', connectionId, '--no-input'], testIo.io, {
runKtxCli(['--project-dir', tempDir, 'ingest', connectionId, '--no-input'], testIo.io, {
publicIngest,
}),
).resolves.toBe(0);
@ -735,7 +739,7 @@ describe('runKtxCli', () => {
expect(publicIngest).toHaveBeenCalledWith(
{
command: 'run',
projectDir: '/tmp/project',
projectDir: tempDir,
targetConnectionId: connectionId,
all: false,
json: false,
@ -1478,6 +1482,7 @@ describe('runKtxCli', () => {
it('dispatches public connection subcommands through the existing connection implementation', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-connection-dispatch-'));
await writeFile(join(tempDir, 'ktx.yaml'), 'project: connection-dispatch\n', 'utf-8');
const connection = vi.fn(async () => 0);
await expect(

View file

@ -1132,6 +1132,7 @@ describe('runKtxIngest', () => {
const expectedManagedDaemon = {
cliVersion: '0.2.0',
projectDir,
installPolicy: 'auto',
io: io.io,
};

View file

@ -539,6 +539,7 @@ function managedDaemonOptionsForIngestRun(
}
return {
cliVersion: args.cliVersion,
projectDir: args.projectDir,
installPolicy: args.runtimeInstallPolicy,
io,
};

View file

@ -45,9 +45,6 @@ function runtime(): ManagedPythonCommandRuntime {
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,
@ -77,7 +74,14 @@ function runtime(): ManagedPythonCommandRuntime {
function daemonResult(status: 'started' | 'reused' = 'reused'): ManagedPythonDaemonStartResult {
return {
status,
layout: runtime().layout,
layout: {
...runtime().layout,
projectDir: '/work/proj',
daemonStateDir: '/work/proj/.ktx/runtime',
daemonStatePath: '/work/proj/.ktx/runtime/daemon.json',
daemonStdoutPath: '/work/proj/.ktx/runtime/daemon.stdout.log',
daemonStderrPath: '/work/proj/.ktx/runtime/daemon.stderr.log',
},
baseUrl: 'http://127.0.0.1:61234',
state: {
schemaVersion: 1,
@ -87,8 +91,8 @@ function daemonResult(status: 'started' | 'reused' = 'reused'): ManagedPythonDae
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',
stdoutLog: '/work/proj/.ktx/runtime/daemon.stdout.log',
stderrLog: '/work/proj/.ktx/runtime/daemon.stderr.log',
},
};
}
@ -138,6 +142,7 @@ describe('ensureManagedLocalEmbeddingsDaemon', () => {
await expect(
ensureManagedLocalEmbeddingsDaemon({
cliVersion: '0.2.0',
projectDir: '/work/proj',
installPolicy: 'auto',
io: io.io,
ensureRuntime,
@ -158,6 +163,7 @@ describe('ensureManagedLocalEmbeddingsDaemon', () => {
});
expect(startDaemon).toHaveBeenCalledWith({
cliVersion: '0.2.0',
projectDir: '/work/proj',
features: ['local-embeddings'],
force: false,
});
@ -169,6 +175,7 @@ describe('ensureManagedLocalEmbeddingsDaemon', () => {
await ensureManagedLocalEmbeddingsDaemon({
cliVersion: '0.2.0',
projectDir: '/work/proj',
installPolicy: 'prompt',
io: io.io,
ensureRuntime: vi.fn(async () => runtime()),

View file

@ -19,6 +19,7 @@ export interface ManagedLocalEmbeddingsDaemon {
export interface ManagedLocalEmbeddingsOptions {
cliVersion: string;
projectDir: string;
installPolicy: KtxManagedPythonInstallPolicy;
io: KtxCliIo;
ensureRuntime?: (options: {
@ -29,6 +30,7 @@ export interface ManagedLocalEmbeddingsOptions {
}) => Promise<ManagedPythonCommandRuntime>;
startDaemon?: (options: {
cliVersion: string;
projectDir: string;
features: ['local-embeddings'];
force: boolean;
}) => Promise<ManagedPythonDaemonStartResult>;
@ -79,6 +81,7 @@ export async function ensureManagedLocalEmbeddingsDaemon(
});
const daemon = await startDaemon({
cliVersion: options.cliVersion,
projectDir: options.projectDir,
features: ['local-embeddings'],
force: false,
});

View file

@ -45,9 +45,6 @@ function layout(): ManagedPythonRuntimeLayout {
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',
};
}

View file

@ -15,11 +15,13 @@ import {
} from './managed-python-daemon.js';
import type {
InstalledKtxRuntimeManifest,
ManagedPythonDaemonLayout,
ManagedPythonRuntimeInstallResult,
ManagedPythonRuntimeLayout,
} from './managed-python-runtime.js';
function layout(root: string): ManagedPythonRuntimeLayout {
function layout(root: string): ManagedPythonDaemonLayout {
const projectDir = join(root, 'project');
return {
cliVersion: '0.2.0',
runtimeRoot: join(root, 'runtime'),
@ -31,12 +33,19 @@ function layout(root: string): ManagedPythonRuntimeLayout {
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'),
projectDir,
daemonStateDir: join(projectDir, '.ktx', 'runtime'),
daemonStatePath: join(projectDir, '.ktx', 'runtime', 'daemon.json'),
daemonStdoutPath: join(projectDir, '.ktx', 'runtime', 'daemon.stdout.log'),
daemonStderrPath: join(projectDir, '.ktx', 'runtime', 'daemon.stderr.log'),
};
}
function installLayout(root: string): ManagedPythonRuntimeLayout {
const { projectDir: _projectDir, daemonStateDir: _d, daemonStatePath: _ds, daemonStdoutPath: _so, daemonStderrPath: _se, ...rest } = layout(root);
return rest;
}
function manifest(root: string, features: Array<'core' | 'local-embeddings'> = ['core']): InstalledKtxRuntimeManifest {
const runtimeLayout = layout(root);
return {
@ -66,7 +75,7 @@ function manifest(root: string, features: Array<'core' | 'local-embeddings'> = [
function installResult(root: string, features: Array<'core' | 'local-embeddings'> = ['core']): ManagedPythonRuntimeInstallResult {
return {
status: 'ready',
layout: layout(root),
layout: installLayout(root),
asset: {
manifest: manifest(root, features).asset,
wheelPath: join(root, 'assets', 'python', 'kaelio_ktx-0.2.0-py3-none-any.whl'),
@ -107,22 +116,12 @@ function runningState(root: string, overrides: Partial<ManagedPythonDaemonState>
};
}
function daemonStatePath(root: string, version: string): string {
return join(root, 'runtime', version, 'daemon.json');
}
function runningStateForVersion(
root: string,
version: string,
overrides: Partial<ManagedPythonDaemonState> = {},
): ManagedPythonDaemonState {
function daemonOptionsBase(root: string) {
return {
...runningState(root),
version,
stdoutLog: join(root, 'runtime', version, 'daemon.stdout.log'),
stderrLog: join(root, 'runtime', version, 'daemon.stderr.log'),
...overrides,
};
cliVersion: '0.2.0',
projectDir: layout(root).projectDir,
runtimeRoot: join(root, 'runtime'),
} as const;
}
describe('managed Python daemon lifecycle', () => {
@ -138,8 +137,7 @@ describe('managed Python daemon lifecycle', () => {
it('reports stopped when no daemon state exists', async () => {
const status = await readManagedPythonDaemonStatus({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
...daemonOptionsBase(tempDir),
processAlive: vi.fn(() => false),
fetch: makeFetch(),
});
@ -153,8 +151,7 @@ describe('managed Python daemon lifecycle', () => {
const installRuntime = vi.fn(async () => installResult(tempDir));
const result = await startManagedPythonDaemon({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
...daemonOptionsBase(tempDir),
features: ['core'],
installRuntime,
spawnDaemon,
@ -204,8 +201,7 @@ describe('managed Python daemon lifecycle', () => {
});
const result = await startManagedPythonDaemon({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
...daemonOptionsBase(tempDir),
features: ['core'],
installRuntime,
spawnDaemon,
@ -226,13 +222,12 @@ describe('managed Python daemon lifecycle', () => {
});
it('reuses a healthy daemon with the requested feature set', async () => {
await mkdir(layout(tempDir).versionDir, { recursive: true });
await mkdir(layout(tempDir).daemonStateDir, { 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'),
...daemonOptionsBase(tempDir),
features: ['core'],
installRuntime: vi.fn(async () => installResult(tempDir)),
spawnDaemon,
@ -247,15 +242,14 @@ describe('managed Python daemon lifecycle', () => {
});
it('starts a fresh daemon when the previous state is stale', async () => {
await mkdir(layout(tempDir).versionDir, { recursive: true });
await mkdir(layout(tempDir).daemonStateDir, { 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'),
...daemonOptionsBase(tempDir),
features: ['core'],
installRuntime: vi.fn(async () => installResult(tempDir)),
spawnDaemon: makeSpawn(6666),
@ -276,13 +270,12 @@ describe('managed Python daemon lifecycle', () => {
});
it('stops a recorded daemon and removes the state file', async () => {
await mkdir(layout(tempDir).versionDir, { recursive: true });
await mkdir(layout(tempDir).daemonStateDir, { 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'),
...daemonOptionsBase(tempDir),
processAlive: vi.fn(() => true),
killProcess,
});
@ -292,25 +285,16 @@ describe('managed Python daemon lifecycle', () => {
await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow();
});
it('stops all recorded daemon states across runtime versions and removes state files', async () => {
await mkdir(join(tempDir, 'runtime', '0.1.0'), { recursive: true });
await mkdir(join(tempDir, 'runtime', '0.2.0'), { recursive: true });
await writeFile(
daemonStatePath(tempDir, '0.1.0'),
`${JSON.stringify(runningStateForVersion(tempDir, '0.1.0', { pid: 1111, port: 61111 }), null, 2)}\n`,
);
await writeFile(
daemonStatePath(tempDir, '0.2.0'),
`${JSON.stringify(runningStateForVersion(tempDir, '0.2.0', { pid: 2222, port: 62222 }), null, 2)}\n`,
);
const alive = new Set([1111, 2222]);
it('stops the recorded daemon for this project and removes the state file', async () => {
await mkdir(layout(tempDir).daemonStateDir, { recursive: true });
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
const alive = new Set([4242]);
const killProcess = vi.fn((pid: number) => {
alive.delete(pid);
});
const result = await stopAllManagedPythonDaemons({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
...daemonOptionsBase(tempDir),
listProcesses: vi.fn(async () => []),
processAlive: vi.fn((pid) => alive.has(pid)),
killProcess,
@ -318,20 +302,17 @@ describe('managed Python daemon lifecycle', () => {
});
expect(result.failed).toHaveLength(0);
expect(result.stopped.map((entry) => entry.pid).sort()).toEqual([1111, 2222]);
expect(killProcess).toHaveBeenCalledWith(1111, 'SIGTERM');
expect(killProcess).toHaveBeenCalledWith(2222, 'SIGTERM');
await expect(readFile(daemonStatePath(tempDir, '0.1.0'), 'utf8')).rejects.toThrow();
await expect(readFile(daemonStatePath(tempDir, '0.2.0'), 'utf8')).rejects.toThrow();
expect(result.stopped.map((entry) => entry.pid)).toEqual([4242]);
expect(killProcess).toHaveBeenCalledWith(4242, 'SIGTERM');
await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow();
});
it('removes stale state when the recorded daemon process is no longer alive', async () => {
await mkdir(layout(tempDir).versionDir, { recursive: true });
await mkdir(layout(tempDir).daemonStateDir, { recursive: true });
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
const result = await stopAllManagedPythonDaemons({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
...daemonOptionsBase(tempDir),
listProcesses: vi.fn(async () => []),
processAlive: vi.fn(() => false),
killProcess: vi.fn(),
@ -344,7 +325,7 @@ describe('managed Python daemon lifecycle', () => {
});
it('deduplicates a daemon found by state and process scan, preferring state metadata', async () => {
await mkdir(layout(tempDir).versionDir, { recursive: true });
await mkdir(layout(tempDir).daemonStateDir, { recursive: true });
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
const alive = new Set([4242]);
const killProcess = vi.fn((pid: number) => {
@ -352,8 +333,7 @@ describe('managed Python daemon lifecycle', () => {
});
const result = await stopAllManagedPythonDaemons({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
...daemonOptionsBase(tempDir),
listProcesses: vi.fn(async (): Promise<ManagedPythonDaemonProcessInfo[]> => [
{ pid: 4242, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 61234' },
]),
@ -378,8 +358,7 @@ describe('managed Python daemon lifecycle', () => {
});
const result = await stopAllManagedPythonDaemons({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
...daemonOptionsBase(tempDir),
listProcesses: vi.fn(async (): Promise<ManagedPythonDaemonProcessInfo[]> => [
{ pid: 3333, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 8765' },
{ pid: 4444, command: 'node server.js --port 8765' },
@ -404,12 +383,11 @@ describe('managed Python daemon lifecycle', () => {
});
it('reports a failed stop when TERM and KILL leave a daemon running', async () => {
await mkdir(layout(tempDir).versionDir, { recursive: true });
await mkdir(layout(tempDir).daemonStateDir, { recursive: true });
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
const result = await stopAllManagedPythonDaemons({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
...daemonOptionsBase(tempDir),
listProcesses: vi.fn(async () => []),
processAlive: vi.fn(() => true),
killProcess: vi.fn(),

View file

@ -1,19 +1,18 @@
import { execFile, spawn } from 'node:child_process';
import { mkdir, open, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { promisify } from 'node:util';
import { z } from 'zod';
import {
installManagedPythonRuntime,
managedPythonRuntimeLayout,
managedPythonDaemonLayout,
runtimeFeatureSchema,
type KtxRuntimeFeature,
type ManagedPythonDaemonLayout,
type ManagedPythonDaemonLayoutOptions,
type ManagedPythonRuntimeInstallOptions,
type ManagedPythonRuntimeInstallResult,
type ManagedPythonRuntimeLayout,
type ManagedPythonRuntimeLayoutOptions,
} from './managed-python-runtime.js';
export interface ManagedPythonDaemonState {
@ -29,20 +28,20 @@ export interface ManagedPythonDaemonState {
}
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 };
| { kind: 'stopped'; detail: string; layout: ManagedPythonDaemonLayout }
| { kind: 'running'; detail: string; layout: ManagedPythonDaemonLayout; state: ManagedPythonDaemonState; baseUrl: string }
| { kind: 'stale'; detail: string; layout: ManagedPythonDaemonLayout; state?: ManagedPythonDaemonState };
export interface ManagedPythonDaemonStartResult {
status: 'started' | 'reused';
layout: ManagedPythonRuntimeLayout;
layout: ManagedPythonDaemonLayout;
state: ManagedPythonDaemonState;
baseUrl: string;
}
export interface ManagedPythonDaemonStopResult {
status: 'stopped' | 'already-stopped';
layout: ManagedPythonRuntimeLayout;
layout: ManagedPythonDaemonLayout;
state?: ManagedPythonDaemonState;
}
@ -68,7 +67,6 @@ export interface ManagedPythonDaemonStopAllFailure extends ManagedPythonDaemonSt
}
export interface ManagedPythonDaemonStopAllResult {
runtimeRoot: string;
stopped: ManagedPythonDaemonStopAllEntry[];
stale: ManagedPythonDaemonStopAllEntry[];
failed: ManagedPythonDaemonStopAllFailure[];
@ -101,7 +99,7 @@ export type ManagedPythonDaemonFetch = (
export type ManagedPythonDaemonKillProcess = (pid: number, signal?: NodeJS.Signals) => void;
export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLayoutOptions {
export interface ManagedPythonDaemonStartOptions extends ManagedPythonDaemonLayoutOptions {
features: KtxRuntimeFeature[];
force?: boolean;
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
@ -115,17 +113,17 @@ export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLay
pollIntervalMs?: number;
}
export interface ManagedPythonDaemonStatusOptions extends ManagedPythonRuntimeLayoutOptions {
export interface ManagedPythonDaemonStatusOptions extends ManagedPythonDaemonLayoutOptions {
fetch?: ManagedPythonDaemonFetch;
processAlive?: (pid: number) => boolean;
}
export interface ManagedPythonDaemonStopOptions extends ManagedPythonRuntimeLayoutOptions {
export interface ManagedPythonDaemonStopOptions extends ManagedPythonDaemonLayoutOptions {
processAlive?: (pid: number) => boolean;
killProcess?: ManagedPythonDaemonKillProcess;
}
export interface ManagedPythonDaemonStopAllOptions extends ManagedPythonRuntimeLayoutOptions {
export interface ManagedPythonDaemonStopAllOptions extends ManagedPythonDaemonLayoutOptions {
listProcesses?: () => Promise<ManagedPythonDaemonProcessInfo[]>;
processAlive?: (pid: number) => boolean;
killProcess?: ManagedPythonDaemonKillProcess;
@ -242,7 +240,7 @@ async function healthOk(input: {
export async function readManagedPythonDaemonStatus(
options: ManagedPythonDaemonStatusOptions,
): Promise<ManagedPythonDaemonStatus> {
const layout = managedPythonRuntimeLayout(options);
const layout = managedPythonDaemonLayout(options);
let state: ManagedPythonDaemonState | undefined;
try {
state = await readState(layout.daemonStatePath);
@ -329,12 +327,12 @@ async function waitForHealth(input: {
throw new Error(`KTX Python daemon failed to start: ${lastDetail}. stderr: ${input.state.stderrLog}`);
}
async function removeState(layout: ManagedPythonRuntimeLayout): Promise<void> {
async function removeState(layout: ManagedPythonDaemonLayout): Promise<void> {
await rm(layout.daemonStatePath, { force: true });
}
async function stopRecordedDaemon(input: {
layout: ManagedPythonRuntimeLayout;
layout: ManagedPythonDaemonLayout;
state: ManagedPythonDaemonState;
processAlive: (pid: number) => boolean;
killProcess: ManagedPythonDaemonKillProcess;
@ -345,10 +343,6 @@ async function stopRecordedDaemon(input: {
await removeState(input.layout);
}
function runtimeRootForStopAll(options: ManagedPythonRuntimeLayoutOptions): string {
return managedPythonRuntimeLayout(options).runtimeRoot;
}
async function removeStatePaths(paths: string[]): Promise<void> {
await Promise.all([...new Set(paths)].map((path) => rm(path, { force: true })));
}
@ -410,42 +404,26 @@ async function probeCandidateHealth(
}
}
async function readStateCandidates(runtimeRoot: string): Promise<ManagedPythonDaemonStopCandidate[]> {
let entries;
async function readStateCandidates(statePath: string): Promise<ManagedPythonDaemonStopCandidate[]> {
let state: ManagedPythonDaemonState | undefined;
try {
entries = await readdir(runtimeRoot, { withFileTypes: true });
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code === 'ENOENT') {
return [];
}
throw error;
state = await readState(statePath);
} catch {
return [];
}
const candidates: ManagedPythonDaemonStopCandidate[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const statePath = join(runtimeRoot, entry.name, 'daemon.json');
let state: ManagedPythonDaemonState | undefined;
try {
state = await readState(statePath);
} catch {
continue;
}
if (!state) {
continue;
}
candidates.push({
if (!state) {
return [];
}
return [
{
pid: state.pid,
source: 'state',
host: state.host,
port: state.port,
version: state.version,
statePaths: [statePath],
});
}
return candidates;
},
];
}
function tokenizeCommand(command: string): string[] {
@ -638,12 +616,12 @@ async function waitUntilStopped(input: {
async function discoverStopAllCandidates(
options: ManagedPythonDaemonStopAllOptions,
): Promise<{
runtimeRoot: string;
layout: ManagedPythonDaemonLayout;
candidates: ManagedPythonDaemonStopCandidate[];
scanErrors: string[];
}> {
const runtimeRoot = runtimeRootForStopAll(options);
const stateCandidates = await readStateCandidates(runtimeRoot);
const layout = managedPythonDaemonLayout(options);
const stateCandidates = await readStateCandidates(layout.daemonStatePath);
const scanErrors: string[] = [];
let processCandidates: ManagedPythonDaemonStopCandidate[] = [];
try {
@ -656,7 +634,7 @@ async function discoverStopAllCandidates(
scanErrors.push(error instanceof Error ? error.message : String(error));
}
return {
runtimeRoot,
layout,
candidates: mergeCandidates([...stateCandidates, ...processCandidates]),
scanErrors,
};
@ -674,13 +652,18 @@ export async function startManagedPythonDaemon(
...(options.env !== undefined ? { env: options.env } : {}),
...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
};
const layout = managedPythonRuntimeLayout({ cliVersion: options.cliVersion, ...layoutOverrides });
const layout = managedPythonDaemonLayout({
cliVersion: options.cliVersion,
projectDir: options.projectDir,
...layoutOverrides,
});
const processAlive = options.processAlive ?? defaultProcessAlive;
const killProcess = options.killProcess ?? defaultKillProcess;
const fetchImpl = options.fetch ?? defaultFetch;
const status = await readManagedPythonDaemonStatus({
cliVersion: options.cliVersion,
projectDir: options.projectDir,
...layoutOverrides,
fetch: fetchImpl,
processAlive,
@ -701,7 +684,7 @@ export async function startManagedPythonDaemon(
force: false,
});
await mkdir(layout.versionDir, { recursive: true });
await mkdir(layout.daemonStateDir, { recursive: true });
const stdout = await open(layout.daemonStdoutPath, 'a');
const stderr = await open(layout.daemonStderrPath, 'a');
try {
@ -752,7 +735,7 @@ export async function startManagedPythonDaemon(
export async function stopManagedPythonDaemon(
options: ManagedPythonDaemonStopOptions,
): Promise<ManagedPythonDaemonStopResult> {
const layout = managedPythonRuntimeLayout(options);
const layout = managedPythonDaemonLayout(options);
const state = await readState(layout.daemonStatePath);
if (!state) {
return { status: 'already-stopped', layout };
@ -818,7 +801,6 @@ export async function stopAllManagedPythonDaemons(
}
return {
runtimeRoot: discovery.runtimeRoot,
stopped,
stale,
failed,

View file

@ -33,6 +33,7 @@ describe('createManagedPythonDaemonBaseUrlResolver', () => {
}));
const resolveBaseUrl = createManagedPythonDaemonBaseUrlResolver({
cliVersion: '0.2.0',
projectDir: '/work/proj',
installPolicy: 'auto',
io: testIo.io,
ensureRuntime,
@ -52,6 +53,7 @@ describe('createManagedPythonDaemonBaseUrlResolver', () => {
expect(startDaemon).toHaveBeenCalledTimes(1);
expect(startDaemon).toHaveBeenCalledWith({
cliVersion: '0.2.0',
projectDir: '/work/proj',
features: ['core'],
force: false,
});
@ -72,6 +74,7 @@ describe('createManagedPythonDaemonBaseUrlResolver', () => {
}));
const resolveBaseUrl = createManagedPythonDaemonBaseUrlResolver({
cliVersion: '0.2.0',
projectDir: '/work/proj',
installPolicy: 'never',
io: testIo.io,
ensureRuntime,

View file

@ -34,6 +34,7 @@ export type ManagedPythonHttpPostJson = (
export interface ManagedPythonCoreDaemonOptions {
cliVersion: string;
projectDir: string;
installPolicy: KtxManagedPythonInstallPolicy;
io: KtxCliIo;
ensureRuntime?: (options: {
@ -44,6 +45,7 @@ export interface ManagedPythonCoreDaemonOptions {
}) => Promise<ManagedPythonCommandRuntime>;
startDaemon?: (options: {
cliVersion: string;
projectDir: string;
features: ['core'];
force: false;
}) => Promise<ManagedPythonDaemonStartResult>;
@ -135,6 +137,7 @@ export function createManagedPythonDaemonBaseUrlResolver(
});
const daemon = await startDaemon({
cliVersion: options.cliVersion,
projectDir: options.projectDir,
features: ['core'],
force: false,
});

View file

@ -7,6 +7,7 @@ import {
MISSING_UV_RUNTIME_INSTALL_MESSAGE,
doctorManagedPythonRuntime,
installManagedPythonRuntime,
managedPythonDaemonLayout,
managedPythonRuntimeLayout,
readManagedPythonRuntimeStatus,
verifyRuntimeAsset,
@ -40,7 +41,7 @@ async function writeAsset(root: string, contents = 'wheel-bytes') {
}
describe('managedPythonRuntimeLayout', () => {
it('uses the macOS application-support runtime root', () => {
it('uses ~/.ktx/runtime as the runtime root on macOS', () => {
const layout = managedPythonRuntimeLayout({
cliVersion: '0.2.0',
platform: 'darwin',
@ -49,28 +50,42 @@ describe('managedPythonRuntimeLayout', () => {
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.runtimeRoot).toBe('/Users/alex/.ktx/runtime');
expect(layout.versionDir).toBe('/Users/alex/.ktx/runtime/0.2.0');
expect(layout.venvDir).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv');
expect(layout.pythonPath).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv/bin/python');
expect(layout.daemonPath).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv/bin/ktx-daemon');
expect(layout.assetManifestPath).toBe('/repo/packages/cli/assets/python/manifest.json');
});
it('honors KTX_RUNTIME_ROOT before platform defaults', () => {
it('uses ~/.ktx/runtime on Linux too', () => {
const layout = managedPythonRuntimeLayout({
cliVersion: '0.2.0',
platform: 'linux',
env: {},
homeDir: '/home/alex',
assetDir: '/repo/packages/cli/assets/python',
});
expect(layout.runtimeRoot).toBe('/home/alex/.ktx/runtime');
expect(layout.versionDir).toBe('/home/alex/.ktx/runtime/0.2.0');
});
it('uses Scripts/*.exe layout on Windows under ~/.ktx/runtime', () => {
const layout = managedPythonRuntimeLayout({
cliVersion: '0.2.0',
platform: 'win32',
env: {},
homeDir: 'C:\\Users\\Alex',
assetDir: 'C:\\repo\\packages\\cli\\assets\\python',
});
expect(layout.runtimeRoot).toBe('C:\\Users\\Alex/.ktx/runtime');
expect(layout.pythonPath).toBe('C:\\Users\\Alex/.ktx/runtime/0.2.0/.venv/Scripts/python.exe');
expect(layout.daemonPath).toBe('C:\\Users\\Alex/.ktx/runtime/0.2.0/.venv/Scripts/ktx-daemon.exe');
});
it('honors KTX_RUNTIME_ROOT before the default ~/.ktx/runtime', () => {
const layout = managedPythonRuntimeLayout({
cliVersion: '0.2.0',
platform: 'darwin',
@ -82,32 +97,39 @@ describe('managedPythonRuntimeLayout', () => {
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({
describe('managedPythonDaemonLayout', () => {
it('places daemon state, stdout, and stderr under {projectDir}/.ktx/runtime', () => {
const layout = managedPythonDaemonLayout({
cliVersion: '0.2.0',
platform: 'linux',
env: { XDG_DATA_HOME: '/var/xdg' },
homeDir: '/home/alex',
projectDir: '/work/orbit-analytics',
platform: 'darwin',
env: {},
homeDir: '/Users/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');
expect(layout.projectDir).toBe('/work/orbit-analytics');
expect(layout.daemonStateDir).toBe('/work/orbit-analytics/.ktx/runtime');
expect(layout.daemonStatePath).toBe('/work/orbit-analytics/.ktx/runtime/daemon.json');
expect(layout.daemonStdoutPath).toBe('/work/orbit-analytics/.ktx/runtime/daemon.stdout.log');
expect(layout.daemonStderrPath).toBe('/work/orbit-analytics/.ktx/runtime/daemon.stderr.log');
});
it('uses LocalAppData on Windows', () => {
const layout = managedPythonRuntimeLayout({
it('keeps install paths under the global runtime root regardless of projectDir', () => {
const layout = managedPythonDaemonLayout({
cliVersion: '0.2.0',
platform: 'win32',
env: { LOCALAPPDATA: 'C:\\Users\\Alex\\AppData\\Local' },
homeDir: 'C:\\Users\\Alex',
assetDir: 'C:\\repo\\packages\\cli\\assets\\python',
projectDir: '/work/orbit-analytics',
platform: 'darwin',
env: {},
homeDir: '/Users/alex',
assetDir: '/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');
expect(layout.runtimeRoot).toBe('/Users/alex/.ktx/runtime');
expect(layout.versionDir).toBe('/Users/alex/.ktx/runtime/0.2.0');
expect(layout.pythonPath).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv/bin/python');
});
});

View file

@ -61,6 +61,15 @@ export interface ManagedPythonRuntimeLayout {
assetManifestPath: string;
pythonPath: string;
daemonPath: string;
}
export interface ManagedPythonDaemonLayoutOptions extends ManagedPythonRuntimeLayoutOptions {
projectDir: string;
}
export interface ManagedPythonDaemonLayout extends ManagedPythonRuntimeLayout {
projectDir: string;
daemonStateDir: string;
daemonStatePath: string;
daemonStdoutPath: string;
daemonStderrPath: string;
@ -114,17 +123,11 @@ function defaultAssetDir(): string {
return fileURLToPath(new URL('../assets/python/', import.meta.url));
}
function runtimeRootFor(input: Required<Pick<ManagedPythonRuntimeLayoutOptions, 'platform' | 'env' | 'homeDir'>>): string {
function runtimeRootFor(input: { env: NodeJS.ProcessEnv; homeDir: string }): 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');
return join(input.homeDir, '.ktx', 'runtime');
}
function executablePath(venvDir: string, platform: NodeJS.Platform, name: string): string {
@ -138,7 +141,7 @@ export function managedPythonRuntimeLayout(options: ManagedPythonRuntimeLayoutOp
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 runtimeRoot = options.runtimeRoot ?? runtimeRootFor({ env, homeDir });
const versionDir = join(runtimeRoot, options.cliVersion);
const venvDir = join(versionDir, '.venv');
const assetDir = options.assetDir ?? defaultAssetDir();
@ -154,9 +157,19 @@ export function managedPythonRuntimeLayout(options: ManagedPythonRuntimeLayoutOp
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'),
};
}
export function managedPythonDaemonLayout(options: ManagedPythonDaemonLayoutOptions): ManagedPythonDaemonLayout {
const runtime = managedPythonRuntimeLayout(options);
const daemonStateDir = join(options.projectDir, '.ktx', 'runtime');
return {
...runtime,
projectDir: options.projectDir,
daemonStateDir,
daemonStatePath: join(daemonStateDir, 'daemon.json'),
daemonStdoutPath: join(daemonStateDir, 'daemon.stdout.log'),
daemonStderrPath: join(daemonStateDir, 'daemon.stderr.log'),
};
}

View file

@ -1,6 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, 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 { runKtxCli, type KtxCliDeps } from './index.js';
async function makeFixtureProject(prefix: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), prefix));
await writeFile(join(dir, 'ktx.yaml'), 'project: project-dir-fixture\n', 'utf-8');
return dir;
}
function makeIo() {
let stdout = '';
let stderr = '';
@ -23,12 +32,22 @@ function makeIo() {
}
describe('project directory defaults', () => {
afterEach(() => {
let envProjectDir: string;
let explicitProjectDir: string;
beforeEach(async () => {
envProjectDir = await makeFixtureProject('ktx-env-project-');
explicitProjectDir = await makeFixtureProject('ktx-explicit-project-');
});
afterEach(async () => {
delete process.env.KTX_PROJECT_DIR;
await rm(envProjectDir, { recursive: true, force: true });
await rm(explicitProjectDir, { recursive: true, force: true });
});
it('uses KTX_PROJECT_DIR when Commander-dispatched commands omit --project-dir', async () => {
process.env.KTX_PROJECT_DIR = '/tmp/ktx-env-project';
process.env.KTX_PROJECT_DIR = envProjectDir;
const connection = vi.fn(async () => 0);
const doctor = vi.fn(async () => 0);
@ -45,26 +64,26 @@ describe('project directory defaults', () => {
{
argv: ['connection', 'list'],
spy: connection,
expected: { command: 'list', projectDir: '/tmp/ktx-env-project' },
expectedStderr: 'Project: /tmp/ktx-env-project\n',
expected: { command: 'list', projectDir: envProjectDir },
expectedStderr: `Project: ${envProjectDir}\n`,
},
{
argv: ['status', '--no-input'],
spy: doctor,
expected: { command: 'project', projectDir: '/tmp/ktx-env-project' },
expectedStderr: 'Project: /tmp/ktx-env-project\n',
expected: { command: 'project', projectDir: envProjectDir },
expectedStderr: `Project: ${envProjectDir}\n`,
},
{
argv: ['setup', '--no-input'],
spy: setup,
expected: { command: 'run', projectDir: '/tmp/ktx-env-project' },
expected: { command: 'run', projectDir: envProjectDir },
expectedStderr: '',
},
{
argv: ['ingest', 'warehouse', '--no-input'],
spy: publicIngest,
expected: { command: 'run', projectDir: '/tmp/ktx-env-project', targetConnectionId: 'warehouse' },
expectedStderr: 'Project: /tmp/ktx-env-project\n',
expected: { command: 'run', projectDir: envProjectDir, targetConnectionId: 'warehouse' },
expectedStderr: `Project: ${envProjectDir}\n`,
},
];
@ -77,35 +96,35 @@ describe('project directory defaults', () => {
});
it('lets explicit global --project-dir override KTX_PROJECT_DIR before and after nested commands', async () => {
process.env.KTX_PROJECT_DIR = '/tmp/ktx-env-project';
process.env.KTX_PROJECT_DIR = envProjectDir;
const publicIngest = vi.fn(async () => 0);
const beforeCommandIo = makeIo();
const afterCommandIo = makeIo();
await expect(
runKtxCli(['--project-dir', '/tmp/ktx-explicit-project', 'ingest', 'warehouse', '--no-input'], beforeCommandIo.io, {
runKtxCli(['--project-dir', explicitProjectDir, 'ingest', 'warehouse', '--no-input'], beforeCommandIo.io, {
publicIngest,
}),
).resolves.toBe(0);
await expect(
runKtxCli(['ingest', 'warehouse', '--project-dir=/tmp/ktx-explicit-project', '--no-input'], afterCommandIo.io, {
runKtxCli(['ingest', 'warehouse', `--project-dir=${explicitProjectDir}`, '--no-input'], afterCommandIo.io, {
publicIngest,
}),
).resolves.toBe(0);
expect(publicIngest).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ command: 'run', projectDir: '/tmp/ktx-explicit-project' }),
expect.objectContaining({ command: 'run', projectDir: explicitProjectDir }),
beforeCommandIo.io,
);
expect(publicIngest).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ command: 'run', projectDir: '/tmp/ktx-explicit-project' }),
expect.objectContaining({ command: 'run', projectDir: explicitProjectDir }),
afterCommandIo.io,
);
expect(beforeCommandIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n');
expect(afterCommandIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n');
expect(beforeCommandIo.stderr()).toBe(`Project: ${explicitProjectDir}\n`);
expect(afterCommandIo.stderr()).toBe(`Project: ${explicitProjectDir}\n`);
});
it('uses nearest ancestor containing ktx.yaml when no explicit or environment project-dir exists', async () => {

View file

@ -49,9 +49,6 @@ describe('runKtxRuntime', () => {
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',
@ -128,9 +125,11 @@ describe('runKtxRuntime', () => {
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',
projectDir: '/work/proj',
daemonStateDir: '/work/proj/.ktx/runtime',
daemonStatePath: '/work/proj/.ktx/runtime/daemon.json',
daemonStdoutPath: '/work/proj/.ktx/runtime/daemon.stdout.log',
daemonStderrPath: '/work/proj/.ktx/runtime/daemon.stderr.log',
},
state: {
schemaVersion: 1,
@ -140,15 +139,15 @@ describe('runKtxRuntime', () => {
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',
stdoutLog: '/work/proj/.ktx/runtime/daemon.stdout.log',
stderrLog: '/work/proj/.ktx/runtime/daemon.stderr.log',
},
})),
};
await expect(
runKtxRuntime(
{ command: 'start', cliVersion: '0.2.0', feature: 'local-embeddings', force: true },
{ command: 'start', cliVersion: '0.2.0', projectDir: '/work/proj', feature: 'local-embeddings', force: true },
io.io,
deps,
),
@ -156,6 +155,7 @@ describe('runKtxRuntime', () => {
expect(deps.startDaemon).toHaveBeenCalledWith({
cliVersion: '0.2.0',
projectDir: '/work/proj',
features: ['local-embeddings'],
force: true,
});
@ -163,7 +163,7 @@ describe('runKtxRuntime', () => {
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');
expect(io.stdout()).toContain('stderr: /work/proj/.ktx/runtime/daemon.stderr.log');
});
it('stops the managed Python daemon', async () => {
@ -182,9 +182,11 @@ describe('runKtxRuntime', () => {
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',
projectDir: '/work/proj',
daemonStateDir: '/work/proj/.ktx/runtime',
daemonStatePath: '/work/proj/.ktx/runtime/daemon.json',
daemonStdoutPath: '/work/proj/.ktx/runtime/daemon.stdout.log',
daemonStderrPath: '/work/proj/.ktx/runtime/daemon.stderr.log',
},
state: {
schemaVersion: 1,
@ -194,15 +196,17 @@ describe('runKtxRuntime', () => {
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',
stdoutLog: '/work/proj/.ktx/runtime/daemon.stdout.log',
stderrLog: '/work/proj/.ktx/runtime/daemon.stderr.log',
},
})),
};
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: false }, io.io, deps)).resolves.toBe(0);
await expect(
runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', projectDir: '/work/proj', all: false }, io.io, deps),
).resolves.toBe(0);
expect(deps.stopDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
expect(deps.stopDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0', projectDir: '/work/proj' });
expect(io.stdout()).toContain('Stopped KTX Python daemon');
expect(io.stdout()).toContain('pid: 4242');
});
@ -211,9 +215,8 @@ describe('runKtxRuntime', () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
stopAllDaemons: vi.fn(async (): Promise<ManagedPythonDaemonStopAllResult> => ({
runtimeRoot: '/runtime',
stopped: [
{ pid: 4242, source: 'state', url: 'http://127.0.0.1:61234', statePaths: ['/runtime/0.2.0/daemon.json'] },
{ pid: 4242, source: 'state', url: 'http://127.0.0.1:61234', statePaths: ['/work/proj/.ktx/runtime/daemon.json'] },
{ pid: 5252, source: 'process', url: 'http://127.0.0.1:8765', statePaths: [] },
],
stale: [],
@ -222,9 +225,11 @@ describe('runKtxRuntime', () => {
})),
};
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: true }, io.io, deps)).resolves.toBe(0);
await expect(
runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', projectDir: '/work/proj', all: true }, io.io, deps),
).resolves.toBe(0);
expect(deps.stopAllDaemons).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
expect(deps.stopAllDaemons).toHaveBeenCalledWith({ cliVersion: '0.2.0', projectDir: '/work/proj' });
expect(io.stdout()).toContain('Stopped 2 KTX Python daemons');
expect(io.stdout()).toContain('pid: 4242 source: state url: http://127.0.0.1:61234');
expect(io.stdout()).toContain('pid: 5252 source: process url: http://127.0.0.1:8765');
@ -234,7 +239,6 @@ describe('runKtxRuntime', () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
stopAllDaemons: vi.fn(async (): Promise<ManagedPythonDaemonStopAllResult> => ({
runtimeRoot: '/runtime',
stopped: [],
stale: [],
failed: [
@ -242,7 +246,7 @@ describe('runKtxRuntime', () => {
pid: 4242,
source: 'state',
url: 'http://127.0.0.1:61234',
statePaths: ['/runtime/0.2.0/daemon.json'],
statePaths: ['/work/proj/.ktx/runtime/daemon.json'],
detail: 'Process still running after SIGKILL',
},
],
@ -250,7 +254,9 @@ describe('runKtxRuntime', () => {
})),
};
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: true }, io.io, deps)).resolves.toBe(1);
await expect(
runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', projectDir: '/work/proj', all: true }, io.io, deps),
).resolves.toBe(1);
expect(io.stderr()).toContain('Stopped 0 KTX Python daemons; failed 1');
expect(io.stderr()).toContain('pid: 4242 source: state url: http://127.0.0.1:61234');
@ -274,9 +280,6 @@ describe('runKtxRuntime', () => {
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',
},
})),
doctorRuntime: vi.fn(async (): Promise<ManagedPythonRuntimeDoctorCheck[]> => [
@ -325,9 +328,6 @@ describe('runKtxRuntime', () => {
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,
@ -386,9 +386,6 @@ describe('runKtxRuntime', () => {
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,

View file

@ -21,19 +21,20 @@ import {
export type KtxRuntimeArgs =
| { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
| { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
| { command: 'stop'; cliVersion: string; all: boolean }
| { command: 'start'; cliVersion: string; projectDir: string; feature: KtxRuntimeFeature; force: boolean }
| { command: 'stop'; cliVersion: string; projectDir: string; all: boolean }
| { command: 'status'; cliVersion: string; json: boolean };
export interface KtxRuntimeDeps {
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
startDaemon?: (options: {
cliVersion: string;
projectDir: string;
features: KtxRuntimeFeature[];
force?: boolean;
}) => Promise<ManagedPythonDaemonStartResult>;
stopDaemon?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopResult>;
stopAllDaemons?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopAllResult>;
stopDaemon?: (options: { cliVersion: string; projectDir: string }) => Promise<ManagedPythonDaemonStopResult>;
stopAllDaemons?: (options: { cliVersion: string; projectDir: string }) => Promise<ManagedPythonDaemonStopAllResult>;
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeDoctorCheck[]>;
}
@ -174,6 +175,7 @@ export async function runKtxRuntime(
const startDaemon = deps.startDaemon ?? startManagedPythonDaemon;
const result = await startDaemon({
cliVersion: args.cliVersion,
projectDir: args.projectDir,
features: [args.feature],
force: args.force,
});
@ -183,11 +185,11 @@ export async function runKtxRuntime(
if (args.command === 'stop') {
if (args.all) {
const stopAllDaemons = deps.stopAllDaemons ?? stopAllManagedPythonDaemons;
const result = await stopAllDaemons({ cliVersion: args.cliVersion });
const result = await stopAllDaemons({ cliVersion: args.cliVersion, projectDir: args.projectDir });
return writeDaemonStopAll(io, result);
} else {
const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon;
const result = await stopDaemon({ cliVersion: args.cliVersion });
const result = await stopDaemon({ cliVersion: args.cliVersion, projectDir: args.projectDir });
writeDaemonStop(io, result);
return 0;
}

View file

@ -413,6 +413,7 @@ describe('runKtxScan', () => {
expect(createLocalIngestAdapters).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir }), {
managedDaemon: {
cliVersion: '0.2.0',
projectDir: tempDir,
installPolicy: 'auto',
io: io.io,
},

View file

@ -139,6 +139,7 @@ function managedDaemonOptionsForScanRun(args: Extract<KtxScanArgs, { command: 'r
}
return {
cliVersion: args.cliVersion,
projectDir: args.projectDir,
installPolicy: args.runtimeInstallPolicy,
io,
};

View file

@ -156,6 +156,7 @@ describe('setup embeddings step', () => {
expect(result.status).toBe('ready');
expect(ensureLocalEmbeddings).toHaveBeenCalledWith({
cliVersion: '0.2.0',
projectDir: tempDir,
installPolicy: 'auto',
io: io.io,
});

View file

@ -401,6 +401,7 @@ export async function runKtxSetupEmbeddingsStep(
try {
managedLocalEmbeddings = await ensureLocalEmbeddings({
cliVersion: args.cliVersion,
projectDir: args.projectDir,
installPolicy: args.runtimeInstallPolicy,
io,
});