mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
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:
parent
6d7d90571e
commit
e28b10454a
23 changed files with 450 additions and 248 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1132,6 +1132,7 @@ describe('runKtxIngest', () => {
|
|||
|
||||
const expectedManagedDaemon = {
|
||||
cliVersion: '0.2.0',
|
||||
projectDir,
|
||||
installPolicy: 'auto',
|
||||
io: io.io,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -539,6 +539,7 @@ function managedDaemonOptionsForIngestRun(
|
|||
}
|
||||
return {
|
||||
cliVersion: args.cliVersion,
|
||||
projectDir: args.projectDir,
|
||||
installPolicy: args.runtimeInstallPolicy,
|
||||
io,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ function managedDaemonOptionsForScanRun(args: Extract<KtxScanArgs, { command: 'r
|
|||
}
|
||||
return {
|
||||
cliVersion: args.cliVersion,
|
||||
projectDir: args.projectDir,
|
||||
installPolicy: args.runtimeInstallPolicy,
|
||||
io,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -401,6 +401,7 @@ export async function runKtxSetupEmbeddingsStep(
|
|||
try {
|
||||
managedLocalEmbeddings = await ensureLocalEmbeddings({
|
||||
cliVersion: args.cliVersion,
|
||||
projectDir: args.projectDir,
|
||||
installPolicy: args.runtimeInstallPolicy,
|
||||
io,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue