From e05a6d43abb4aa7b6e69376cac675c6a94def809 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> Date: Tue, 12 May 2026 01:07:47 +0200 Subject: [PATCH 1/8] fix(cli): report metabase ingest readiness --- packages/cli/src/setup.test.ts | 57 ++++++++++++++++++++++++++++++++++ packages/cli/src/setup.ts | 42 +++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index c8961e2a..44fc8c7d 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js'; import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js'; import { readKtxSetupStatus, runKtxSetup } from './setup.js'; @@ -274,6 +275,62 @@ describe('setup status', () => { }); }); + it('reports Vertex LLM and context ready after a successful Metabase ingest report', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'project: revenue', + 'setup:', + ' database_connection_ids:', + ' - warehouse', + ' completed_steps:', + ' - project', + ' - databases', + ' - sources', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', + ' metabase:', + ' driver: metabase', + ' url: env:METABASE_URL', + ' api_key_ref: env:METABASE_API_KEY', + ' warehouse_connection_id: warehouse', + 'llm:', + ' provider:', + ' backend: vertex', + ' vertex:', + ' project: kaelio-dev', + ' location: us-east5', + ' models:', + ' default: claude-sonnet-4-6', + 'ingest:', + ' embeddings:', + ' backend: deterministic', + ' model: deterministic', + ' dimensions: 8', + '', + ].join('\n'), + 'utf-8', + ); + await persistLocalBundleReport( + tempDir, + localFakeBundleReport('metabase-job-1', { + connectionId: 'warehouse', + sourceKey: 'metabase', + }), + ); + + const status = await readKtxSetupStatus(tempDir); + const io = makeIo(); + await expect(runKtxSetup({ command: 'status', projectDir: tempDir, json: false }, io.io)).resolves.toBe(0); + + expect(status.llm).toMatchObject({ backend: 'vertex', ready: true, model: 'claude-sonnet-4-6' }); + expect(status.context).toMatchObject({ ready: true, status: 'completed' }); + expect(io.stdout()).toContain('LLM ready: yes (claude-sonnet-4-6)'); + expect(io.stdout()).toContain('KTX context built: yes'); + }); + it('prints plain and JSON setup status', async () => { const plainIo = makeIo(); const jsonIo = makeIo(); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 89c5dcdc..0b0c400d 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -1,7 +1,8 @@ import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { cancel, isCancel, select } from '@clack/prompts'; -import { loadKtxProject } from '@ktx/context/project'; +import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest'; +import { ktxLocalStateDbPath, loadKtxProject, type KtxLocalProject } from '@ktx/context/project'; import type { KtxCliIo } from './cli-runtime.js'; import type { KtxDemoArgs } from './demo.js'; import { defaultDemoProjectDir } from './demo-assets.js'; @@ -152,6 +153,7 @@ export interface KtxSetupDeps { } const SOURCE_DRIVERS = new Set(['dbt', 'metricflow', 'metabase', 'looker', 'lookml', 'notion']); +const READY_LLM_BACKENDS = new Set(['anthropic', 'vertex', 'gateway']); type KtxSetupEntryAction = 'setup' | 'new-project' | 'agents' | 'status' | 'demo' | 'exit'; type KtxSetupFlowStep = 'models' | 'embeddings' | 'databases' | 'sources' | 'context' | 'agents'; @@ -234,7 +236,12 @@ async function runKtxSetupDemoFromEntryMenu( } function llmReady(status: KtxSetupStatus['llm']): boolean { - return status.backend === 'anthropic' && typeof status.model === 'string' && status.model.length > 0; + return ( + status.backend !== undefined && + READY_LLM_BACKENDS.has(status.backend) && + typeof status.model === 'string' && + status.model.length > 0 + ); } function embeddingsReady(status: KtxSetupStatus['embeddings']): boolean { @@ -259,6 +266,31 @@ function sourceConnections(config: Awaited>['c .sort((left, right) => left.connectionId.localeCompare(right.connectionId)); } +type LocalIngestStatusReport = NonNullable>>; + +function reportHasSavedContext(report: LocalIngestStatusReport): boolean { + if (report.body.failedWorkUnits.length > 0) { + return false; + } + const counts = savedMemoryCountsForReport(report); + return counts.wikiCount > 0 || counts.slCount > 0; +} + +async function readIngestContextStatus(project: KtxLocalProject): Promise { + if (!existsSync(ktxLocalStateDbPath(project))) { + return null; + } + const report = await getLatestLocalIngestStatus(project); + if (!report || !reportHasSavedContext(report)) { + return null; + } + return { + ready: true, + status: 'completed', + runId: report.runId, + }; +} + export async function readKtxSetupStatus(projectDir: string): Promise { const resolvedProjectDir = resolve(projectDir); if (!existsSync(join(resolvedProjectDir, 'ktx.yaml'))) { @@ -291,6 +323,10 @@ export async function readKtxSetupStatus(projectDir: string): Promise Date: Tue, 12 May 2026 11:13:14 +0200 Subject: [PATCH 2/8] feat(llm): add local AI SDK DevTools tracing --- .gitignore | 1 + README.md | 23 +++++ packages/llm/package.json | 1 + packages/llm/src/model-health.test.ts | 5 +- packages/llm/src/model-health.ts | 2 +- packages/llm/src/model-provider.test.ts | 132 +++++++++++++++++++++++- packages/llm/src/model-provider.ts | 43 +++++++- pnpm-lock.yaml | 14 +++ 8 files changed, 215 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index eb63517a..ed14196b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ yarn-error.log* # Local project runtime state .ktx/ +**/.devtools/ *.db *.sqlite *.sqlite3 diff --git a/README.md b/README.md index 84592226..255aa51b 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,29 @@ pnpm run link:dev ktx-dev --help ``` +### Debug LLM traces + +KTX can capture local AI SDK DevTools traces for LLM calls that run through the +KTX provider. Enable it with an environment flag when running an LLM-backed +command: + +```bash +KTX_AI_DEVTOOLS_ENABLED=true ktx dev ingest run \ + --connection-id warehouse \ + --adapter metabase +``` + +Traces are written to `.devtools/generations.json` under the current working +directory. To inspect them, run: + +```bash +pnpm dlx @ai-sdk/devtools +``` + +Then open `http://localhost:4983`. These traces are local-development-only and +store prompts, model outputs, tool arguments/results, and raw provider payloads +in plain text. Do not enable this in production or for sensitive runs. + The repository uses `pnpm` for TypeScript packages and `uv` for Python packages. See [Contributing](docs-site/content/docs/community/contributing.mdx) for full development setup, testing, and PR guidelines. diff --git a/packages/llm/package.json b/packages/llm/package.json index fc7deeba..13f49666 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@ai-sdk/anthropic": "3.0.71", + "@ai-sdk/devtools": "0.0.17", "@ai-sdk/google-vertex": "^4.0.112", "ai": "^6.0.168", "openai": "^6.25.0" diff --git a/packages/llm/src/model-health.test.ts b/packages/llm/src/model-health.test.ts index 003c12d5..d1b3df47 100644 --- a/packages/llm/src/model-health.test.ts +++ b/packages/llm/src/model-health.test.ts @@ -1,3 +1,4 @@ +import { wrapLanguageModel as defaultWrapLanguageModel } from 'ai'; import { describe, expect, it, vi } from 'vitest'; import { runKtxLlmHealthCheck } from './model-health.js'; @@ -7,6 +8,7 @@ describe('KTX LLM health check', () => { it('runs a minimal non-streaming model call through the configured provider', async () => { const generateText = vi.fn(async () => ({ text: 'ok' })); const createAnthropic = vi.fn(() => vi.fn(() => anthropicModel)); + const wrapLanguageModel = vi.fn(defaultWrapLanguageModel); await expect( runKtxLlmHealthCheck( @@ -15,7 +17,7 @@ describe('KTX LLM health check', () => { anthropic: { apiKey: 'sk-ant-test' }, modelSlots: { default: 'claude-sonnet-4-6' }, }, - { deps: { createAnthropic, generateText } }, + { deps: { createAnthropic, generateText, devtoolsEnabled: true, wrapLanguageModel } }, ), ).resolves.toEqual({ ok: true }); @@ -32,6 +34,7 @@ describe('KTX LLM health check', () => { maxOutputTokens: 8, }), ); + expect(wrapLanguageModel).not.toHaveBeenCalled(); }); it('returns a failed result without exposing secret values', async () => { diff --git a/packages/llm/src/model-health.ts b/packages/llm/src/model-health.ts index 131822b6..abbc2735 100644 --- a/packages/llm/src/model-health.ts +++ b/packages/llm/src/model-health.ts @@ -41,7 +41,7 @@ export async function runKtxLlmHealthCheck( ): Promise { try { const { generateText: runGenerateTextOverride, ...providerDeps } = options.deps ?? {}; - const provider = createKtxLlmProvider(config, providerDeps); + const provider = createKtxLlmProvider(config, { ...providerDeps, devtoolsEnabled: false }); const runGenerateText = runGenerateTextOverride ?? generateText; await withTimeout( runGenerateText({ diff --git a/packages/llm/src/model-provider.test.ts b/packages/llm/src/model-provider.test.ts index 55dd5da9..ff65a12a 100644 --- a/packages/llm/src/model-provider.test.ts +++ b/packages/llm/src/model-provider.test.ts @@ -1,10 +1,138 @@ -import type { LanguageModel } from 'ai'; +import { devToolsMiddleware as defaultDevToolsMiddleware } from '@ai-sdk/devtools'; +import { wrapLanguageModel as defaultWrapLanguageModel, type LanguageModel } from 'ai'; import { describe, expect, it, vi } from 'vitest'; -import { createKtxLlmProvider } from './model-provider.js'; +import { createKtxLlmProvider, type KtxLlmProviderFactoryDeps } from './model-provider.js'; const languageModel = (modelId: string, provider = 'test'): LanguageModel => ({ modelId, provider }) as LanguageModel; +const devtoolsMiddleware = (): ReturnType => ({ specificationVersion: 'v3' }); +const wrapWith = (model: LanguageModel) => + vi.fn((_options: Parameters[0]) => model as ReturnType); describe('createKtxLlmProvider', () => { + it('wraps language models with DevTools middleware when explicitly enabled', () => { + const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic'); + const wrappedModel = languageModel('claude-sonnet-4-6', 'anthropic-devtools'); + const middleware = devtoolsMiddleware(); + const wrapLanguageModel = wrapWith(wrappedModel); + const devToolsMiddleware = vi.fn(devtoolsMiddleware); + + const provider = createKtxLlmProvider( + { + backend: 'anthropic', + anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret + modelSlots: { default: 'claude-sonnet-4-6' }, + promptCaching: { enabled: false }, + }, + { + createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)), + devtoolsEnabled: true, + wrapLanguageModel, + devToolsMiddleware, + } satisfies KtxLlmProviderFactoryDeps, + ); + + expect(provider.getModel('default')).toBe(wrappedModel); + expect(devToolsMiddleware).toHaveBeenCalledTimes(1); + expect(wrapLanguageModel).toHaveBeenCalledWith({ + model: anthropicModel, + middleware, + modelId: 'claude-sonnet-4-6', + providerId: 'anthropic', + }); + }); + + it('does not wrap language models by default', () => { + const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic'); + const wrapLanguageModel = vi.fn(defaultWrapLanguageModel); + const devToolsMiddleware = vi.fn(defaultDevToolsMiddleware); + + const provider = createKtxLlmProvider( + { + backend: 'anthropic', + anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret + modelSlots: { default: 'claude-sonnet-4-6' }, + promptCaching: { enabled: false }, + }, + { + createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)), + wrapLanguageModel, + devToolsMiddleware, + } satisfies KtxLlmProviderFactoryDeps, + ); + + expect(provider.getModel('default')).toBe(anthropicModel); + expect(wrapLanguageModel).not.toHaveBeenCalled(); + expect(devToolsMiddleware).not.toHaveBeenCalled(); + }); + + it('wraps language models when KTX_AI_DEVTOOLS_ENABLED is true', () => { + const originalEnv = process.env.KTX_AI_DEVTOOLS_ENABLED; + process.env.KTX_AI_DEVTOOLS_ENABLED = 'true'; + try { + const gatewayModel = languageModel('anthropic/claude-sonnet-4-6', 'gateway'); + const wrappedModel = languageModel('anthropic/claude-sonnet-4-6', 'gateway-devtools'); + const wrapLanguageModel = wrapWith(wrappedModel); + + const provider = createKtxLlmProvider( + { + backend: 'gateway', + gateway: { baseURL: 'https://gateway.test/v1' }, + modelSlots: { default: 'anthropic/claude-sonnet-4-6' }, + promptCaching: { enabled: false }, + }, + { + createGateway: vi.fn(() => vi.fn(() => gatewayModel)), + wrapLanguageModel, + devToolsMiddleware: vi.fn(devtoolsMiddleware), + } satisfies KtxLlmProviderFactoryDeps, + ); + + expect(provider.getModel('default')).toBe(wrappedModel); + expect(wrapLanguageModel).toHaveBeenCalledTimes(1); + } finally { + if (originalEnv === undefined) { + delete process.env.KTX_AI_DEVTOOLS_ENABLED; + } else { + process.env.KTX_AI_DEVTOOLS_ENABLED = originalEnv; + } + } + }); + + it('does not wrap language models in production even when enabled', () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic'); + const wrapLanguageModel = vi.fn(defaultWrapLanguageModel); + const devToolsMiddleware = vi.fn(defaultDevToolsMiddleware); + + const provider = createKtxLlmProvider( + { + backend: 'anthropic', + anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret + modelSlots: { default: 'claude-sonnet-4-6' }, + promptCaching: { enabled: false }, + }, + { + createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)), + devtoolsEnabled: true, + wrapLanguageModel, + devToolsMiddleware, + } satisfies KtxLlmProviderFactoryDeps, + ); + + expect(provider.getModel('default')).toBe(anthropicModel); + expect(wrapLanguageModel).not.toHaveBeenCalled(); + expect(devToolsMiddleware).not.toHaveBeenCalled(); + } finally { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + } + }); + it('uses direct Anthropic with both beta headers', () => { const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic'); const anthropic = vi.fn(() => anthropicModel); diff --git a/packages/llm/src/model-provider.ts b/packages/llm/src/model-provider.ts index 66a9fddd..6dbdcb06 100644 --- a/packages/llm/src/model-provider.ts +++ b/packages/llm/src/model-provider.ts @@ -1,6 +1,7 @@ import { createAnthropic } from '@ai-sdk/anthropic'; +import { devToolsMiddleware } from '@ai-sdk/devtools'; import { createVertexAnthropic } from '@ai-sdk/google-vertex/anthropic'; -import { createGateway, generateText, type LanguageModel } from 'ai'; +import { createGateway, generateText, wrapLanguageModel, type LanguageModel } from 'ai'; import { createKtxToolCallRepairHandler } from './repair.js'; import type { KtxLlmConfig, @@ -21,6 +22,9 @@ export interface KtxLlmProviderFactoryDeps { createVertexAnthropic?: VertexAnthropicFactory; createGateway?: GatewayFactory; generateText?: typeof generateText; + devtoolsEnabled?: boolean; + wrapLanguageModel?: typeof wrapLanguageModel; + devToolsMiddleware?: typeof devToolsMiddleware; } const DEFAULT_PROMPT_CACHING: KtxPromptCachingConfig = { @@ -40,10 +44,27 @@ function resolvePromptCaching(config: KtxLlmConfig): KtxPromptCachingConfig { return { ...DEFAULT_PROMPT_CACHING, ...config.promptCaching }; } +function resolveDevtoolsEnabled(override: boolean | undefined): boolean { + if (process.env.NODE_ENV === 'production') { + return false; + } + if (override !== undefined) { + return override; + } + const value = process.env.KTX_AI_DEVTOOLS_ENABLED?.trim().toLowerCase(); + return value === 'true' || value === '1' || value === 'yes'; +} + export function modelIdFromLanguageModel(model: LanguageModel | string): string { return typeof model === 'string' ? model : ((model as { modelId?: string }).modelId ?? ''); } +function providerIdFromLanguageModel(model: Exclude): string | undefined { + return typeof (model as { provider?: unknown }).provider === 'string' + ? (model as { provider: string }).provider + : undefined; +} + export function isAnthropicProtocolModel(model: LanguageModel | string): boolean { const modelId = modelIdFromLanguageModel(model); return modelId.startsWith('claude-') || modelId.startsWith('anthropic/') || modelId.includes('/claude-'); @@ -53,6 +74,9 @@ class DefaultKtxLlmProvider implements KtxLlmProvider { private readonly promptCaching: KtxPromptCachingConfig; private readonly getModelByResolvedName: (modelId: string) => LanguageModel; private readonly runGenerateText: typeof generateText; + private readonly devtoolsEnabled: boolean; + private readonly runWrapLanguageModel: typeof wrapLanguageModel; + private readonly createDevToolsMiddleware: typeof devToolsMiddleware; constructor( private readonly config: KtxLlmConfig, @@ -60,6 +84,9 @@ class DefaultKtxLlmProvider implements KtxLlmProvider { ) { this.promptCaching = resolvePromptCaching(config); this.runGenerateText = deps.generateText ?? generateText; + this.devtoolsEnabled = resolveDevtoolsEnabled(deps.devtoolsEnabled); + this.runWrapLanguageModel = deps.wrapLanguageModel ?? wrapLanguageModel; + this.createDevToolsMiddleware = deps.devToolsMiddleware ?? devToolsMiddleware; this.getModelByResolvedName = this.createModelFactory(config, deps); } @@ -68,7 +95,7 @@ class DefaultKtxLlmProvider implements KtxLlmProvider { } getModelByName(modelId: string): LanguageModel { - return this.getModelByResolvedName(modelId); + return this.withDevtools(this.getModelByResolvedName(modelId)); } cacheMarker(ttl: KtxPromptCacheTtl, model?: LanguageModel | string) { @@ -113,6 +140,18 @@ class DefaultKtxLlmProvider implements KtxLlmProvider { return this.config.modelSlots[role] ?? this.config.modelSlots.default; } + private withDevtools(model: LanguageModel): LanguageModel { + if (!this.devtoolsEnabled || typeof model === 'string') { + return model; + } + return this.runWrapLanguageModel({ + model: model as Parameters[0]['model'], + middleware: this.createDevToolsMiddleware(), + modelId: modelIdFromLanguageModel(model), + providerId: providerIdFromLanguageModel(model), + }); + } + private createModelFactory(config: KtxLlmConfig, deps: KtxLlmProviderFactoryDeps): (modelId: string) => LanguageModel { if (config.backend === 'anthropic') { const anthropic = (deps.createAnthropic ?? createAnthropic)({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e74389f9..0cd6dbeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,6 +355,9 @@ importers: '@ai-sdk/anthropic': specifier: 3.0.71 version: 3.0.71(zod@4.4.3) + '@ai-sdk/devtools': + specifier: 0.0.17 + version: 0.0.17 '@ai-sdk/google-vertex': specifier: ^4.0.112 version: 4.0.118(zod@4.4.3) @@ -389,6 +392,11 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/devtools@0.0.17': + resolution: {integrity: sha512-CJgo+3DMHOJbxxq1qTgnW4vpFXgBW1pHePMimBW4Go5FPU7iLqppoGX/UC798IXqlD3hncQRPfyBLZjbsJC91w==} + engines: {node: '>=18'} + hasBin: true + '@ai-sdk/gateway@3.0.104': resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==} engines: {node: '>=18'} @@ -4576,6 +4584,12 @@ snapshots: '@ai-sdk/provider-utils': 4.0.26(zod@4.4.3) zod: 4.4.3 + '@ai-sdk/devtools@0.0.17': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@hono/node-server': 1.19.14(hono@4.12.15) + hono: 4.12.15 + '@ai-sdk/gateway@3.0.104(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 From d5f484eb7e624d04d46a5c9bce178daecbc61a06 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> Date: Tue, 12 May 2026 11:21:37 +0200 Subject: [PATCH 3/8] fix: standardize KTX environment variables --- .../orbit-relationship-verification/README.md | 2 +- .../memory-agent.service.ingest.test.ts | 24 ++++++++++++ .../src/memory/memory-agent.service.ts | 2 +- scripts/relationship-orbit-verification.mjs | 4 +- .../relationship-orbit-verification.test.mjs | 37 +++++++++++++++++++ 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/examples/orbit-relationship-verification/README.md b/examples/orbit-relationship-verification/README.md index 245411b6..126488a2 100644 --- a/examples/orbit-relationship-verification/README.md +++ b/examples/orbit-relationship-verification/README.md @@ -29,5 +29,5 @@ examples/orbit-relationship-verification/reports/orbit-verification.md Use a real local Orbit project by overriding the project directory: ```bash -KTX_ORBIT_PROJECT_DIR=/path/to/orbit-project pnpm run relationships:verify-orbit +KTX_PROJECT_DIR=/path/to/orbit-project pnpm run relationships:verify-orbit ``` diff --git a/packages/context/src/memory/memory-agent.service.ingest.test.ts b/packages/context/src/memory/memory-agent.service.ingest.test.ts index 710ba956..6375e494 100644 --- a/packages/context/src/memory/memory-agent.service.ingest.test.ts +++ b/packages/context/src/memory/memory-agent.service.ingest.test.ts @@ -37,6 +37,7 @@ interface BuiltMocks { agentRunner: any; slValidator: any; toolsetFactory: any; + logger: any; } const buildMocks = (overrides: Partial = {}): BuiltMocks => { @@ -131,6 +132,7 @@ const buildMocks = (overrides: Partial = {}): BuiltMocks => { getAllTools: vi.fn().mockReturnValue([]), }), }, + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, }; return { ...defaults, ...overrides }; @@ -179,6 +181,7 @@ const buildService = (mocks: BuiltMocks): MemoryAgentService => telemetry: { trackMemoryIngestion: mocks.eventTracker.trackEvent, }, + logger: mocks.logger, }); const baseInput = { @@ -238,6 +241,27 @@ describe('MemoryAgentService.ingest — session-branch orchestration', () => { expect(result.commitHash).toBe('cafebabe'); }); + it('logs prompt debug output when KTX_MEMORY_AGENT_DEBUG_PROMPTS is enabled', async () => { + const previousDebugPrompts = process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS; + const mocks = buildMocks(); + const svc = buildService(mocks); + + try { + process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = '1'; + + await svc.ingest(baseInput); + + expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] system=')); + expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] user=')); + } finally { + if (previousDebugPrompts === undefined) { + delete process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS; + } else { + process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = previousDebugPrompts; + } + } + }); + it('empty path: squash returns no touched paths → no enqueue, cleanup(empty), commitHash=null', async () => { const mocks = buildMocks(); mocks.gitService.squashMergeIntoMain.mockResolvedValue({ diff --git a/packages/context/src/memory/memory-agent.service.ts b/packages/context/src/memory/memory-agent.service.ts index fd1f0a6c..6f239053 100644 --- a/packages/context/src/memory/memory-agent.service.ts +++ b/packages/context/src/memory/memory-agent.service.ts @@ -192,7 +192,7 @@ export class MemoryAgentService { `[memory-agent] chat=${chatId} running (sourceType=${sourceType}, hasSL=${hasSL}, budget=${stepBudget}, model=${modelName})${signalsSuffix}${dialectSuffix}`, ); - if (process.env.MEMORY_AGENT_DEBUG_PROMPTS === '1') { + if (process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS === '1') { this.logger.debug(`[memory-agent prompt-debug] system=${systemPrompt}`); this.logger.debug(`[memory-agent prompt-debug] user=${prompt}`); } diff --git a/scripts/relationship-orbit-verification.mjs b/scripts/relationship-orbit-verification.mjs index 1c24a4e9..d1c97f56 100644 --- a/scripts/relationship-orbit-verification.mjs +++ b/scripts/relationship-orbit-verification.mjs @@ -62,7 +62,7 @@ function firstNonEmptyLine(...values) { function parseArgs(argv) { const options = { connectionId: process.env.KTX_ORBIT_CONNECTION_ID ?? 'orbit', - projectDir: process.env.KTX_ORBIT_PROJECT_DIR ?? defaultProjectDir, + projectDir: process.env.KTX_PROJECT_DIR ?? defaultProjectDir, reportPath: defaultReportPath, }; @@ -242,7 +242,7 @@ function orbitVerificationEnv(projectDir) { export async function runOrbitVerification(options = {}) { const connectionId = options.connectionId ?? process.env.KTX_ORBIT_CONNECTION_ID ?? 'orbit'; - const projectDir = options.projectDir ?? process.env.KTX_ORBIT_PROJECT_DIR ?? defaultProjectDir; + const projectDir = options.projectDir ?? process.env.KTX_PROJECT_DIR ?? defaultProjectDir; const reportPath = options.reportPath ?? defaultReportPath; const rootDir = options.rootDir ?? ktxRootDir; const runner = options.runWorkspaceKtx ?? runWorkspaceKtx; diff --git a/scripts/relationship-orbit-verification.test.mjs b/scripts/relationship-orbit-verification.test.mjs index c7cdaffc..017b2518 100644 --- a/scripts/relationship-orbit-verification.test.mjs +++ b/scripts/relationship-orbit-verification.test.mjs @@ -115,6 +115,43 @@ describe('relationship Orbit verification helper', () => { assert.match(writes[0].content, new RegExp(defaultProjectDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); }); + it('uses KTX_PROJECT_DIR for the Orbit verification project override', async () => { + const previousProjectDir = process.env.KTX_PROJECT_DIR; + const calls = []; + + try { + process.env.KTX_PROJECT_DIR = '/tmp/orbit-project-from-env'; + + const result = await runOrbitVerification({ + reportPath: '/tmp/orbit-report.md', + now: () => new Date('2026-05-07T10:00:00.000Z'), + mkdir: async () => {}, + writeFile: async () => {}, + runWorkspaceKtx: async (argv, options) => { + calls.push(argv); + if (argv[2] === 'report') { + options.stdout.write(successReportJson()); + return 0; + } + options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n'); + return 0; + }, + }); + + assert.equal(result.projectDir, '/tmp/orbit-project-from-env'); + assert.deepEqual(calls, [ + ['dev', 'scan', 'orbit', '--enrich', '--project-dir', '/tmp/orbit-project-from-env'], + ['dev', 'scan', 'report', '--json', '--project-dir', '/tmp/orbit-project-from-env', 'scan-orbit-1'], + ]); + } finally { + if (previousProjectDir === undefined) { + delete process.env.KTX_PROJECT_DIR; + } else { + process.env.KTX_PROJECT_DIR = previousProjectDir; + } + } + }); + it('extracts the run id from human scan output', () => { assert.equal(extractRunId(`KTX scan completed\nStatus: done\nRun: scan-orbit-1\nConnection: orbit\n`), 'scan-orbit-1'); assert.equal(extractRunId('KTX scan completed without a run line\n'), null); From 4c93a6e983c9e5241202f1c9adc568243d7900ef Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 12 May 2026 12:02:26 +0200 Subject: [PATCH 4/8] fix(ci): update stale KTX test expectations (#32) Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> --- packages/cli/src/demo.test.ts | 10 +- packages/cli/src/standalone-smoke.test.ts | 16 +-- scripts/examples-docs.test.mjs | 129 ++++++++++++---------- 3 files changed, 86 insertions(+), 69 deletions(-) diff --git a/packages/cli/src/demo.test.ts b/packages/cli/src/demo.test.ts index 0cedba99..5c9fa7ac 100644 --- a/packages/cli/src/demo.test.ts +++ b/packages/cli/src/demo.test.ts @@ -336,8 +336,8 @@ describe('runKtxDemo', () => { notion: { pageCount: 8 }, }, generatedOutputs: { - semanticLayer: { manifestSourceCount: 6, fileCount: 6 }, - knowledge: { manifestPageCount: 10, fileCount: 10 }, + semanticLayer: { manifestSourceCount: 46, fileCount: 46 }, + knowledge: { manifestPageCount: 28, fileCount: 28 }, links: { manifestLinkCount: 23, linkCount: 23 }, reports: { primaryPath: 'reports/seeded-demo-report.json', fileCount: 1 }, }, @@ -636,10 +636,10 @@ describe('runKtxDemo', () => { ).resolves.toBe(0); expect(seededIo.stdout()).toContain('Status: ready'); - expect(seededIo.stdout()).toContain('Semantic-layer sources: 6 manifest, 6 files'); - expect(seededIo.stdout()).toContain('Knowledge pages: 10 manifest, 10 files'); + expect(seededIo.stdout()).toContain('Semantic-layer sources: 46 manifest, 46 files'); + expect(seededIo.stdout()).toContain('Knowledge pages: 28 manifest, 28 files'); expect(seededIo.stdout()).not.toContain('Status: corrupt'); - expect(seededIo.stdout()).not.toContain('Semantic-layer sources: 6 manifest, 0 files'); + expect(seededIo.stdout()).not.toContain('Semantic-layer sources: 46 manifest, 0 files'); }); it('fails corrupted demo projects in no-input mode with reset guidance', async () => { diff --git a/packages/cli/src/standalone-smoke.test.ts b/packages/cli/src/standalone-smoke.test.ts index 27f34b92..a7b6c049 100644 --- a/packages/cli/src/standalone-smoke.test.ts +++ b/packages/cli/src/standalone-smoke.test.ts @@ -370,7 +370,7 @@ describe('standalone built ktx CLI smoke', () => { totalFound: number; }>(await client.callTool({ name: 'knowledge_search', arguments: { query: 'ARR contract', limit: 5 } })); expect(knowledgeSearch.totalFound).toBeGreaterThan(0); - expect(knowledgeSearch.results.map((result) => result.key)).toContain('arr-contract-first'); + expect(knowledgeSearch.results.map((result) => result.key)).toContain('orbit-arr-contract-first-definition'); const knowledgeRead = structuredContent<{ key: string; @@ -378,26 +378,26 @@ describe('standalone built ktx CLI smoke', () => { content: string; tags: string[]; slRefs: string[]; - }>(await client.callTool({ name: 'knowledge_read', arguments: { key: 'arr-contract-first' } })); - expect(knowledgeRead.key).toBe('arr-contract-first'); + }>(await client.callTool({ name: 'knowledge_read', arguments: { key: 'orbit-arr-contract-first-definition' } })); + expect(knowledgeRead.key).toBe('orbit-arr-contract-first-definition'); expect(knowledgeRead.summary).toContain('ARR'); expect(knowledgeRead.content).toContain('contract'); - expect(knowledgeRead.slRefs).toContain('orbit_demo.contracts'); + expect(knowledgeRead.slRefs).toContain('mart_arr_daily'); const slRead = structuredContent<{ sourceName: string; yaml: string }>( await client.callTool({ name: 'sl_read_source', - arguments: { connectionId: 'orbit_demo', sourceName: 'accounts' }, + arguments: { connectionId: 'postgres-warehouse', sourceName: 'mart_arr_daily' }, }), ); - expect(slRead.sourceName).toBe('accounts'); - expect(slRead.yaml).toContain('name: accounts'); + expect(slRead.sourceName).toBe('mart_arr_daily'); + expect(slRead.yaml).toContain('name: mart_arr_daily'); expect(slRead.yaml).toContain('measures:'); const slValidate = structuredContent<{ success: boolean; errors: string[]; warnings: string[] }>( await client.callTool({ name: 'sl_validate', - arguments: { connectionId: 'orbit_demo', names: ['accounts', 'contracts'] }, + arguments: { connectionId: 'postgres-warehouse', names: ['mart_arr_daily'] }, }), ); expect(slValidate.success).toBe(true); diff --git a/scripts/examples-docs.test.mjs b/scripts/examples-docs.test.mjs index 24c83452..81c42b9c 100644 --- a/scripts/examples-docs.test.mjs +++ b/scripts/examples-docs.test.mjs @@ -135,72 +135,86 @@ describe('standalone example docs', () => { assert.doesNotMatch(readme, /--historic-sql-min-calls/); }); - it('lists every published TypeScript package in the package root README', async () => { - const rootReadme = await readText('README.md'); + it('lists every workspace package in the contributor docs', async () => { + const contributing = await readText('docs-site/content/docs/community/contributing.mdx'); - assert.match(rootReadme, /`packages\/context`/); - assert.match(rootReadme, /`packages\/cli`/); - assert.match(rootReadme, /`packages\/connector-bigquery`/); - assert.match(rootReadme, /`packages\/connector-clickhouse`/); - assert.match(rootReadme, /`packages\/connector-mysql`/); - assert.match(rootReadme, /`packages\/connector-postgres`/); - assert.match(rootReadme, /`packages\/connector-snowflake`/); - assert.match(rootReadme, /`packages\/connector-sqlite`/); - assert.match(rootReadme, /`packages\/connector-sqlserver`/); - assert.match(rootReadme, /`python\/ktx-sl`/); - assert.match(rootReadme, /`python\/ktx-daemon`/); + assert.match(contributing, /cli\/\s+# CLI entry point/); + assert.match(contributing, /context\/\s+# Core context engine/); + assert.match(contributing, /llm\/\s+# LLM client abstraction/); + assert.match(contributing, /connector-bigquery\/\s+# BigQuery connector/); + assert.match(contributing, /connector-clickhouse\/\s+# ClickHouse connector/); + assert.match(contributing, /connector-mysql\/\s+# MySQL connector/); + assert.match(contributing, /connector-postgres\/\s+# PostgreSQL connector/); + assert.match(contributing, /connector-snowflake\/\s+# Snowflake connector/); + assert.match(contributing, /connector-sqlite\/\s+# SQLite connector/); + assert.match(contributing, /connector-sqlserver\/\s+# SQL Server connector/); + assert.match(contributing, /ktx-sl\/\s+# Semantic layer/); + assert.match(contributing, /ktx-daemon\/\s+# Daemon/); }); it('documents every standalone MCP tool that the CLI server exposes', async () => { - const rootReadme = await readText('README.md'); + const servingAgents = await readText('docs-site/content/docs/guides/serving-agents.mdx'); - assert.match(rootReadme, /`connection_list`/); - assert.match(rootReadme, /`knowledge_search`/); - assert.match(rootReadme, /`knowledge_read`/); - assert.match(rootReadme, /`knowledge_write`/); - assert.match(rootReadme, /`sl_list_sources`/); - assert.match(rootReadme, /`sl_read_source`/); - assert.match(rootReadme, /`sl_write_source`/); - assert.match(rootReadme, /`sl_validate`/); - assert.match(rootReadme, /`sl_query`/); - assert.match(rootReadme, /`ingest_trigger`/); - assert.match(rootReadme, /`ingest_status`/); - assert.match(rootReadme, /`ingest_report`/); - assert.match(rootReadme, /`ingest_replay`/); + for (const tool of [ + 'connection_list', + 'connection_test', + 'knowledge_search', + 'knowledge_read', + 'knowledge_write', + 'sl_list_sources', + 'sl_read_source', + 'sl_write_source', + 'sl_validate', + 'sl_query', + 'scan_trigger', + 'scan_status', + 'scan_report', + 'scan_list_artifacts', + 'scan_read_artifact', + 'ingest_trigger', + 'ingest_status', + 'ingest_report', + 'ingest_replay', + 'memory_capture', + 'memory_capture_status', + ]) { + assert.match(servingAgents, new RegExp(`\`${tool}\``)); + } }); - it('walks through ktx connection list and ktx connection test in the README quickstart', async () => { - const rootReadme = await readText('README.md'); + it('walks through connection testing in the quickstart and CLI reference', async () => { + const quickstart = await readText('docs-site/content/docs/getting-started/quickstart.mdx'); + const connectionReference = await readText('docs-site/content/docs/cli-reference/ktx-connection.mdx'); - assert.match(rootReadme, /connection list --project-dir/); - assert.match(rootReadme, /connection test warehouse --project-dir/); - assert.match(rootReadme, /Driver: sqlite/); - assert.match(rootReadme, /Tables: 1/); + assert.match(connectionReference, /ktx connection list/); + assert.match(connectionReference, /ktx connection test my-warehouse/); + assert.match(quickstart, /Connection test passed/); + assert.match(quickstart, /Driver: PostgreSQL .* Tables: 42/); }); - it('documents public npm and managed runtime usage in the README', async () => { + it('documents public npm and managed runtime usage', async () => { const rootReadme = await readText('README.md'); + const quickstart = await readText('docs-site/content/docs/getting-started/quickstart.mdx'); + const packageArtifacts = await readText('examples/package-artifacts/README.md'); - assert.match(rootReadme, publicPackagePattern('npx {package} setup demo --no-input')); - assert.match(rootReadme, publicPackagePattern('npx {package} sl query')); - assert.match(rootReadme, publicPackagePattern('npm install {package}')); assert.match(rootReadme, publicPackagePattern('npm install -g {package}')); - assert.match(rootReadme, /ktx runtime install/); - assert.match(rootReadme, /ktx runtime status/); - assert.match(rootReadme, /ktx runtime doctor/); - assert.match(rootReadme, /ktx runtime start/); - assert.match(rootReadme, /ktx runtime stop/); - assert.match(rootReadme, /ktx runtime prune --dry-run/); - assert.match(rootReadme, /ktx runtime prune --yes/); - assert.match(rootReadme, /KTX requires `uv` on `PATH`/); - assert.match(rootReadme, /KTX doesn't download `uv` automatically/); + assert.match(quickstart, publicPackagePattern('npm install -g {package}')); + assert.match(quickstart, /ktx runtime install --feature local-embeddings --yes/); + assert.match(quickstart, /ktx runtime start --feature local-embeddings/); + assert.match(quickstart, /Install `uv`, run `ktx runtime doctor`/); + assert.match(packageArtifacts, /requires `uv` on `PATH`/); + assert.match(packageArtifacts, /ktx runtime status/); + assert.match(packageArtifacts, /ktx runtime doctor/); + assert.match(packageArtifacts, /ktx runtime prune --dry-run/); + assert.match(packageArtifacts, /ktx runtime prune --yes/); assert.match( - rootReadme, - runtimeWheelPackagePattern( - 'release\\s+artifact manifest contains the public npm tarball and the\\s+bundled `{package}`\\s+runtime wheel', + packageArtifacts, + new RegExp( + `artifact manifest contains the public \`${escapeRegExp(publicNpmPackageName())}\` npm tarball and the\\s+bundled \`${escapeRegExp( + runtimeWheelPackageName(), + )}\` runtime wheel`, ), ); - assert.match(rootReadme, /source packages for\s+development, not public release artifacts/); assert.match(rootReadme, /ktx serve --mcp stdio/); assert.doesNotMatch(rootReadme, /uv run ktx-daemon serve-http/); assert.doesNotMatch(rootReadme, /--semantic-compute-url http:\/\/127\.0\.0\.1:8765/); @@ -232,14 +246,17 @@ describe('standalone example docs', () => { assert.doesNotMatch(readme, /python -m ktx_daemon semantic-validate/); }); - it('replaces the fake-ingest smoke with a ktx scan walkthrough in the README', async () => { + it('documents scan workflows in the docs site', async () => { const rootReadme = await readText('README.md'); + const buildingContext = await readText('docs-site/content/docs/guides/building-context.mdx'); + const scanReference = await readText('docs-site/content/docs/cli-reference/ktx-scan.mdx'); - assert.match(rootReadme, /### Scan the demo warehouse/); - assert.match(rootReadme, /scan warehouse --project-dir/); - assert.match(rootReadme, /scan status --project-dir/); - assert.match(rootReadme, /scan report --project-dir/); - assert.match(rootReadme, /raw-sources\/warehouse\/live-database/); + assert.match(buildingContext, /ktx dev scan /); + assert.match(buildingContext, /ktx dev scan status /); + assert.match(buildingContext, /ktx dev scan report /); + assert.match(scanReference, /ktx dev scan \[options\]/); + assert.match(rootReadme, /raw-sources\//); + assert.match(rootReadme, /live-database\//); assert.doesNotMatch(rootReadme, /Run a local ingest smoke test/); assert.doesNotMatch(rootReadme, /ktx dev ingest run --project-dir/); assert.doesNotMatch(rootReadme, /ktx ingest status --project-dir/); From d830e8c46e33ce7b0b2c888d12039a499ef07333 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> Date: Tue, 12 May 2026 12:24:25 +0200 Subject: [PATCH 5/8] docs: standardize env variable examples --- .../content/docs/integrations/primary-sources.mdx | 2 +- packages/cli/src/connection.test.ts | 6 +++--- packages/cli/src/index.test.ts | 4 ++-- packages/cli/src/standalone-smoke.test.ts | 6 +++--- .../context/src/connections/notion-config.test.ts | 14 +++++++------- .../context/src/ingest/local-stage-ingest.test.ts | 10 +++++----- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs-site/content/docs/integrations/primary-sources.mdx b/docs-site/content/docs/integrations/primary-sources.mdx index c36260d1..dcfd143f 100644 --- a/docs-site/content/docs/integrations/primary-sources.mdx +++ b/docs-site/content/docs/integrations/primary-sources.mdx @@ -213,7 +213,7 @@ For multiple datasets: | Method | Config | |--------|--------| | Service account JSON | `credentials_json: file:/path/to/key.json` | -| Environment variable | `credentials_json: env:GCP_CREDENTIALS_JSON` | +| Environment variable | `credentials_json: env:BIGQUERY_CREDENTIALS_JSON` | The project ID is extracted automatically from the service account JSON file. diff --git a/packages/cli/src/connection.test.ts b/packages/cli/src/connection.test.ts index ae593805..04c73cf1 100644 --- a/packages/cli/src/connection.test.ts +++ b/packages/cli/src/connection.test.ts @@ -477,7 +477,7 @@ describe('runKtxConnection', () => { force: false, allowLiteralCredentials: false, notion: { - authTokenRef: 'env:NOTION_AUTH_TOKEN', + authTokenRef: 'env:NOTION_TOKEN', crawlMode: 'all_accessible', rootPageIds: [], rootDatabaseIds: [], @@ -493,7 +493,7 @@ describe('runKtxConnection', () => { const yaml = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'); expect(yaml).toContain('driver: notion'); - expect(yaml).toContain('auth_token_ref: env:NOTION_AUTH_TOKEN'); + expect(yaml).toContain('auth_token_ref: env:NOTION_TOKEN'); expect(yaml).toContain('crawl_mode: all_accessible'); expect(yaml).toContain('max_pages_per_run: 50'); expect(yaml).not.toContain('ntn_'); @@ -516,7 +516,7 @@ describe('runKtxConnection', () => { force: false, allowLiteralCredentials: false, notion: { - authTokenRef: 'env:NOTION_AUTH_TOKEN', + authTokenRef: 'env:NOTION_TOKEN', crawlMode: 'all_accessible', rootPageIds: [], rootDatabaseIds: ['database-1'], diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index a575eeed..8bc2a3a6 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -1964,7 +1964,7 @@ describe('runKtxCli', () => { '--project-dir', tempDir, '--token-env', - 'NOTION_AUTH_TOKEN', + 'NOTION_TOKEN', '--crawl-mode', 'selected_roots', '--root-page-id', @@ -1991,7 +1991,7 @@ describe('runKtxCli', () => { force: false, allowLiteralCredentials: false, notion: { - authTokenRef: 'env:NOTION_AUTH_TOKEN', + authTokenRef: 'env:NOTION_TOKEN', crawlMode: 'selected_roots', rootPageIds: ['page-1'], rootDatabaseIds: ['database-1'], diff --git a/packages/cli/src/standalone-smoke.test.ts b/packages/cli/src/standalone-smoke.test.ts index 27f34b92..b1b30534 100644 --- a/packages/cli/src/standalone-smoke.test.ts +++ b/packages/cli/src/standalone-smoke.test.ts @@ -716,7 +716,7 @@ describe('standalone built ktx CLI smoke', () => { '--project-dir', projectDir, '--token-env', - 'NOTION_AUTH_TOKEN', + 'NOTION_TOKEN', '--crawl-mode', 'all_accessible', '--max-pages', @@ -729,7 +729,7 @@ describe('standalone built ktx CLI smoke', () => { const yaml = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'); expect(yaml).toContain('driver: notion'); - expect(yaml).toContain('auth_token_ref: env:NOTION_AUTH_TOKEN'); + expect(yaml).toContain('auth_token_ref: env:NOTION_TOKEN'); expect(yaml).toContain('crawl_mode: all_accessible'); expect(yaml).toContain('max_pages_per_run: 5'); expect(yaml).not.toContain('ntn_'); @@ -737,7 +737,7 @@ describe('standalone built ktx CLI smoke', () => { const parsed = parseKtxProjectConfig(yaml); expect(parsed.connections['notion-main']).toMatchObject({ driver: 'notion', - auth_token_ref: 'env:NOTION_AUTH_TOKEN', + auth_token_ref: 'env:NOTION_TOKEN', crawl_mode: 'all_accessible', }); }); diff --git a/packages/context/src/connections/notion-config.test.ts b/packages/context/src/connections/notion-config.test.ts index 33d1e110..8ad88c86 100644 --- a/packages/context/src/connections/notion-config.test.ts +++ b/packages/context/src/connections/notion-config.test.ts @@ -23,14 +23,14 @@ describe('standalone Notion connection config', () => { it('parses selected-root Notion config with safe defaults', () => { const parsed = parseNotionConnectionConfig({ driver: 'notion', - auth_token_ref: 'env:NOTION_AUTH_TOKEN', + auth_token_ref: 'env:NOTION_TOKEN', crawl_mode: 'selected_roots', root_page_ids: ['page-1'], }); expect(parsed).toEqual({ driver: 'notion', - auth_token_ref: 'env:NOTION_AUTH_TOKEN', + auth_token_ref: 'env:NOTION_TOKEN', crawl_mode: 'selected_roots', root_page_ids: ['page-1'], root_database_ids: [], @@ -70,7 +70,7 @@ describe('standalone Notion connection config', () => { expect(() => parseNotionConnectionConfig({ driver: 'notion', - auth_token_ref: 'env:NOTION_AUTH_TOKEN', + auth_token_ref: 'env:NOTION_TOKEN', crawl_mode: 'selected_roots', }), ).toThrow('selected_roots requires at least one root page, database, or data source id'); @@ -81,8 +81,8 @@ describe('standalone Notion connection config', () => { await writeFile(tokenPath, 'ntn_file_token\n', 'utf-8'); await expect( - resolveNotionAuthToken('env:NOTION_AUTH_TOKEN', { - env: { NOTION_AUTH_TOKEN: 'ntn_env_token' }, + resolveNotionAuthToken('env:NOTION_TOKEN', { + env: { NOTION_TOKEN: 'ntn_env_token' }, }), ).resolves.toBe('ntn_env_token'); await expect(resolveNotionAuthToken(`file:${tokenPath}`)).resolves.toBe('ntn_file_token'); @@ -95,14 +95,14 @@ describe('standalone Notion connection config', () => { const pullConfig = await notionConnectionToPullConfig( parseNotionConnectionConfig({ driver: 'notion', - auth_token_ref: 'env:NOTION_AUTH_TOKEN', + auth_token_ref: 'env:NOTION_TOKEN', crawl_mode: 'all_accessible', max_pages_per_run: 12, max_knowledge_creates_per_run: 2, max_knowledge_updates_per_run: 7, last_successful_cursor: '{"phase":"all_accessible_pages","cursor":"cursor-1"}', }), - { env: { NOTION_AUTH_TOKEN: 'ntn_env_token' } }, + { env: { NOTION_TOKEN: 'ntn_env_token' } }, ); expect(pullConfig).toEqual({ diff --git a/packages/context/src/ingest/local-stage-ingest.test.ts b/packages/context/src/ingest/local-stage-ingest.test.ts index e24174fb..157bd96b 100644 --- a/packages/context/src/ingest/local-stage-ingest.test.ts +++ b/packages/context/src/ingest/local-stage-ingest.test.ts @@ -569,8 +569,8 @@ describe('local ingest', () => { }); it('passes resolved standalone Notion config into fetch adapters', async () => { - const priorToken = process.env.NOTION_AUTH_TOKEN; - process.env.NOTION_AUTH_TOKEN = 'ntn_local_test_token'; + const priorToken = process.env.NOTION_TOKEN; + process.env.NOTION_TOKEN = 'ntn_local_test_token'; try { await writeFile( join(project.projectDir, 'ktx.yaml'), @@ -579,7 +579,7 @@ describe('local ingest', () => { 'connections:', ' notion-main:', ' driver: notion', - ' auth_token_ref: env:NOTION_AUTH_TOKEN', + ' auth_token_ref: env:NOTION_TOKEN', ' crawl_mode: selected_roots', ' root_page_ids:', ' - page-1', @@ -666,9 +666,9 @@ describe('local ingest', () => { }); } finally { if (priorToken === undefined) { - delete process.env.NOTION_AUTH_TOKEN; + delete process.env.NOTION_TOKEN; } else { - process.env.NOTION_AUTH_TOKEN = priorToken; + process.env.NOTION_TOKEN = priorToken; } } }); From 085f68beec89f66e2a38665a66988daefd6b292b Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> Date: Tue, 12 May 2026 12:26:19 +0200 Subject: [PATCH 6/8] docs: refresh KTX demo readiness guidance --- README.md | 89 ++++++++++++++++++++++- packages/cli/src/demo.test.ts | 25 +++++-- packages/cli/src/standalone-smoke.test.ts | 18 ++--- 3 files changed, 117 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 84592226..696558a5 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ SQLite. Install the CLI and run the setup wizard: ```bash +npm install @kaelio/ktx npm install -g @kaelio/ktx ktx setup ``` @@ -70,6 +71,40 @@ KTX context built: yes Agent integration ready: yes (claude-code:project) ``` +Run the packaged demo without installing globally: + +```bash +npx @kaelio/ktx setup demo --no-input +npx @kaelio/ktx setup demo inspect +``` + +The default demo uses packaged sample data and prebuilt context. It does not +require API keys, network access, or an LLM provider. + +Generate SQL from a semantic-layer source: + +```bash +npx @kaelio/ktx sl query --project-dir "$PROJECT_DIR" \ + --connection-id warehouse \ + --measure accounts.account_count \ + --dimension accounts.segment \ + --format sql +``` + +List and test a configured warehouse connection: + +```bash +ktx connection list --project-dir "$PROJECT_DIR" +ktx connection test warehouse --project-dir "$PROJECT_DIR" +``` + +The connection test prints the configured driver and discovered table count: + +```text +Driver: sqlite +Tables: 1 +``` + ## What's in a project ``` @@ -97,6 +132,47 @@ Semantic sources and knowledge pages are committed to git. The `.ktx/` directory holds ephemeral state and is git-ignored — delete it and KTX rebuilds on the next run. +### Scan the demo warehouse + +Scan artifacts are written under +`raw-sources/warehouse/live-database//` in the project directory. + +```bash +SCAN_OUTPUT="$(ktx scan warehouse --project-dir "$PROJECT_DIR")" +printf '%s\n' "$SCAN_OUTPUT" +SCAN_RUN_ID="$(printf '%s\n' "$SCAN_OUTPUT" | awk '/^Run: / { print $2 }')" +ktx scan status --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID" +ktx scan report --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID" +``` + +For non-SQLite drivers, prefer credential references such as `--url env:NAME` +or `--url file:PATH` over literal credential URLs. + +## Managed Python runtime + +KTX installs its Python runtime only when a Python-backed command needs it. +The runtime lives outside the npm cache, is versioned by the installed CLI +version, and is managed by `ktx runtime` commands. + +KTX requires `uv` on `PATH` to create the managed runtime. Install `uv` with +your system package manager or the official installer before running Python- +backed KTX commands. KTX doesn't download `uv` automatically; run +`ktx runtime doctor` if runtime installation fails: + +```bash +ktx runtime install --yes +ktx runtime status +ktx runtime doctor +ktx runtime start +ktx runtime stop +ktx runtime prune --dry-run +ktx runtime prune --yes +``` + +The release artifact manifest contains the public npm tarball and the bundled `kaelio-ktx` +runtime wheel. The `python/ktx-sl` and `python/ktx-daemon` directories remain +source packages for development, not public release artifacts. + ## Serve agents KTX integrates with coding agents through CLI skills, an MCP server, or both. @@ -126,6 +202,11 @@ This exposes tools for connections, knowledge search, semantic-layer sources, validation, queries, ingestion, and replay. The `--semantic-compute` flag starts the managed Python runtime for query planning automatically. +The standalone MCP server exposes `connection_list`, `knowledge_search`, +`knowledge_read`, `knowledge_write`, `sl_list_sources`, `sl_read_source`, +`sl_write_source`, `sl_validate`, `sl_query`, `ingest_trigger`, +`ingest_status`, `ingest_report`, and `ingest_replay`. + Supported agents: Claude Code, Codex, Cursor, OpenCode, and any agent that reads `.agents/` skills or MCP configuration. @@ -136,7 +217,13 @@ reads `.agents/` skills or MCP configuration. | `packages/cli` | CLI entry point | | `packages/context` | Core context engine | | `packages/llm` | LLM and embedding providers | -| `packages/connector-*` | Database connectors (Postgres, Snowflake, BigQuery, ClickHouse, MySQL, SQL Server, SQLite) | +| `packages/connector-bigquery` | BigQuery scan connector | +| `packages/connector-clickhouse` | ClickHouse scan connector | +| `packages/connector-mysql` | MySQL scan connector | +| `packages/connector-postgres` | Postgres scan connector | +| `packages/connector-snowflake` | Snowflake scan connector | +| `packages/connector-sqlite` | SQLite scan connector | +| `packages/connector-sqlserver` | SQL Server scan connector | | `python/ktx-sl` | Semantic-layer query planning | | `python/ktx-daemon` | Portable compute service | diff --git a/packages/cli/src/demo.test.ts b/packages/cli/src/demo.test.ts index 0cedba99..0b053ee6 100644 --- a/packages/cli/src/demo.test.ts +++ b/packages/cli/src/demo.test.ts @@ -11,6 +11,9 @@ import type { renderMemoryFlowTui } from './memory-flow-tui.js'; import { KTX_NEXT_STEP_COMMANDS } from './next-steps.js'; import { resetVizFallbackWarningsForTest } from './viz-fallback.js'; +const SEEDED_DEMO_SEMANTIC_SOURCE_COUNT = 46; +const SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT = 28; + function makeIo(options: { isTTY?: boolean; columns?: number; rawMode?: boolean } = {}) { let stdout = ''; let stderr = ''; @@ -336,8 +339,14 @@ describe('runKtxDemo', () => { notion: { pageCount: 8 }, }, generatedOutputs: { - semanticLayer: { manifestSourceCount: 6, fileCount: 6 }, - knowledge: { manifestPageCount: 10, fileCount: 10 }, + semanticLayer: { + manifestSourceCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT, + fileCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT, + }, + knowledge: { + manifestPageCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT, + fileCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT, + }, links: { manifestLinkCount: 23, linkCount: 23 }, reports: { primaryPath: 'reports/seeded-demo-report.json', fileCount: 1 }, }, @@ -636,10 +645,16 @@ describe('runKtxDemo', () => { ).resolves.toBe(0); expect(seededIo.stdout()).toContain('Status: ready'); - expect(seededIo.stdout()).toContain('Semantic-layer sources: 6 manifest, 6 files'); - expect(seededIo.stdout()).toContain('Knowledge pages: 10 manifest, 10 files'); + expect(seededIo.stdout()).toContain( + `Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} files`, + ); + expect(seededIo.stdout()).toContain( + `Knowledge pages: ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} manifest, ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} files`, + ); expect(seededIo.stdout()).not.toContain('Status: corrupt'); - expect(seededIo.stdout()).not.toContain('Semantic-layer sources: 6 manifest, 0 files'); + expect(seededIo.stdout()).not.toContain( + `Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, 0 files`, + ); }); it('fails corrupted demo projects in no-input mode with reset guidance', async () => { diff --git a/packages/cli/src/standalone-smoke.test.ts b/packages/cli/src/standalone-smoke.test.ts index 27f34b92..0b15410c 100644 --- a/packages/cli/src/standalone-smoke.test.ts +++ b/packages/cli/src/standalone-smoke.test.ts @@ -368,9 +368,9 @@ describe('standalone built ktx CLI smoke', () => { const knowledgeSearch = structuredContent<{ results: Array<{ key: string; summary: string; score: number }>; totalFound: number; - }>(await client.callTool({ name: 'knowledge_search', arguments: { query: 'ARR contract', limit: 5 } })); + }>(await client.callTool({ name: 'knowledge_search', arguments: { query: 'ARR contract-first definition', limit: 10 } })); expect(knowledgeSearch.totalFound).toBeGreaterThan(0); - expect(knowledgeSearch.results.map((result) => result.key)).toContain('arr-contract-first'); + expect(knowledgeSearch.results.map((result) => result.key)).toContain('orbit-arr-contract-first-definition'); const knowledgeRead = structuredContent<{ key: string; @@ -378,26 +378,26 @@ describe('standalone built ktx CLI smoke', () => { content: string; tags: string[]; slRefs: string[]; - }>(await client.callTool({ name: 'knowledge_read', arguments: { key: 'arr-contract-first' } })); - expect(knowledgeRead.key).toBe('arr-contract-first'); + }>(await client.callTool({ name: 'knowledge_read', arguments: { key: 'orbit-arr-contract-first-definition' } })); + expect(knowledgeRead.key).toBe('orbit-arr-contract-first-definition'); expect(knowledgeRead.summary).toContain('ARR'); expect(knowledgeRead.content).toContain('contract'); - expect(knowledgeRead.slRefs).toContain('orbit_demo.contracts'); + expect(knowledgeRead.slRefs).toContain('mart_arr_daily'); const slRead = structuredContent<{ sourceName: string; yaml: string }>( await client.callTool({ name: 'sl_read_source', - arguments: { connectionId: 'orbit_demo', sourceName: 'accounts' }, + arguments: { connectionId: 'dbt-main', sourceName: 'mart_arr_daily' }, }), ); - expect(slRead.sourceName).toBe('accounts'); - expect(slRead.yaml).toContain('name: accounts'); + expect(slRead.sourceName).toBe('mart_arr_daily'); + expect(slRead.yaml).toContain('name: mart_arr_daily'); expect(slRead.yaml).toContain('measures:'); const slValidate = structuredContent<{ success: boolean; errors: string[]; warnings: string[] }>( await client.callTool({ name: 'sl_validate', - arguments: { connectionId: 'orbit_demo', names: ['accounts', 'contracts'] }, + arguments: { connectionId: 'dbt-main', names: ['mart_arr_daily', 'stg_contracts'] }, }), ); expect(slValidate.success).toBe(true); From 69e546678facbb9ab1bc1742c450e8d7ca9f9790 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 12 May 2026 12:56:46 +0200 Subject: [PATCH 7/8] docs: replace README logo with KTX lockup --- README.md | 2 +- assets/ktx-lockup.svg | 32 ++++++++++++++++++++++++++++++++ assets/ktx-readme-header.png | Bin 29179 -> 0 bytes 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 assets/ktx-lockup.svg delete mode 100644 assets/ktx-readme-header.png diff --git a/README.md b/README.md index 84592226..f8718542 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- KTX + KTX

diff --git a/assets/ktx-lockup.svg b/assets/ktx-lockup.svg new file mode 100644 index 00000000..f1bcd2dd --- /dev/null +++ b/assets/ktx-lockup.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + ktx + diff --git a/assets/ktx-readme-header.png b/assets/ktx-readme-header.png deleted file mode 100644 index 11cfb4e47be1693a4eaa3b08bb5221aa77bfe342..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29179 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 P|EsR5qf(~y>cjs7y4@6% From 36c3f93ad7fb1aeca641959835efc430f744ba2f Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 12 May 2026 13:00:08 +0200 Subject: [PATCH 8/8] feat(cli): add reliable runtime stop --all (#30) * feat(cli): add runtime stop all * test(cli): avoid Metabase secret fixture path collision --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> --- .../connection-metabase-setup.test.ts | 2 +- packages/cli/src/commands/runtime-commands.ts | 4 +- packages/cli/src/index.test.ts | 27 +- packages/cli/src/index.ts | 5 + .../cli/src/managed-python-daemon.test.ts | 154 +++++++ packages/cli/src/managed-python-daemon.ts | 435 +++++++++++++++++- packages/cli/src/runtime.test.ts | 53 ++- packages/cli/src/runtime.ts | 71 ++- 8 files changed, 734 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/connection-metabase-setup.test.ts b/packages/cli/src/commands/connection-metabase-setup.test.ts index cd94565a..cf7308d7 100644 --- a/packages/cli/src/commands/connection-metabase-setup.test.ts +++ b/packages/cli/src/commands/connection-metabase-setup.test.ts @@ -138,7 +138,7 @@ function makeIo(options: { isTTY?: boolean; stdinIsTTY?: boolean } = {}) { describe('runKtxConnectionMetabaseSetup', () => { const fakeMetabaseCredential = 'mb_example'; const existingMetabaseCredential = 'mb_existing'; - const fakeAdminCredential = 'pw'; + const fakeAdminCredential = 'admin-secret-value-123'; let tempDir: string; let projectDir: string; diff --git a/packages/cli/src/commands/runtime-commands.ts b/packages/cli/src/commands/runtime-commands.ts index 8f478658..3ce7d9ba 100644 --- a/packages/cli/src/commands/runtime-commands.ts +++ b/packages/cli/src/commands/runtime-commands.ts @@ -53,10 +53,12 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand runtime .command('stop') .description('Stop the KTX-managed Python HTTP daemon') - .action(async () => { + .option('--all', 'Stop all KTX daemon processes recorded or discoverable on this machine', false) + .action(async (options: { all?: boolean }) => { await runRuntimeArgs(context, { command: 'stop', cliVersion: context.packageInfo.version, + all: options.all === true, }); }); diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index 8bc2a3a6..4a45274b 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -143,6 +143,7 @@ describe('runKtxCli', () => { const installIo = makeIo(); const startIo = makeIo(); const stopIo = makeIo(); + const stopAllIo = makeIo(); const statusIo = makeIo(); const doctorIo = makeIo(); const pruneIo = makeIo(); @@ -156,6 +157,7 @@ describe('runKtxCli', () => { runKtxCli(['runtime', 'start', '--feature', 'local-embeddings', '--force'], startIo.io, { runtime }), ).resolves.toBe(0); await expect(runKtxCli(['runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0); + await expect(runKtxCli(['runtime', 'stop', '--all'], stopAllIo.io, { runtime })).resolves.toBe(0); await expect(runKtxCli(['runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0); await expect(runKtxCli(['runtime', 'doctor'], doctorIo.io, { runtime })).resolves.toBe(0); await expect(runKtxCli(['runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(0); @@ -185,11 +187,21 @@ describe('runKtxCli', () => { { command: 'stop', cliVersion: '0.0.0-private', + all: false, }, stopIo.io, ); expect(runtime).toHaveBeenNthCalledWith( 4, + { + command: 'stop', + cliVersion: '0.0.0-private', + all: true, + }, + stopAllIo.io, + ); + expect(runtime).toHaveBeenNthCalledWith( + 5, { command: 'status', cliVersion: '0.0.0-private', @@ -198,7 +210,7 @@ describe('runKtxCli', () => { statusIo.io, ); expect(runtime).toHaveBeenNthCalledWith( - 5, + 6, { command: 'doctor', cliVersion: '0.0.0-private', @@ -207,7 +219,7 @@ describe('runKtxCli', () => { doctorIo.io, ); expect(runtime).toHaveBeenNthCalledWith( - 6, + 7, { command: 'prune', cliVersion: '0.0.0-private', @@ -218,6 +230,17 @@ describe('runKtxCli', () => { ); }); + it('documents runtime stop all in command help', async () => { + const testIo = makeIo(); + + await expect(runKtxCli(['runtime', 'stop', '--help'], testIo.io)).resolves.toBe(0); + + expect(testIo.stdout()).toContain('--all'); + expect(testIo.stdout()).toContain('Stop all KTX daemon processes recorded or discoverable'); + expect(testIo.stdout()).toContain('on this machine'); + expect(testIo.stderr()).toBe(''); + }); + it('routes sl query managed runtime install policies', async () => { const sl = vi.fn(async () => 0); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 96fbbeec..de906ece 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -47,13 +47,18 @@ export { runKtxRuntime, type KtxRuntimeArgs, type KtxRuntimeDeps } from './runti export { allocateDaemonPort, readManagedPythonDaemonStatus, + stopAllManagedPythonDaemons, startManagedPythonDaemon, stopManagedPythonDaemon, } from './managed-python-daemon.js'; export type { + ManagedPythonDaemonProcessInfo, ManagedPythonDaemonStartResult, ManagedPythonDaemonState, ManagedPythonDaemonStatus, + ManagedPythonDaemonStopAllEntry, + ManagedPythonDaemonStopAllFailure, + ManagedPythonDaemonStopAllResult, ManagedPythonDaemonStopResult, } from './managed-python-daemon.js'; export { diff --git a/packages/cli/src/managed-python-daemon.test.ts b/packages/cli/src/managed-python-daemon.test.ts index 4e7af22c..ffa69972 100644 --- a/packages/cli/src/managed-python-daemon.test.ts +++ b/packages/cli/src/managed-python-daemon.test.ts @@ -5,9 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readManagedPythonDaemonStatus, startManagedPythonDaemon, + stopAllManagedPythonDaemons, stopManagedPythonDaemon, type ManagedPythonDaemonChild, type ManagedPythonDaemonFetch, + type ManagedPythonDaemonProcessInfo, type ManagedPythonDaemonSpawn, type ManagedPythonDaemonState, } from './managed-python-daemon.js'; @@ -105,6 +107,24 @@ function runningState(root: string, overrides: Partial }; } +function daemonStatePath(root: string, version: string): string { + return join(root, 'runtime', version, 'daemon.json'); +} + +function runningStateForVersion( + root: string, + version: string, + overrides: Partial = {}, +): ManagedPythonDaemonState { + return { + ...runningState(root), + version, + stdoutLog: join(root, 'runtime', version, 'daemon.stdout.log'), + stderrLog: join(root, 'runtime', version, 'daemon.stderr.log'), + ...overrides, + }; +} + describe('managed Python daemon lifecycle', () => { let tempDir: string; @@ -271,4 +291,138 @@ describe('managed Python daemon lifecycle', () => { expect(killProcess).toHaveBeenCalledWith(4242); 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]); + const killProcess = vi.fn((pid: number) => { + alive.delete(pid); + }); + + const result = await stopAllManagedPythonDaemons({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + listProcesses: vi.fn(async () => []), + processAlive: vi.fn((pid) => alive.has(pid)), + killProcess, + stopGraceMs: 0, + }); + + 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(); + }); + + it('removes stale state when the recorded daemon process is no longer alive', async () => { + await mkdir(layout(tempDir).versionDir, { 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'), + listProcesses: vi.fn(async () => []), + processAlive: vi.fn(() => false), + killProcess: vi.fn(), + stopGraceMs: 0, + }); + + expect(result.stopped).toHaveLength(0); + expect(result.stale.map((entry) => entry.pid)).toEqual([4242]); + await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow(); + }); + + it('deduplicates a daemon found by state and process scan, preferring state metadata', async () => { + await mkdir(layout(tempDir).versionDir, { 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'), + listProcesses: vi.fn(async (): Promise => [ + { pid: 4242, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 61234' }, + ]), + processAlive: vi.fn((pid) => alive.has(pid)), + killProcess, + stopGraceMs: 0, + }); + + expect(result.stopped).toHaveLength(1); + expect(result.stopped[0]).toMatchObject({ + pid: 4242, + source: 'state', + url: 'http://127.0.0.1:58731', + }); + expect(killProcess).toHaveBeenCalledTimes(1); + }); + + it('stops unrecorded ktx-daemon serve-http processes from process scan results', async () => { + const alive = new Set([3333, 5555]); + const killProcess = vi.fn((pid: number) => { + alive.delete(pid); + }); + + const result = await stopAllManagedPythonDaemons({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + listProcesses: vi.fn(async (): Promise => [ + { pid: 3333, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 8765' }, + { pid: 4444, command: 'node server.js --port 8765' }, + { pid: 5555, command: 'grep ktx-daemon serve-http --port 8765' }, + ]), + processAlive: vi.fn((pid) => alive.has(pid)), + killProcess, + stopGraceMs: 0, + }); + + expect(result.failed).toHaveLength(0); + expect(result.stopped).toEqual([ + expect.objectContaining({ + pid: 3333, + source: 'process', + url: 'http://127.0.0.1:8765', + }), + ]); + expect(killProcess).toHaveBeenCalledWith(3333, 'SIGTERM'); + expect(killProcess).not.toHaveBeenCalledWith(4444, expect.anything()); + expect(killProcess).not.toHaveBeenCalledWith(5555, expect.anything()); + }); + + it('reports a failed stop when TERM and KILL leave a daemon running', async () => { + await mkdir(layout(tempDir).versionDir, { 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'), + listProcesses: vi.fn(async () => []), + processAlive: vi.fn(() => true), + killProcess: vi.fn(), + stopGraceMs: 0, + }); + + expect(result.stopped).toHaveLength(0); + expect(result.failed).toEqual([ + expect.objectContaining({ + pid: 4242, + detail: 'Process still running after SIGKILL', + }), + ]); + expect(await readFile(layout(tempDir).daemonStatePath, 'utf8')).toContain('"pid": 4242'); + }); }); diff --git a/packages/cli/src/managed-python-daemon.ts b/packages/cli/src/managed-python-daemon.ts index 2caf9182..b99de581 100644 --- a/packages/cli/src/managed-python-daemon.ts +++ b/packages/cli/src/managed-python-daemon.ts @@ -1,7 +1,9 @@ -import { spawn } from 'node:child_process'; -import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises'; +import { execFile, spawn } from 'node:child_process'; +import { mkdir, open, readdir, 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, @@ -44,6 +46,35 @@ export interface ManagedPythonDaemonStopResult { state?: ManagedPythonDaemonState; } +export interface ManagedPythonDaemonProcessInfo { + pid: number; + command: string; +} + +export type ManagedPythonDaemonStopAllSource = 'state' | 'process'; + +export interface ManagedPythonDaemonStopAllEntry { + pid: number; + source: ManagedPythonDaemonStopAllSource; + url?: string; + health?: 'healthy' | 'unreachable'; + version?: string; + command?: string; + statePaths: string[]; +} + +export interface ManagedPythonDaemonStopAllFailure extends ManagedPythonDaemonStopAllEntry { + detail: string; +} + +export interface ManagedPythonDaemonStopAllResult { + runtimeRoot: string; + stopped: ManagedPythonDaemonStopAllEntry[]; + stale: ManagedPythonDaemonStopAllEntry[]; + failed: ManagedPythonDaemonStopAllFailure[]; + scanErrors: string[]; +} + export interface ManagedPythonDaemonChild { pid?: number; unref(): void; @@ -68,6 +99,8 @@ export type ManagedPythonDaemonFetch = ( text(): Promise; }>; +export type ManagedPythonDaemonKillProcess = (pid: number, signal?: NodeJS.Signals) => void; + export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLayoutOptions { features: KtxRuntimeFeature[]; force?: boolean; @@ -76,7 +109,7 @@ export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLay fetch?: ManagedPythonDaemonFetch; allocatePort?: () => Promise; processAlive?: (pid: number) => boolean; - killProcess?: (pid: number) => void; + killProcess?: ManagedPythonDaemonKillProcess; now?: () => Date; startupTimeoutMs?: number; pollIntervalMs?: number; @@ -89,9 +122,20 @@ export interface ManagedPythonDaemonStatusOptions extends ManagedPythonRuntimeLa export interface ManagedPythonDaemonStopOptions extends ManagedPythonRuntimeLayoutOptions { processAlive?: (pid: number) => boolean; - killProcess?: (pid: number) => void; + killProcess?: ManagedPythonDaemonKillProcess; } +export interface ManagedPythonDaemonStopAllOptions extends ManagedPythonRuntimeLayoutOptions { + listProcesses?: () => Promise; + processAlive?: (pid: number) => boolean; + killProcess?: ManagedPythonDaemonKillProcess; + stopGraceMs?: number; + pollIntervalMs?: number; + healthProbeMs?: number; +} + +const execFileAsync = promisify(execFile); + const daemonStateSchema = z.object({ schemaVersion: z.literal(1), pid: z.number().int().positive(), @@ -126,9 +170,9 @@ function defaultProcessAlive(pid: number): boolean { } } -function defaultKillProcess(pid: number): void { +function defaultKillProcess(pid: number, signal: NodeJS.Signals = 'SIGTERM'): void { try { - process.kill(pid, 'SIGTERM'); + process.kill(pid, signal); } catch (error) { const code = (error as { code?: unknown }).code; if (code !== 'ESRCH') { @@ -293,7 +337,7 @@ async function stopRecordedDaemon(input: { layout: ManagedPythonRuntimeLayout; state: ManagedPythonDaemonState; processAlive: (pid: number) => boolean; - killProcess: (pid: number) => void; + killProcess: ManagedPythonDaemonKillProcess; }): Promise { if (input.processAlive(input.state.pid)) { input.killProcess(input.state.pid); @@ -301,6 +345,323 @@ async function stopRecordedDaemon(input: { await removeState(input.layout); } +function runtimeRootForStopAll(options: ManagedPythonRuntimeLayoutOptions): string { + return managedPythonRuntimeLayout(options).runtimeRoot; +} + +async function removeStatePaths(paths: string[]): Promise { + await Promise.all([...new Set(paths)].map((path) => rm(path, { force: true }))); +} + +interface ManagedPythonDaemonStopCandidate { + pid: number; + source: ManagedPythonDaemonStopAllSource; + host?: string; + port?: number; + version?: string; + command?: string; + statePaths: string[]; +} + +function candidateUrl(candidate: ManagedPythonDaemonStopCandidate): string | undefined { + if (!candidate.host || !candidate.port) { + return undefined; + } + return `http://${candidate.host}:${candidate.port}`; +} + +function candidateEntry(candidate: ManagedPythonDaemonStopCandidate): ManagedPythonDaemonStopAllEntry { + return { + pid: candidate.pid, + source: candidate.source, + ...(candidateUrl(candidate) ? { url: candidateUrl(candidate) } : {}), + ...(candidate.version ? { version: candidate.version } : {}), + ...(candidate.command ? { command: candidate.command } : {}), + statePaths: [...candidate.statePaths], + }; +} + +async function probeCandidateHealth( + candidate: ManagedPythonDaemonStopCandidate, + timeoutMs: number, +): Promise<'healthy' | 'unreachable' | undefined> { + const url = candidateUrl(candidate); + if (!url) { + return undefined; + } + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, timeoutMs); + try { + const response = await fetch(`${url}/health`, { signal: controller.signal }); + if (!response.ok) { + return 'unreachable'; + } + const body = (await response.json()) as unknown; + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return 'unreachable'; + } + return (body as Record).status === 'healthy' ? 'healthy' : 'unreachable'; + } catch { + return 'unreachable'; + } finally { + clearTimeout(timeout); + } +} + +async function readStateCandidates(runtimeRoot: string): Promise { + let entries; + try { + entries = await readdir(runtimeRoot, { withFileTypes: true }); + } catch (error) { + const code = (error as { code?: unknown }).code; + if (code === 'ENOENT') { + return []; + } + throw error; + } + 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({ + pid: state.pid, + source: 'state', + host: state.host, + port: state.port, + version: state.version, + statePaths: [statePath], + }); + } + return candidates; +} + +function tokenizeCommand(command: string): string[] { + const tokens: string[] = []; + for (const match of command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) { + tokens.push(match[1] ?? match[2] ?? match[3] ?? ''); + } + return tokens; +} + +function executableName(token: string): string { + return token.split(/[\\/]/).at(-1) ?? token; +} + +function isKtxDaemonExecutable(token: string): boolean { + return executableName(token) === 'ktx-daemon' || executableName(token) === 'ktx-daemon.exe'; +} + +function normalizedExecutableName(token: string): string { + return executableName(token).replace(/\.exe$/i, '').toLowerCase(); +} + +function hasUvRunPrefix(tokens: string[], daemonIndex: number): boolean { + return normalizedExecutableName(tokens[0] ?? '') === 'uv' && tokens.slice(1, daemonIndex).includes('run'); +} + +function isPythonExecutable(token: string): boolean { + const name = normalizedExecutableName(token); + return name === 'python' || name === 'python3'; +} + +function hasPythonModulePrefix(tokens: string[], moduleFlagIndex: number): boolean { + if (moduleFlagIndex === 1 && isPythonExecutable(tokens[0] ?? '')) { + return true; + } + return ( + normalizedExecutableName(tokens[0] ?? '') === 'uv' && + tokens.slice(1, moduleFlagIndex).includes('run') && + tokens.some((token, index) => index < moduleFlagIndex && isPythonExecutable(token)) + ); +} + +function isKtxDaemonServeHttp(tokens: string[]): boolean { + for (let index = 0; index < tokens.length; index += 1) { + if ( + isKtxDaemonExecutable(tokens[index] ?? '') && + tokens[index + 1] === 'serve-http' && + (index === 0 || hasUvRunPrefix(tokens, index)) + ) { + return true; + } + if ( + tokens[index] === '-m' && + tokens[index + 1] === 'ktx_daemon' && + tokens[index + 2] === 'serve-http' && + hasPythonModulePrefix(tokens, index) + ) { + return true; + } + } + return false; +} + +function parseCommandOption(tokens: string[], option: string): string | undefined { + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === option) { + return tokens[index + 1]; + } + if (token?.startsWith(`${option}=`)) { + return token.slice(option.length + 1); + } + } + return undefined; +} + +function processCandidate(processInfo: ManagedPythonDaemonProcessInfo): ManagedPythonDaemonStopCandidate | undefined { + const tokens = tokenizeCommand(processInfo.command); + if (!isKtxDaemonServeHttp(tokens)) { + return undefined; + } + const host = parseCommandOption(tokens, '--host') ?? '127.0.0.1'; + const rawPort = parseCommandOption(tokens, '--port'); + const parsedPort = rawPort ? Number.parseInt(rawPort, 10) : 8765; + const port = Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 ? parsedPort : 8765; + return { + pid: processInfo.pid, + source: 'process', + host, + port, + command: processInfo.command, + statePaths: [], + }; +} + +function mergeCandidates(candidates: ManagedPythonDaemonStopCandidate[]): ManagedPythonDaemonStopCandidate[] { + const byPid = new Map(); + for (const candidate of candidates) { + const existing = byPid.get(candidate.pid); + if (!existing) { + byPid.set(candidate.pid, { ...candidate, statePaths: [...candidate.statePaths] }); + continue; + } + existing.statePaths.push(...candidate.statePaths); + if (existing.source === 'process' && candidate.source === 'state') { + byPid.set(candidate.pid, { + ...candidate, + statePaths: [...new Set([...existing.statePaths, ...candidate.statePaths])], + }); + } else { + existing.statePaths = [...new Set(existing.statePaths)]; + } + } + return [...byPid.values()].sort((left, right) => left.pid - right.pid); +} + +function parsePosixProcessList(output: string): ManagedPythonDaemonProcessInfo[] { + const processes: ManagedPythonDaemonProcessInfo[] = []; + for (const line of output.split(/\r?\n/)) { + const match = line.match(/^\s*(\d+)\s+(.+)$/); + if (!match) { + continue; + } + processes.push({ pid: Number.parseInt(match[1], 10), command: match[2] }); + } + return processes; +} + +function parseWindowsProcessList(output: string): ManagedPythonDaemonProcessInfo[] { + if (!output.trim()) { + return []; + } + const parsed = JSON.parse(output) as unknown; + const records = Array.isArray(parsed) ? parsed : [parsed]; + const processes: ManagedPythonDaemonProcessInfo[] = []; + for (const record of records) { + if (!record || typeof record !== 'object') { + continue; + } + const value = record as Record; + const pid = value.ProcessId; + const command = value.CommandLine; + if (typeof pid === 'number' && typeof command === 'string' && command.length > 0) { + processes.push({ pid, command }); + } + } + return processes; +} + +async function defaultListProcesses(platform: NodeJS.Platform = process.platform): Promise { + if (platform === 'win32') { + const command = [ + 'Get-CimInstance Win32_Process', + '| Where-Object { $_.CommandLine -ne $null }', + '| Select-Object ProcessId,CommandLine', + '| ConvertTo-Json -Compress', + ].join(' '); + const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + return parseWindowsProcessList(stdout); + } + const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,command='], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + return parsePosixProcessList(stdout); +} + +async function waitUntilStopped(input: { + pid: number; + processAlive: (pid: number) => boolean; + timeoutMs: number; + pollIntervalMs: number; +}): Promise { + const deadline = Date.now() + input.timeoutMs; + do { + if (!input.processAlive(input.pid)) { + return true; + } + if (Date.now() >= deadline) { + break; + } + await delay(input.pollIntervalMs); + } while (Date.now() <= deadline); + return !input.processAlive(input.pid); +} + +async function discoverStopAllCandidates( + options: ManagedPythonDaemonStopAllOptions, +): Promise<{ + runtimeRoot: string; + candidates: ManagedPythonDaemonStopCandidate[]; + scanErrors: string[]; +}> { + const runtimeRoot = runtimeRootForStopAll(options); + const stateCandidates = await readStateCandidates(runtimeRoot); + const scanErrors: string[] = []; + let processCandidates: ManagedPythonDaemonStopCandidate[] = []; + try { + const processes = await (options.listProcesses ?? defaultListProcesses)(); + processCandidates = processes.flatMap((processInfo) => { + const candidate = processCandidate(processInfo); + return candidate ? [candidate] : []; + }); + } catch (error) { + scanErrors.push(error instanceof Error ? error.message : String(error)); + } + return { + runtimeRoot, + candidates: mergeCandidates([...stateCandidates, ...processCandidates]), + scanErrors, + }; +} + export async function startManagedPythonDaemon( options: ManagedPythonDaemonStartOptions, ): Promise { @@ -404,3 +765,63 @@ export async function stopManagedPythonDaemon( }); return { status: 'stopped', layout, state }; } + +export async function stopAllManagedPythonDaemons( + options: ManagedPythonDaemonStopAllOptions, +): Promise { + const processAlive = options.processAlive ?? defaultProcessAlive; + const killProcess = options.killProcess ?? defaultKillProcess; + const stopGraceMs = options.stopGraceMs ?? 500; + const pollIntervalMs = options.pollIntervalMs ?? 50; + const healthProbeMs = options.healthProbeMs ?? 100; + const discovery = await discoverStopAllCandidates(options); + const stopped: ManagedPythonDaemonStopAllEntry[] = []; + const stale: ManagedPythonDaemonStopAllEntry[] = []; + const failed: ManagedPythonDaemonStopAllFailure[] = []; + + for (const candidate of discovery.candidates) { + const health = await probeCandidateHealth(candidate, healthProbeMs); + const entry = { ...candidateEntry(candidate), ...(health ? { health } : {}) }; + if (!processAlive(candidate.pid)) { + await removeStatePaths(candidate.statePaths); + stale.push(entry); + continue; + } + try { + killProcess(candidate.pid, 'SIGTERM'); + if ( + !(await waitUntilStopped({ + pid: candidate.pid, + processAlive, + timeoutMs: stopGraceMs, + pollIntervalMs, + })) + ) { + killProcess(candidate.pid, 'SIGKILL'); + if ( + !(await waitUntilStopped({ + pid: candidate.pid, + processAlive, + timeoutMs: stopGraceMs, + pollIntervalMs, + })) + ) { + failed.push({ ...entry, detail: 'Process still running after SIGKILL' }); + continue; + } + } + await removeStatePaths(candidate.statePaths); + stopped.push(entry); + } catch (error) { + failed.push({ ...entry, detail: error instanceof Error ? error.message : String(error) }); + } + } + + return { + runtimeRoot: discovery.runtimeRoot, + stopped, + stale, + failed, + scanErrors: discovery.scanErrors, + }; +} diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts index e367d339..46f708b2 100644 --- a/packages/cli/src/runtime.test.ts +++ b/packages/cli/src/runtime.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { + ManagedPythonDaemonStopAllResult, ManagedPythonDaemonStartResult, ManagedPythonDaemonStopResult, } from './managed-python-daemon.js'; @@ -199,13 +200,63 @@ describe('runKtxRuntime', () => { })), }; - await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0' }, io.io, deps)).resolves.toBe(0); + await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: false }, io.io, deps)).resolves.toBe(0); expect(deps.stopDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0' }); expect(io.stdout()).toContain('Stopped KTX Python daemon'); expect(io.stdout()).toContain('pid: 4242'); }); + it('stops all discovered Python daemons and reports the summary', async () => { + const io = makeIo(); + const deps: KtxRuntimeDeps = { + stopAllDaemons: vi.fn(async (): Promise => ({ + runtimeRoot: '/runtime', + stopped: [ + { pid: 4242, source: 'state', url: 'http://127.0.0.1:61234', statePaths: ['/runtime/0.2.0/daemon.json'] }, + { pid: 5252, source: 'process', url: 'http://127.0.0.1:8765', statePaths: [] }, + ], + stale: [], + failed: [], + scanErrors: [], + })), + }; + + await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: true }, io.io, deps)).resolves.toBe(0); + + expect(deps.stopAllDaemons).toHaveBeenCalledWith({ cliVersion: '0.2.0' }); + 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'); + }); + + it('returns failure when stop all cannot stop every daemon', async () => { + const io = makeIo(); + const deps: KtxRuntimeDeps = { + stopAllDaemons: vi.fn(async (): Promise => ({ + runtimeRoot: '/runtime', + stopped: [], + stale: [], + failed: [ + { + pid: 4242, + source: 'state', + url: 'http://127.0.0.1:61234', + statePaths: ['/runtime/0.2.0/daemon.json'], + detail: 'Process still running after SIGKILL', + }, + ], + scanErrors: ['ps failed'], + })), + }; + + await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', 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'); + expect(io.stderr()).toContain('process scan: ps failed'); + }); + it('prints runtime status as JSON', async () => { const io = makeIo(); const deps: KtxRuntimeDeps = { diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index fe2b5f74..e88f2b31 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -1,7 +1,9 @@ import type { KtxCliIo } from './cli-runtime.js'; import { + stopAllManagedPythonDaemons, startManagedPythonDaemon, stopManagedPythonDaemon, + type ManagedPythonDaemonStopAllResult, type ManagedPythonDaemonStartResult, type ManagedPythonDaemonStopResult, } from './managed-python-daemon.js'; @@ -22,7 +24,7 @@ import { export type KtxRuntimeArgs = | { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean } | { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean } - | { command: 'stop'; cliVersion: string } + | { command: 'stop'; cliVersion: string; all: boolean } | { command: 'status'; cliVersion: string; json: boolean } | { command: 'doctor'; cliVersion: string; json: boolean } | { command: 'prune'; cliVersion: string; dryRun: boolean; yes: boolean }; @@ -35,6 +37,7 @@ export interface KtxRuntimeDeps { force?: boolean; }) => Promise; stopDaemon?: (options: { cliVersion: string }) => Promise; + stopAllDaemons?: (options: { cliVersion: string }) => Promise; readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; pruneRuntime?: (options: { @@ -81,6 +84,58 @@ function writeDaemonStop(io: KtxCliIo, result: ManagedPythonDaemonStopResult): v io.stdout.write(`state: ${result.layout.daemonStatePath}\n`); } +function writeStopAllEntry(io: KtxCliIo, entry: { pid: number; source: string; url?: string; health?: string; detail?: string }): void { + io.stdout.write( + `pid: ${entry.pid} source: ${entry.source}${entry.url ? ` url: ${entry.url}` : ''}${ + entry.health ? ` health: ${entry.health}` : '' + }${ + entry.detail ? ` detail: ${entry.detail}` : '' + }\n`, + ); +} + +function writeDaemonStopAll(io: KtxCliIo, result: ManagedPythonDaemonStopAllResult): number { + const failed = result.failed.length + result.scanErrors.length; + if ( + result.stopped.length === 0 && + result.stale.length === 0 && + result.failed.length === 0 && + result.scanErrors.length === 0 + ) { + io.stdout.write('No KTX Python daemons found\n'); + return 0; + } + if (failed === 0) { + io.stdout.write(`Stopped ${result.stopped.length} KTX Python daemons\n`); + if (result.stale.length > 0) { + io.stdout.write(`Cleaned ${result.stale.length} stale daemon states\n`); + } + for (const entry of result.stopped) { + writeStopAllEntry(io, entry); + } + for (const entry of result.stale) { + writeStopAllEntry(io, entry); + } + return 0; + } + io.stderr.write( + `Stopped ${result.stopped.length} KTX Python daemons; failed ${result.failed.length}${ + result.stale.length > 0 ? `; cleaned stale ${result.stale.length}` : '' + }\n`, + ); + for (const entry of result.failed) { + io.stderr.write( + `pid: ${entry.pid} source: ${entry.source}${entry.url ? ` url: ${entry.url}` : ''}${ + entry.health ? ` health: ${entry.health}` : '' + } detail: ${entry.detail}\n`, + ); + } + for (const error of result.scanErrors) { + io.stderr.write(`process scan: ${error}\n`); + } + return 1; +} + function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void { io.stdout.write('KTX Python runtime\n'); io.stdout.write(`status: ${status.kind}\n`); @@ -142,10 +197,16 @@ export async function runKtxRuntime( return 0; } if (args.command === 'stop') { - const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon; - const result = await stopDaemon({ cliVersion: args.cliVersion }); - writeDaemonStop(io, result); - return 0; + if (args.all) { + const stopAllDaemons = deps.stopAllDaemons ?? stopAllManagedPythonDaemons; + const result = await stopAllDaemons({ cliVersion: args.cliVersion }); + return writeDaemonStopAll(io, result); + } else { + const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon; + const result = await stopDaemon({ cliVersion: args.cliVersion }); + writeDaemonStop(io, result); + return 0; + } } if (args.command === 'status') { const readStatus = deps.readStatus ?? readManagedPythonRuntimeStatus;