ktx/docs/superpowers/plans/2026-05-11-managed-python-runtime-daemon-lifecycle.md
Andrey Avtomonov 9dad936ac7
feat: npm-managed Python runtime for @kaelio/ktx (#7)
* docs: add npm managed python runtime design

* build: add bundled python runtime wheel builder

* build: make local embedding dependencies optional

* build: bundle python runtime wheel in cli artifacts

* build: track bundled python runtime release artifact

* test: verify bundled python runtime wheel

* docs: add plan for bundled python runtime wheel

* test: cover managed python runtime lifecycle

* feat: add managed python runtime installer

* feat: add runtime command runner

* feat: expose runtime management commands

* test: verify managed python runtime commands

* docs: add plan for managed python runtime installer

* feat: add managed python command helper

* feat: use managed runtime for sl query compute

* feat: route sl query managed runtime policy

* docs: add plan for managed runtime sl query integration

* feat: add managed runtime daemon metadata

* feat: manage python daemon lifecycle

* feat: add runtime daemon start stop commands

* fix: verify managed runtime daemon lifecycle

* docs: add plan for managed runtime daemon lifecycle

* feat: add managed local embeddings config marker

* feat: add managed local embeddings daemon helper

* feat: use managed runtime for local embedding setup

* feat: pass managed runtime policy through setup

* docs: add plan for managed local embeddings runtime

* feat: read CLI package metadata dynamically

* feat: assemble public kaelio ktx npm package

* feat: release one public kaelio ktx npm artifact

* test: cover public kaelio ktx package invocations

* chore: verify public kaelio ktx package artifacts

* docs: add plan for public kaelio ktx npm package

* test: verify managed runtime in public package smoke

* test: finalize managed runtime release smoke

* docs: add plan for managed runtime release smoke

* test: specify local embeddings release smoke

* feat: add local embeddings runtime smoke

* chore: register local embeddings smoke

* fix: verify local embeddings smoke

* fix: restore artifact smoke python env helper

* docs: add plan for managed local embeddings release smoke

* refactor: share managed runtime install policy parsing

* feat: use managed runtime for agent semantic queries

* feat: use managed runtime for MCP semantic compute

* docs: add plan for managed agent and MCP semantic runtime

* feat(cli): add managed daemon HTTP helpers

* feat(cli): route local adapters through managed daemon

* feat(cli): use managed daemon for ingest helpers

* feat(cli): pass managed daemon options to scan

* feat(context): pass MCP ingest pull config options

* feat(cli): pass managed daemon options to serve ingest

* test: verify managed local ingest daemon runtime

* docs: add plan for managed local ingest daemon runtime

* docs: align managed runtime examples

* docs: add plan for managed runtime docs cleanup

* test: cover published package runtime smoke commands

* test: validate published package smoke outputs

* docs: add plan for published package runtime smoke

* build: stamp public npm package version

* release: add npm public release policy

* release: add guarded npm publish script

* release: document public npm release handoff

* docs: add plan for public npm release handoff

* test: cover managed runtime prune in package smoke

* docs: document managed runtime prune

* docs: add plan for managed runtime prune smoke and docs

* chore: encode uv runtime prerequisite policy

* fix: clarify missing uv runtime error

* docs: document uv runtime prerequisite

* docs: add plan for uv runtime prerequisite contract

* refactor: limit release artifacts to public package runtime

* chore: align release policy with bundled runtime wheel

* docs: describe single public runtime artifact surface

* test: verify single public runtime artifact contract

* docs: add plan for single public runtime artifact cleanup

* fix: align local embeddings smoke with public version

* docs: add plan for local embeddings smoke public version

* release: soft-launch as @kaelio/ktx@0.1.0-rc.0 on next tag

Publish target moves to the pre-release version 0.1.0-rc.0 under the next
dist-tag so npm install @kaelio/ktx (which resolves to latest) does not
pick up the soft-launch build. Users opt in via @kaelio/ktx@next.

* Fix release script boundary checks

* Remove PostHog from public package bundle
2026-05-11 15:50:34 +02:00

48 KiB

Managed Python Runtime Daemon Lifecycle Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add ktx runtime start and ktx runtime stop for the KTX-managed Python HTTP daemon, including state files, health checks, reuse, and stale daemon repair.

Architecture: Keep daemon process management in a new CLI-owned module that depends on the existing managed runtime installer. The module starts ktx-daemon serve-http from the installed runtime on 127.0.0.1, writes an adjacent daemon state file, verifies /health before reuse, and removes stale state when the process, port, version, or requested feature set no longer matches.

Tech Stack: TypeScript, Node 22 ESM, Commander, Vitest, zod, FastAPI, uvicorn, uv, KTX managed runtime assets.


Existing status

This plan is based on docs/superpowers/specs/2026-05-11-npm-managed-python-runtime-design.md.

Existing plans based on the spec:

  • docs/superpowers/plans/2026-05-11-bundled-python-runtime-wheel.md is implemented. The worktree contains scripts/build-python-runtime-wheel.mjs, scripts/build-python-runtime-wheel.test.mjs, runtime-wheel packaging in scripts/package-artifacts.mjs, release-policy coverage, and matching artifact tests.
  • docs/superpowers/plans/2026-05-11-managed-python-runtime-installer.md is implemented. The worktree contains packages/cli/src/managed-python-runtime.ts, packages/cli/src/runtime.ts, packages/cli/src/commands/runtime-commands.ts, CLI registration, and matching Vitest coverage.
  • docs/superpowers/plans/2026-05-11-managed-python-runtime-command-integration.md is implemented. The worktree contains packages/cli/src/managed-python-command.ts, ktx sl query runtime policy flags, schema validation, and matching CLI tests.

Implementation evidence collected before writing this plan:

node --test scripts/build-python-runtime-wheel.test.mjs scripts/package-artifacts.test.mjs scripts/release-readiness.test.mjs

Expected current result:

# pass 38
# fail 0
pnpm --filter @ktx/cli run test -- src/managed-python-runtime.test.ts src/runtime.test.ts src/index.test.ts src/managed-python-command.test.ts src/sl.test.ts

Expected current result:

Test Files  58 passed (58)
Tests  699 passed (699)

Spec requirements still outside this plan:

  • Lazy local-embeddings installation and daemon reuse from embedding setup, embedding health checks, and ingest paths.
  • Managed runtime usage for Python-backed operations beyond ktx sl query.
  • Public npm package rename from @ktx/cli to @kaelio/ktx.

This plan implements the daemon lifecycle requirement:

  • ktx runtime start
  • ktx runtime stop
  • A versioned daemon state file adjacent to the installed runtime manifest.
  • Random localhost port allocation.
  • Captured daemon stdout and stderr logs.
  • /health validation before daemon reuse.
  • Stale daemon cleanup when process, health, version, or features don't match.

File structure

  • Modify python/ktx-daemon/src/ktx_daemon/app.py: include a daemon version in /health, supplied by KTX_DAEMON_VERSION for managed runtime starts.
  • Modify python/ktx-daemon/tests/test_app.py: assert the health endpoint returns the managed version when the environment variable is set.
  • Modify packages/cli/src/managed-python-runtime.ts: add daemon state and log paths to ManagedPythonRuntimeLayout.
  • Modify packages/cli/src/managed-python-runtime.test.ts: assert the new layout paths.
  • Modify packages/cli/src/runtime.test.ts and packages/cli/src/managed-python-command.test.ts: add daemon paths to layout fixtures after the layout type changes.
  • Create packages/cli/src/managed-python-daemon.ts: start, stop, status, health-check, stale-state, and state-file logic for the managed HTTP daemon.
  • Create packages/cli/src/managed-python-daemon.test.ts: unit tests for stopped status, start, reuse, stale repair, and stop.
  • Modify packages/cli/src/runtime.ts: route runtime start and runtime stop through the daemon lifecycle module and print concise output.
  • Modify packages/cli/src/runtime.test.ts: assert command runner behavior for start and stop.
  • Modify packages/cli/src/commands/runtime-commands.ts: register ktx runtime start and ktx runtime stop, and accept --yes on ktx runtime install so the preparation command printed by ktx sl query --no-input is valid.
  • Modify packages/cli/src/index.test.ts: assert Commander routes the new runtime subcommands with the CLI package version.
  • Modify packages/cli/src/index.ts: export the daemon lifecycle helpers for tests and programmatic use.

Task 1: Add daemon metadata to runtime layout and Python health

Files:

  • Modify: packages/cli/src/managed-python-runtime.ts

  • Modify: packages/cli/src/managed-python-runtime.test.ts

  • Modify: packages/cli/src/runtime.test.ts

  • Modify: packages/cli/src/managed-python-command.test.ts

  • Modify: python/ktx-daemon/src/ktx_daemon/app.py

  • Modify: python/ktx-daemon/tests/test_app.py

  • Step 1: Write failing TypeScript layout assertions

In packages/cli/src/managed-python-runtime.test.ts, update the first managedPythonRuntimeLayout test so it includes these expectations after the existing daemonPath assertion:

    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',
    );
  • Step 2: Run the failing layout test

Run:

pnpm --filter @ktx/cli run test -- src/managed-python-runtime.test.ts

Expected: FAIL with TypeScript or assertion errors for missing daemonStatePath, daemonStdoutPath, and daemonStderrPath.

  • Step 3: Add daemon paths to the runtime layout type

In packages/cli/src/managed-python-runtime.ts, add these fields to ManagedPythonRuntimeLayout immediately after daemonPath:

  daemonStatePath: string;
  daemonStdoutPath: string;
  daemonStderrPath: string;

In managedPythonRuntimeLayout, add these properties to the returned object immediately after daemonPath:

    daemonStatePath: join(versionDir, 'daemon.json'),
    daemonStdoutPath: join(versionDir, 'daemon.stdout.log'),
    daemonStderrPath: join(versionDir, 'daemon.stderr.log'),
  • Step 4: Update layout fixtures used by existing tests

In packages/cli/src/runtime.test.ts, every object literal that represents a ManagedPythonRuntimeLayout must include these fields:

          daemonStatePath: '/runtime/0.2.0/daemon.json',
          daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
          daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',

In packages/cli/src/managed-python-command.test.ts, update the layout() helper to return these fields:

    daemonStatePath: '/runtime/0.2.0/daemon.json',
    daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
    daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
  • Step 5: Verify the TypeScript layout change

Run:

pnpm --filter @ktx/cli run test -- src/managed-python-runtime.test.ts src/runtime.test.ts src/managed-python-command.test.ts

Expected: PASS.

  • Step 6: Write the failing Python health-version test

In python/ktx-daemon/tests/test_app.py, add this test after test_health_endpoint_returns_healthy:

def test_health_endpoint_returns_managed_runtime_version(monkeypatch) -> None:
    monkeypatch.setenv("KTX_DAEMON_VERSION", "0.2.0")
    client = TestClient(create_app())

    response = client.get("/health")

    assert response.status_code == 200
    assert response.json() == {"status": "healthy", "version": "0.2.0"}
  • Step 7: Run the failing Python health test

Run:

source .venv/bin/activate && uv run pytest python/ktx-daemon/tests/test_app.py::test_health_endpoint_returns_managed_runtime_version -q

Expected: FAIL because /health does not include version.

  • Step 8: Include version metadata in daemon health

In python/ktx-daemon/src/ktx_daemon/app.py, add this import with the existing imports:

import os

Replace the health endpoint with:

    @app.get("/health")
    async def health() -> dict[str, str]:
        response = {"status": "healthy"}
        version = os.environ.get("KTX_DAEMON_VERSION")
        if version:
            response["version"] = version
        return response
  • Step 9: Verify Python health tests

Run:

source .venv/bin/activate && uv run pytest python/ktx-daemon/tests/test_app.py -q

Expected: PASS.

  • Step 10: Run Python pre-commit for modified Python files

Run:

source .venv/bin/activate && uv run pre-commit run --files python/ktx-daemon/src/ktx_daemon/app.py python/ktx-daemon/tests/test_app.py

Expected: PASS. If pre-commit cannot run because hooks or tool versions are missing, capture the error and run:

source .venv/bin/activate && uv run ruff check python/ktx-daemon/src/ktx_daemon/app.py python/ktx-daemon/tests/test_app.py
  • Step 11: Commit

Run:

git add packages/cli/src/managed-python-runtime.ts packages/cli/src/managed-python-runtime.test.ts packages/cli/src/runtime.test.ts packages/cli/src/managed-python-command.test.ts python/ktx-daemon/src/ktx_daemon/app.py python/ktx-daemon/tests/test_app.py
git commit -m "feat: add managed runtime daemon metadata"

Task 2: Implement managed daemon lifecycle library

Files:

  • Create: packages/cli/src/managed-python-daemon.test.ts

  • Create: packages/cli/src/managed-python-daemon.ts

  • Test: packages/cli/src/managed-python-daemon.test.ts

  • Step 1: Write the failing daemon lifecycle tests

Create packages/cli/src/managed-python-daemon.test.ts with this content:

import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
  readManagedPythonDaemonStatus,
  startManagedPythonDaemon,
  stopManagedPythonDaemon,
  type ManagedPythonDaemonChild,
  type ManagedPythonDaemonFetch,
  type ManagedPythonDaemonSpawn,
  type ManagedPythonDaemonState,
} from './managed-python-daemon.js';
import type {
  InstalledKtxRuntimeManifest,
  ManagedPythonRuntimeInstallResult,
  ManagedPythonRuntimeLayout,
} from './managed-python-runtime.js';

function layout(root: string): ManagedPythonRuntimeLayout {
  return {
    cliVersion: '0.2.0',
    runtimeRoot: join(root, 'runtime'),
    versionDir: join(root, 'runtime', '0.2.0'),
    venvDir: join(root, 'runtime', '0.2.0', '.venv'),
    manifestPath: join(root, 'runtime', '0.2.0', 'manifest.json'),
    installLogPath: join(root, 'runtime', '0.2.0', 'install.log'),
    assetDir: join(root, 'assets', 'python'),
    assetManifestPath: join(root, 'assets', 'python', 'manifest.json'),
    pythonPath: join(root, 'runtime', '0.2.0', '.venv', 'bin', 'python'),
    daemonPath: join(root, 'runtime', '0.2.0', '.venv', 'bin', 'ktx-daemon'),
    daemonStatePath: join(root, 'runtime', '0.2.0', 'daemon.json'),
    daemonStdoutPath: join(root, 'runtime', '0.2.0', 'daemon.stdout.log'),
    daemonStderrPath: join(root, 'runtime', '0.2.0', 'daemon.stderr.log'),
  };
}

function manifest(root: string, features: Array<'core' | 'local-embeddings'> = ['core']): InstalledKtxRuntimeManifest {
  const runtimeLayout = layout(root);
  return {
    schemaVersion: 1,
    cliVersion: '0.2.0',
    installedAt: '2026-05-11T00:00:00.000Z',
    asset: {
      schemaVersion: 1,
      distributionName: 'kaelio-ktx',
      normalizedName: 'kaelio_ktx',
      version: '0.2.0',
      wheel: {
        file: 'kaelio_ktx-0.2.0-py3-none-any.whl',
        sha256: 'a'.repeat(64),
        bytes: 123,
      },
    },
    features,
    python: {
      executable: runtimeLayout.pythonPath,
      daemonExecutable: runtimeLayout.daemonPath,
    },
    installLog: runtimeLayout.installLogPath,
  };
}

function installResult(root: string, features: Array<'core' | 'local-embeddings'> = ['core']): ManagedPythonRuntimeInstallResult {
  return {
    status: 'ready',
    layout: layout(root),
    asset: {
      manifest: manifest(root, features).asset,
      wheelPath: join(root, 'assets', 'python', 'kaelio_ktx-0.2.0-py3-none-any.whl'),
    },
    manifest: manifest(root, features),
  };
}

function makeFetch(version = '0.2.0'): ManagedPythonDaemonFetch {
  return vi.fn(async () => ({
    ok: true,
    status: 200,
    json: async () => ({ status: 'healthy', version }),
    text: async () => '',
  }));
}

function makeSpawn(pid = 4242): ManagedPythonDaemonSpawn {
  return vi.fn((_command, _args, _options): ManagedPythonDaemonChild => ({
    pid,
    unref: vi.fn(),
  }));
}

function runningState(root: string, overrides: Partial<ManagedPythonDaemonState> = {}): ManagedPythonDaemonState {
  const runtimeLayout = layout(root);
  return {
    schemaVersion: 1,
    pid: 4242,
    host: '127.0.0.1',
    port: 58731,
    version: '0.2.0',
    features: ['core'],
    startedAt: '2026-05-11T00:00:00.000Z',
    stdoutLog: runtimeLayout.daemonStdoutPath,
    stderrLog: runtimeLayout.daemonStderrPath,
    ...overrides,
  };
}

describe('managed Python daemon lifecycle', () => {
  let tempDir: string;

  beforeEach(async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'ktx-managed-daemon-'));
  });

  afterEach(async () => {
    await rm(tempDir, { recursive: true, force: true });
  });

  it('reports stopped when no daemon state exists', async () => {
    const status = await readManagedPythonDaemonStatus({
      cliVersion: '0.2.0',
      runtimeRoot: join(tempDir, 'runtime'),
      processAlive: vi.fn(() => false),
      fetch: makeFetch(),
    });

    expect(status.kind).toBe('stopped');
    expect(status.detail).toContain('No daemon state');
  });

  it('starts ktx-daemon serve-http, waits for health, and writes state', async () => {
    const spawnDaemon = makeSpawn(5555);
    const installRuntime = vi.fn(async () => installResult(tempDir));

    const result = await startManagedPythonDaemon({
      cliVersion: '0.2.0',
      runtimeRoot: join(tempDir, 'runtime'),
      features: ['core'],
      installRuntime,
      spawnDaemon,
      fetch: makeFetch(),
      allocatePort: vi.fn(async () => 61234),
      now: () => new Date('2026-05-11T00:00:00.000Z'),
      pollIntervalMs: 1,
    });

    expect(result.status).toBe('started');
    expect(result.baseUrl).toBe('http://127.0.0.1:61234');
    expect(installRuntime).toHaveBeenCalledWith({
      cliVersion: '0.2.0',
      runtimeRoot: join(tempDir, 'runtime'),
      features: ['core'],
      force: false,
    });
    expect(spawnDaemon).toHaveBeenCalledWith(
      layout(tempDir).daemonPath,
      ['serve-http', '--host', '127.0.0.1', '--port', '61234'],
      expect.objectContaining({
        detached: true,
        env: expect.objectContaining({ KTX_DAEMON_VERSION: '0.2.0' }),
      }),
    );
    expect(JSON.parse(await readFile(layout(tempDir).daemonStatePath, 'utf8'))).toMatchObject({
      pid: 5555,
      port: 61234,
      version: '0.2.0',
      features: ['core'],
      stdoutLog: layout(tempDir).daemonStdoutPath,
      stderrLog: layout(tempDir).daemonStderrPath,
    });
  });

  it('reuses a healthy daemon with the requested feature set', async () => {
    await mkdir(layout(tempDir).versionDir, { recursive: true });
    await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
    const spawnDaemon = makeSpawn(9999);

    const result = await startManagedPythonDaemon({
      cliVersion: '0.2.0',
      runtimeRoot: join(tempDir, 'runtime'),
      features: ['core'],
      installRuntime: vi.fn(async () => installResult(tempDir)),
      spawnDaemon,
      fetch: makeFetch(),
      processAlive: vi.fn(() => true),
      pollIntervalMs: 1,
    });

    expect(result.status).toBe('reused');
    expect(result.baseUrl).toBe('http://127.0.0.1:58731');
    expect(spawnDaemon).not.toHaveBeenCalled();
  });

  it('starts a fresh daemon when the previous state is stale', async () => {
    await mkdir(layout(tempDir).versionDir, { recursive: true });
    await writeFile(
      layout(tempDir).daemonStatePath,
      `${JSON.stringify(runningState(tempDir, { version: '0.1.0' }), null, 2)}\n`,
    );

    const result = await startManagedPythonDaemon({
      cliVersion: '0.2.0',
      runtimeRoot: join(tempDir, 'runtime'),
      features: ['core'],
      installRuntime: vi.fn(async () => installResult(tempDir)),
      spawnDaemon: makeSpawn(6666),
      fetch: makeFetch(),
      processAlive: vi.fn(() => true),
      killProcess: vi.fn(),
      allocatePort: vi.fn(async () => 61235),
      now: () => new Date('2026-05-11T00:00:00.000Z'),
      pollIntervalMs: 1,
    });

    expect(result.status).toBe('started');
    expect(JSON.parse(await readFile(layout(tempDir).daemonStatePath, 'utf8'))).toMatchObject({
      pid: 6666,
      port: 61235,
      version: '0.2.0',
    });
  });

  it('stops a recorded daemon and removes the state file', async () => {
    await mkdir(layout(tempDir).versionDir, { recursive: true });
    await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
    const killProcess = vi.fn();

    const result = await stopManagedPythonDaemon({
      cliVersion: '0.2.0',
      runtimeRoot: join(tempDir, 'runtime'),
      processAlive: vi.fn(() => true),
      killProcess,
    });

    expect(result.status).toBe('stopped');
    expect(killProcess).toHaveBeenCalledWith(4242);
    await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow();
  });
});
  • Step 2: Run the failing daemon lifecycle tests

Run:

pnpm --filter @ktx/cli run test -- src/managed-python-daemon.test.ts

Expected: FAIL with an import error for ./managed-python-daemon.js.

  • Step 3: Implement the daemon lifecycle module

Create packages/cli/src/managed-python-daemon.ts with this content:

import { spawn } from 'node:child_process';
import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { setTimeout as delay } from 'node:timers/promises';
import { z } from 'zod';
import {
  installManagedPythonRuntime,
  managedPythonRuntimeLayout,
  runtimeFeatureSchema,
  type KtxRuntimeFeature,
  type ManagedPythonRuntimeInstallOptions,
  type ManagedPythonRuntimeInstallResult,
  type ManagedPythonRuntimeLayout,
  type ManagedPythonRuntimeLayoutOptions,
} from './managed-python-runtime.js';

export interface ManagedPythonDaemonState {
  schemaVersion: 1;
  pid: number;
  host: '127.0.0.1';
  port: number;
  version: string;
  features: KtxRuntimeFeature[];
  startedAt: string;
  stdoutLog: string;
  stderrLog: string;
}

export type ManagedPythonDaemonStatus =
  | { kind: 'stopped'; detail: string; layout: ManagedPythonRuntimeLayout }
  | { kind: 'running'; detail: string; layout: ManagedPythonRuntimeLayout; state: ManagedPythonDaemonState; baseUrl: string }
  | { kind: 'stale'; detail: string; layout: ManagedPythonRuntimeLayout; state?: ManagedPythonDaemonState };

export interface ManagedPythonDaemonStartResult {
  status: 'started' | 'reused';
  layout: ManagedPythonRuntimeLayout;
  state: ManagedPythonDaemonState;
  baseUrl: string;
}

export interface ManagedPythonDaemonStopResult {
  status: 'stopped' | 'already-stopped';
  layout: ManagedPythonRuntimeLayout;
  state?: ManagedPythonDaemonState;
}

export interface ManagedPythonDaemonChild {
  pid?: number;
  unref(): void;
}

export type ManagedPythonDaemonSpawn = (
  command: string,
  args: string[],
  options: {
    detached: boolean;
    stdio: ['ignore', number, number];
    env: NodeJS.ProcessEnv;
  },
) => ManagedPythonDaemonChild;

export type ManagedPythonDaemonFetch = (
  url: string,
) => Promise<{
  ok: boolean;
  status: number;
  json(): Promise<unknown>;
  text(): Promise<string>;
}>;

export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLayoutOptions {
  features: KtxRuntimeFeature[];
  force?: boolean;
  installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
  spawnDaemon?: ManagedPythonDaemonSpawn;
  fetch?: ManagedPythonDaemonFetch;
  allocatePort?: () => Promise<number>;
  processAlive?: (pid: number) => boolean;
  killProcess?: (pid: number) => void;
  now?: () => Date;
  startupTimeoutMs?: number;
  pollIntervalMs?: number;
}

export interface ManagedPythonDaemonStatusOptions extends ManagedPythonRuntimeLayoutOptions {
  fetch?: ManagedPythonDaemonFetch;
  processAlive?: (pid: number) => boolean;
}

export interface ManagedPythonDaemonStopOptions extends ManagedPythonRuntimeLayoutOptions {
  processAlive?: (pid: number) => boolean;
  killProcess?: (pid: number) => void;
}

const daemonStateSchema = z.object({
  schemaVersion: z.literal(1),
  pid: z.number().int().positive(),
  host: z.literal('127.0.0.1'),
  port: z.number().int().min(1).max(65535),
  version: z.string().min(1),
  features: z.array(runtimeFeatureSchema).min(1),
  startedAt: z.string().min(1),
  stdoutLog: z.string().min(1),
  stderrLog: z.string().min(1),
});

function normalizeFeatures(features: KtxRuntimeFeature[]): KtxRuntimeFeature[] {
  const requested = new Set<KtxRuntimeFeature>(['core', ...features]);
  return runtimeFeatureSchema.options.filter((feature) => requested.has(feature));
}

function hasFeatures(state: ManagedPythonDaemonState, features: KtxRuntimeFeature[]): boolean {
  return normalizeFeatures(features).every((feature) => state.features.includes(feature));
}

function defaultFetch(url: string): ReturnType<ManagedPythonDaemonFetch> {
  return fetch(url) as ReturnType<ManagedPythonDaemonFetch>;
}

function defaultProcessAlive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

function defaultKillProcess(pid: number): void {
  try {
    process.kill(pid, 'SIGTERM');
  } catch (error) {
    const code = (error as { code?: unknown }).code;
    if (code !== 'ESRCH') {
      throw error;
    }
  }
}

function defaultSpawnDaemon(
  command: string,
  args: string[],
  options: Parameters<ManagedPythonDaemonSpawn>[2],
): ManagedPythonDaemonChild {
  return spawn(command, args, options);
}

function baseUrl(state: Pick<ManagedPythonDaemonState, 'host' | 'port'>): string {
  return `http://${state.host}:${state.port}`;
}

async function readState(path: string): Promise<ManagedPythonDaemonState | undefined> {
  try {
    return daemonStateSchema.parse(JSON.parse(await readFile(path, 'utf8')) as unknown);
  } catch (error) {
    const code = (error as { code?: unknown }).code;
    if (code === 'ENOENT') {
      return undefined;
    }
    throw error;
  }
}

async function writeState(path: string, state: ManagedPythonDaemonState): Promise<void> {
  await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
}

async function healthOk(input: {
  state: ManagedPythonDaemonState;
  cliVersion: string;
  fetch: ManagedPythonDaemonFetch;
}): Promise<{ ok: true } | { ok: false; detail: string }> {
  try {
    const response = await input.fetch(`${baseUrl(input.state)}/health`);
    if (!response.ok) {
      return { ok: false, detail: `Health check returned HTTP ${response.status}: ${await response.text()}` };
    }
    const body = (await response.json()) as unknown;
    if (!body || typeof body !== 'object' || Array.isArray(body)) {
      return { ok: false, detail: 'Health check returned non-object JSON' };
    }
    const record = body as Record<string, unknown>;
    if (record.status !== 'healthy') {
      return { ok: false, detail: `Health check returned status ${String(record.status)}` };
    }
    if (record.version !== input.cliVersion) {
      return {
        ok: false,
        detail: `Daemon version ${String(record.version)} does not match CLI ${input.cliVersion}`,
      };
    }
    return { ok: true };
  } catch (error) {
    return { ok: false, detail: error instanceof Error ? error.message : String(error) };
  }
}

export async function readManagedPythonDaemonStatus(
  options: ManagedPythonDaemonStatusOptions,
): Promise<ManagedPythonDaemonStatus> {
  const layout = managedPythonRuntimeLayout(options);
  let state: ManagedPythonDaemonState | undefined;
  try {
    state = await readState(layout.daemonStatePath);
  } catch (error) {
    return {
      kind: 'stale',
      detail: `Daemon state is invalid: ${error instanceof Error ? error.message : String(error)}`,
      layout,
    };
  }
  if (!state) {
    return { kind: 'stopped', detail: `No daemon state at ${layout.daemonStatePath}`, layout };
  }
  if (state.version !== options.cliVersion) {
    return {
      kind: 'stale',
      detail: `Daemon is for CLI ${state.version}, current CLI is ${options.cliVersion}`,
      layout,
      state,
    };
  }
  const processAlive = options.processAlive ?? defaultProcessAlive;
  if (!processAlive(state.pid)) {
    return { kind: 'stale', detail: `Daemon process ${state.pid} is not running`, layout, state };
  }
  const health = await healthOk({
    state,
    cliVersion: options.cliVersion,
    fetch: options.fetch ?? defaultFetch,
  });
  if (!health.ok) {
    return { kind: 'stale', detail: health.detail, layout, state };
  }
  return { kind: 'running', detail: `Daemon running at ${baseUrl(state)}`, layout, state, baseUrl: baseUrl(state) };
}

export async function allocateDaemonPort(): Promise<number> {
  return await new Promise((resolve, reject) => {
    const server = createServer();
    server.on('error', reject);
    server.listen(0, '127.0.0.1', () => {
      const address = server.address();
      server.close(() => {
        if (address && typeof address === 'object') {
          resolve(address.port);
          return;
        }
        reject(new Error('Failed to allocate a daemon port'));
      });
    });
  });
}

async function waitForHealth(input: {
  state: ManagedPythonDaemonState;
  cliVersion: string;
  fetch: ManagedPythonDaemonFetch;
  timeoutMs: number;
  pollIntervalMs: number;
}): Promise<void> {
  const deadline = Date.now() + input.timeoutMs;
  let lastDetail = 'daemon did not answer health checks';
  while (Date.now() <= deadline) {
    const health = await healthOk({
      state: input.state,
      cliVersion: input.cliVersion,
      fetch: input.fetch,
    });
    if (health.ok) {
      return;
    }
    lastDetail = health.detail;
    await delay(input.pollIntervalMs);
  }
  throw new Error(`KTX Python daemon failed to start: ${lastDetail}. stderr: ${input.state.stderrLog}`);
}

async function removeState(layout: ManagedPythonRuntimeLayout): Promise<void> {
  await rm(layout.daemonStatePath, { force: true });
}

async function stopRecordedDaemon(input: {
  layout: ManagedPythonRuntimeLayout;
  state: ManagedPythonDaemonState;
  processAlive: (pid: number) => boolean;
  killProcess: (pid: number) => void;
}): Promise<void> {
  if (input.processAlive(input.state.pid)) {
    input.killProcess(input.state.pid);
  }
  await removeState(input.layout);
}

export async function startManagedPythonDaemon(
  options: ManagedPythonDaemonStartOptions,
): Promise<ManagedPythonDaemonStartResult> {
  const features = normalizeFeatures(options.features);
  const installRuntime = options.installRuntime ?? installManagedPythonRuntime;
  const layoutOverrides = {
    ...(options.runtimeRoot !== undefined ? { runtimeRoot: options.runtimeRoot } : {}),
    ...(options.assetDir !== undefined ? { assetDir: options.assetDir } : {}),
    ...(options.platform !== undefined ? { platform: options.platform } : {}),
    ...(options.env !== undefined ? { env: options.env } : {}),
    ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
  };
  const layout = managedPythonRuntimeLayout({ cliVersion: options.cliVersion, ...layoutOverrides });
  const processAlive = options.processAlive ?? defaultProcessAlive;
  const killProcess = options.killProcess ?? defaultKillProcess;
  const fetchImpl = options.fetch ?? defaultFetch;

  const status = await readManagedPythonDaemonStatus({
    cliVersion: options.cliVersion,
    ...layoutOverrides,
    fetch: fetchImpl,
    processAlive,
  });
  if (options.force !== true && status.kind === 'running' && hasFeatures(status.state, features)) {
    return { status: 'reused', layout, state: status.state, baseUrl: status.baseUrl };
  }
  if (status.state) {
    await stopRecordedDaemon({ layout, state: status.state, processAlive, killProcess });
  } else {
    await removeState(layout);
  }

  const installed = await installRuntime({
    cliVersion: options.cliVersion,
    ...layoutOverrides,
    features,
    force: false,
  });

  await mkdir(layout.versionDir, { recursive: true });
  const stdout = await open(layout.daemonStdoutPath, 'a');
  const stderr = await open(layout.daemonStderrPath, 'a');
  try {
    const port = await (options.allocatePort ?? allocateDaemonPort)();
    const spawnDaemon = options.spawnDaemon ?? defaultSpawnDaemon;
    const child = spawnDaemon(
      installed.manifest.python.daemonExecutable,
      ['serve-http', '--host', '127.0.0.1', '--port', String(port)],
      {
        detached: true,
        stdio: ['ignore', stdout.fd, stderr.fd],
        env: {
          ...process.env,
          KTX_DAEMON_VERSION: options.cliVersion,
        },
      },
    );
    child.unref();
    if (!child.pid) {
      throw new Error(`KTX Python daemon did not report a pid. stderr: ${layout.daemonStderrPath}`);
    }
    const state: ManagedPythonDaemonState = {
      schemaVersion: 1,
      pid: child.pid,
      host: '127.0.0.1',
      port,
      version: options.cliVersion,
      features: installed.manifest.features,
      startedAt: (options.now ?? (() => new Date()))().toISOString(),
      stdoutLog: layout.daemonStdoutPath,
      stderrLog: layout.daemonStderrPath,
    };
    await waitForHealth({
      state,
      cliVersion: options.cliVersion,
      fetch: fetchImpl,
      timeoutMs: options.startupTimeoutMs ?? 10_000,
      pollIntervalMs: options.pollIntervalMs ?? 100,
    });
    await writeState(layout.daemonStatePath, state);
    return { status: 'started', layout, state, baseUrl: baseUrl(state) };
  } finally {
    await stdout.close();
    await stderr.close();
  }
}

export async function stopManagedPythonDaemon(
  options: ManagedPythonDaemonStopOptions,
): Promise<ManagedPythonDaemonStopResult> {
  const layout = managedPythonRuntimeLayout(options);
  const state = await readState(layout.daemonStatePath);
  if (!state) {
    return { status: 'already-stopped', layout };
  }
  await stopRecordedDaemon({
    layout,
    state,
    processAlive: options.processAlive ?? defaultProcessAlive,
    killProcess: options.killProcess ?? defaultKillProcess,
  });
  return { status: 'stopped', layout, state };
}
  • Step 4: Run daemon lifecycle tests

Run:

pnpm --filter @ktx/cli run test -- src/managed-python-daemon.test.ts

Expected: PASS.

  • Step 5: Commit

Run:

git add packages/cli/src/managed-python-daemon.ts packages/cli/src/managed-python-daemon.test.ts
git commit -m "feat: manage python daemon lifecycle"

Task 3: Wire runtime start and stop commands

Files:

  • Modify: packages/cli/src/runtime.ts

  • Modify: packages/cli/src/runtime.test.ts

  • Modify: packages/cli/src/commands/runtime-commands.ts

  • Modify: packages/cli/src/index.test.ts

  • Modify: packages/cli/src/index.ts

  • Step 1: Write failing runtime command runner tests

In packages/cli/src/runtime.test.ts, add these imports:

import type {
  ManagedPythonDaemonStartResult,
  ManagedPythonDaemonStopResult,
} from './managed-python-daemon.js';

Add these tests inside describe('runKtxRuntime', () => { ... }) after the install test:

  it('starts the managed Python daemon and prints the base URL', async () => {
    const io = makeIo();
    const deps: KtxRuntimeDeps = {
      startDaemon: vi.fn(async (): Promise<ManagedPythonDaemonStartResult> => ({
        status: 'started',
        baseUrl: 'http://127.0.0.1:61234',
        layout: {
          cliVersion: '0.2.0',
          runtimeRoot: '/runtime',
          versionDir: '/runtime/0.2.0',
          venvDir: '/runtime/0.2.0/.venv',
          manifestPath: '/runtime/0.2.0/manifest.json',
          installLogPath: '/runtime/0.2.0/install.log',
          assetDir: '/assets/python',
          assetManifestPath: '/assets/python/manifest.json',
          pythonPath: '/runtime/0.2.0/.venv/bin/python',
          daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
          daemonStatePath: '/runtime/0.2.0/daemon.json',
          daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
          daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
        },
        state: {
          schemaVersion: 1,
          pid: 4242,
          host: '127.0.0.1',
          port: 61234,
          version: '0.2.0',
          features: ['core', 'local-embeddings'],
          startedAt: '2026-05-11T00:00:00.000Z',
          stdoutLog: '/runtime/0.2.0/daemon.stdout.log',
          stderrLog: '/runtime/0.2.0/daemon.stderr.log',
        },
      })),
    };

    await expect(
      runKtxRuntime(
        { command: 'start', cliVersion: '0.2.0', feature: 'local-embeddings', force: true },
        io.io,
        deps,
      ),
    ).resolves.toBe(0);

    expect(deps.startDaemon).toHaveBeenCalledWith({
      cliVersion: '0.2.0',
      features: ['local-embeddings'],
      force: true,
    });
    expect(io.stdout()).toContain('Started KTX Python daemon');
    expect(io.stdout()).toContain('url: http://127.0.0.1:61234');
    expect(io.stdout()).toContain('pid: 4242');
    expect(io.stdout()).toContain('features: core, local-embeddings');
    expect(io.stdout()).toContain('stderr: /runtime/0.2.0/daemon.stderr.log');
  });

  it('stops the managed Python daemon', async () => {
    const io = makeIo();
    const deps: KtxRuntimeDeps = {
      stopDaemon: vi.fn(async (): Promise<ManagedPythonDaemonStopResult> => ({
        status: 'stopped',
        layout: {
          cliVersion: '0.2.0',
          runtimeRoot: '/runtime',
          versionDir: '/runtime/0.2.0',
          venvDir: '/runtime/0.2.0/.venv',
          manifestPath: '/runtime/0.2.0/manifest.json',
          installLogPath: '/runtime/0.2.0/install.log',
          assetDir: '/assets/python',
          assetManifestPath: '/assets/python/manifest.json',
          pythonPath: '/runtime/0.2.0/.venv/bin/python',
          daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
          daemonStatePath: '/runtime/0.2.0/daemon.json',
          daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
          daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
        },
        state: {
          schemaVersion: 1,
          pid: 4242,
          host: '127.0.0.1',
          port: 61234,
          version: '0.2.0',
          features: ['core'],
          startedAt: '2026-05-11T00:00:00.000Z',
          stdoutLog: '/runtime/0.2.0/daemon.stdout.log',
          stderrLog: '/runtime/0.2.0/daemon.stderr.log',
        },
      })),
    };

    await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0' }, io.io, deps)).resolves.toBe(0);

    expect(deps.stopDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
    expect(io.stdout()).toContain('Stopped KTX Python daemon');
    expect(io.stdout()).toContain('pid: 4242');
  });
  • Step 2: Run the failing command runner tests

Run:

pnpm --filter @ktx/cli run test -- src/runtime.test.ts

Expected: FAIL because KtxRuntimeArgs and KtxRuntimeDeps do not include start, stop, startDaemon, or stopDaemon.

  • Step 3: Update the runtime command runner

In packages/cli/src/runtime.ts, add these imports:

import {
  startManagedPythonDaemon,
  stopManagedPythonDaemon,
  type ManagedPythonDaemonStartResult,
  type ManagedPythonDaemonStopResult,
} from './managed-python-daemon.js';

Extend KtxRuntimeArgs with:

  | { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
  | { command: 'stop'; cliVersion: string }

Extend KtxRuntimeDeps with:

  startDaemon?: (options: {
    cliVersion: string;
    features: KtxRuntimeFeature[];
    force?: boolean;
  }) => Promise<ManagedPythonDaemonStartResult>;
  stopDaemon?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopResult>;

Add these writer helpers after writeInstallResult:

function writeDaemonStart(io: KtxCliIo, result: ManagedPythonDaemonStartResult): void {
  const verb = result.status === 'reused' ? 'Using existing' : 'Started';
  io.stdout.write(`${verb} KTX Python daemon\n`);
  io.stdout.write(`url: ${result.baseUrl}\n`);
  io.stdout.write(`pid: ${result.state.pid}\n`);
  io.stdout.write(`version: ${result.state.version}\n`);
  io.stdout.write(`features: ${result.state.features.join(', ')}\n`);
  io.stdout.write(`state: ${result.layout.daemonStatePath}\n`);
  io.stdout.write(`stdout: ${result.state.stdoutLog}\n`);
  io.stdout.write(`stderr: ${result.state.stderrLog}\n`);
}

function writeDaemonStop(io: KtxCliIo, result: ManagedPythonDaemonStopResult): void {
  if (result.status === 'already-stopped') {
    io.stdout.write('KTX Python daemon already stopped\n');
    return;
  }
  io.stdout.write('Stopped KTX Python daemon\n');
  io.stdout.write(`pid: ${result.state?.pid ?? 'unknown'}\n`);
  io.stdout.write(`state: ${result.layout.daemonStatePath}\n`);
}

Inside runKtxRuntime, add these branches after the install branch:

    if (args.command === 'start') {
      const startDaemon = deps.startDaemon ?? startManagedPythonDaemon;
      const result = await startDaemon({
        cliVersion: args.cliVersion,
        features: [args.feature],
        force: args.force,
      });
      writeDaemonStart(io, result);
      return 0;
    }
    if (args.command === 'stop') {
      const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon;
      const result = await stopDaemon({ cliVersion: args.cliVersion });
      writeDaemonStop(io, result);
      return 0;
    }
  • Step 4: Verify runtime command runner tests

Run:

pnpm --filter @ktx/cli run test -- src/runtime.test.ts

Expected: PASS.

  • Step 5: Write failing Commander routing tests

In packages/cli/src/index.test.ts, inside it('routes runtime management commands with the CLI package version', ...), add two new IO handles after installIo:

    const startIo = makeIo();
    const stopIo = makeIo();

Replace the existing runtime install invocation with this version that also passes --yes, then add the new runtime start and runtime stop invocations immediately after it:

    await expect(
      runKtxCli(['runtime', 'install', '--feature', 'local-embeddings', '--force', '--yes'], installIo.io, {
        runtime,
      }),
    ).resolves.toBe(0);
    await expect(
      runKtxCli(['runtime', 'start', '--feature', 'local-embeddings', '--force'], startIo.io, { runtime }),
    ).resolves.toBe(0);
    await expect(runKtxCli(['runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);

Update the expect(runtime).toHaveBeenNthCalledWith(...) assertions so the runtime calls are:

    expect(runtime).toHaveBeenNthCalledWith(
      1,
      {
        command: 'install',
        cliVersion: '0.0.0-private',
        feature: 'local-embeddings',
        force: true,
      },
      installIo.io,
    );
    expect(runtime).toHaveBeenNthCalledWith(
      2,
      {
        command: 'start',
        cliVersion: '0.0.0-private',
        feature: 'local-embeddings',
        force: true,
      },
      startIo.io,
    );
    expect(runtime).toHaveBeenNthCalledWith(
      3,
      {
        command: 'stop',
        cliVersion: '0.0.0-private',
      },
      stopIo.io,
    );
    expect(runtime).toHaveBeenNthCalledWith(
      4,
      {
        command: 'status',
        cliVersion: '0.0.0-private',
        json: true,
      },
      statusIo.io,
    );
    expect(runtime).toHaveBeenNthCalledWith(
      5,
      {
        command: 'doctor',
        cliVersion: '0.0.0-private',
        json: false,
      },
      doctorIo.io,
    );
    expect(runtime).toHaveBeenNthCalledWith(
      6,
      {
        command: 'prune',
        cliVersion: '0.0.0-private',
        dryRun: true,
        yes: false,
      },
      pruneIo.io,
    );
  • Step 6: Run the failing Commander routing test

Run:

pnpm --filter @ktx/cli run test -- src/index.test.ts

Expected: FAIL because runtime install --yes is not accepted and runtime start and runtime stop are not registered.

  • Step 7: Register start and stop subcommands

In packages/cli/src/commands/runtime-commands.ts, update the existing runtime feature option to return a fresh Commander option per command:

function createRuntimeFeatureOption() {
  return new Option('--feature <feature>', 'Runtime feature level')
    .choices(['core', 'local-embeddings'])
    .default('core');
}

Then update the existing install command so it accepts --yes without changing behavior:

  runtime
    .command('install')
    .description('Install the bundled Python runtime wheel into the managed runtime')
    .addOption(createRuntimeFeatureOption())
    .option('--yes', 'Accept runtime installation without prompting', false)
    .option('--force', 'Reinstall even when the runtime already looks ready', false)
    .action(async (options: { feature: RuntimeFeature; yes?: boolean; force?: boolean }) => {
      await runRuntimeArgs(context, {
        command: 'install',
        cliVersion: context.packageInfo.version,
        feature: options.feature,
        force: options.force === true,
      });
    });

Add this start command after the install command:

  runtime
    .command('start')
    .description('Start the KTX-managed Python HTTP daemon')
    .addOption(createRuntimeFeatureOption())
    .option('--force', 'Restart even when a matching daemon is already running', false)
    .action(async (options: { feature: RuntimeFeature; force?: boolean }) => {
      await runRuntimeArgs(context, {
        command: 'start',
        cliVersion: context.packageInfo.version,
        feature: options.feature,
        force: options.force === true,
      });
    });

Add this stop command after the start command:

  runtime
    .command('stop')
    .description('Stop the KTX-managed Python HTTP daemon')
    .action(async () => {
      await runRuntimeArgs(context, {
        command: 'stop',
        cliVersion: context.packageInfo.version,
      });
    });
  • Step 8: Export daemon lifecycle helpers

In packages/cli/src/index.ts, add this export near the other public test and programmatic exports:

export {
  allocateDaemonPort,
  readManagedPythonDaemonStatus,
  startManagedPythonDaemon,
  stopManagedPythonDaemon,
} from './managed-python-daemon.js';
export type {
  ManagedPythonDaemonStartResult,
  ManagedPythonDaemonState,
  ManagedPythonDaemonStatus,
  ManagedPythonDaemonStopResult,
} from './managed-python-daemon.js';
  • Step 9: Verify CLI routing tests

Run:

pnpm --filter @ktx/cli run test -- src/index.test.ts src/runtime.test.ts

Expected: PASS.

  • Step 10: Commit

Run:

git add packages/cli/src/runtime.ts packages/cli/src/runtime.test.ts packages/cli/src/commands/runtime-commands.ts packages/cli/src/index.test.ts packages/cli/src/index.ts
git commit -m "feat: add runtime daemon start stop commands"

Task 4: Verify daemon lifecycle end to end

Files:

  • Verify: packages/cli/src/managed-python-daemon.ts

  • Verify: packages/cli/src/runtime.ts

  • Verify: python/ktx-daemon/src/ktx_daemon/app.py

  • Step 1: Run focused CLI tests

Run:

pnpm --filter @ktx/cli run test -- src/managed-python-runtime.test.ts src/managed-python-daemon.test.ts src/runtime.test.ts src/index.test.ts

Expected: PASS.

  • Step 2: Run focused Python tests

Run:

source .venv/bin/activate && uv run pytest python/ktx-daemon/tests/test_app.py python/ktx-daemon/tests/test_cli.py -q

Expected: PASS.

  • Step 3: Run TypeScript type-check

Run:

pnpm --filter @ktx/cli run type-check

Expected: PASS.

  • Step 4: Run Python pre-commit for modified files

Run:

source .venv/bin/activate && uv run pre-commit run --files python/ktx-daemon/src/ktx_daemon/app.py python/ktx-daemon/tests/test_app.py packages/cli/src/managed-python-runtime.ts packages/cli/src/managed-python-runtime.test.ts packages/cli/src/managed-python-daemon.ts packages/cli/src/managed-python-daemon.test.ts packages/cli/src/runtime.ts packages/cli/src/runtime.test.ts packages/cli/src/commands/runtime-commands.ts packages/cli/src/index.test.ts packages/cli/src/index.ts

Expected: PASS. If pre-commit rejects TypeScript file arguments because a hook only handles Python, run the Python-only pre-commit command from Task 1 and then run:

pnpm --filter @ktx/cli run check
  • Step 5: Build the CLI package

Run:

pnpm --filter @ktx/cli run build

Expected: PASS.

  • Step 6: Build runtime wheel assets

Run:

pnpm run artifacts:verify

Expected: PASS and packages/cli/assets/python/manifest.json exists with a matching kaelio_ktx-0.1.0-py3-none-any.whl.

  • Step 7: Smoke runtime install, start, reuse, and stop

Run:

KTX_RUNTIME_ROOT="$(mktemp -d)"
KTX_RUNTIME_ROOT="$KTX_RUNTIME_ROOT" node packages/cli/dist/bin.js runtime install --yes
KTX_RUNTIME_ROOT="$KTX_RUNTIME_ROOT" node packages/cli/dist/bin.js runtime start
KTX_RUNTIME_ROOT="$KTX_RUNTIME_ROOT" node packages/cli/dist/bin.js runtime start
KTX_RUNTIME_ROOT="$KTX_RUNTIME_ROOT" node packages/cli/dist/bin.js runtime stop
rm -rf "$KTX_RUNTIME_ROOT"

Expected:

Installed KTX Python runtime
Started KTX Python daemon
Using existing KTX Python daemon
Stopped KTX Python daemon

If the existing runtime layout does not honor KTX_RUNTIME_ROOT, run the same commands without that environment variable and clean up with:

node packages/cli/dist/bin.js runtime stop
node packages/cli/dist/bin.js runtime prune --dry-run
  • Step 8: Commit verification-only fixes if needed

If verification exposed a small defect inside this plan's files, fix it and commit only the touched files:

git add packages/cli/src/managed-python-daemon.ts packages/cli/src/managed-python-daemon.test.ts packages/cli/src/runtime.ts packages/cli/src/runtime.test.ts packages/cli/src/commands/runtime-commands.ts packages/cli/src/index.test.ts packages/cli/src/index.ts python/ktx-daemon/src/ktx_daemon/app.py python/ktx-daemon/tests/test_app.py packages/cli/src/managed-python-runtime.ts packages/cli/src/managed-python-runtime.test.ts packages/cli/src/managed-python-command.test.ts
git commit -m "fix: verify managed runtime daemon lifecycle"

Skip this step when there are no verification fixes.

Acceptance criteria

  • ktx runtime start installs or reuses the requested runtime feature level and starts ktx-daemon serve-http on 127.0.0.1 with a random available port.
  • ktx runtime start reuses a healthy matching daemon and starts a fresh daemon when the recorded process, health response, version, or feature set is stale.
  • ktx runtime stop terminates the recorded daemon process and removes the daemon state file.
  • The daemon state file records pid, port, version, features, startedAt, stdout log path, and stderr log path.
  • The daemon health endpoint returns {"status": "healthy"} by default and includes version when KTX_DAEMON_VERSION is set.
  • Daemon stdout and stderr are preserved under the versioned runtime directory.
  • Focused TypeScript tests, focused Python tests, CLI type-check, and Python-file pre-commit pass or have explicitly recorded environment blockers.

Self-review checklist

  • Spec coverage: this plan covers ktx runtime start, ktx runtime stop, daemon state, random localhost port binding, health validation, version matching, stale repair, and captured daemon logs. It leaves lazy embedding command integration and public npm renaming for later plans.
  • Placeholder scan: this plan contains no placeholder steps, deferred code blocks, or undefined function names.
  • Type consistency: runtime feature values are consistently core and local-embeddings; daemon state uses schemaVersion, pid, host, port, version, features, startedAt, stdoutLog, and stderrLog; command runner types use startDaemon and stopDaemon.