refactor(cli): extract project checks and historic SQL doctor into status-project

Move project-level doctor checks, semantic search embedding checks, and
historic SQL doctor logic from doctor.ts into a dedicated status-project.ts
module. Removes historic-sql-doctor.ts and its test file, consolidating
everything into the new module with its own tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luca Martial 2026-05-13 15:45:54 -07:00
parent 2b5dcc0122
commit d1b4bcfcb3
5 changed files with 697 additions and 751 deletions

View file

@ -1,8 +1,7 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { KtxEmbeddingConfig, KtxEmbeddingHealthCheckOptions, KtxEmbeddingHealthCheckResult } from '@ktx/llm';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
formatDoctorReport,
runKtxDoctor,
@ -31,31 +30,6 @@ function makeIo() {
};
}
type EmbeddingHealthCheck = (
config: KtxEmbeddingConfig,
options?: KtxEmbeddingHealthCheckOptions,
) => Promise<KtxEmbeddingHealthCheckResult>;
async function writeProjectConfig(projectDir: string, embeddingLines: string[]): Promise<void> {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'ingest:',
' adapters:',
' - live-database',
' embeddings:',
...embeddingLines.map((line) => ` ${line}`),
'',
].join('\n'),
'utf-8',
);
}
describe('formatDoctorReport', () => {
it('shows the failing check and its fix in plain output', () => {
const checks: DoctorCheck[] = [
@ -307,6 +281,7 @@ describe('runKtxDoctor', () => {
});
it('runs project checks against a valid ktx.yaml', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key';
await writeFile(
join(tempDir, 'ktx.yaml'),
[
@ -315,222 +290,109 @@ describe('runKtxDoctor', () => {
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'llm:',
' provider:',
' backend: anthropic',
' models:',
' default: claude-sonnet-4-5',
'ingest:',
' adapters:',
' - live-database',
' embeddings:',
' backend: openai',
' model: text-embedding-3-small',
' dimensions: 1536',
'',
].join('\n'),
'utf-8',
);
process.env.OPENAI_API_KEY = 'test-key';
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
},
{},
),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('KTX status');
expect(testIo.stdout()).toContain('· warehouse');
expect(testIo.stdout()).toContain('✓ Project');
const out = testIo.stdout();
expect(out).toContain('KTX status');
expect(out).toContain('· warehouse');
expect(out).toContain('Connections (1)');
expect(out).toContain('LLM');
expect(out).toContain('anthropic');
expect(out).toContain('Embeddings');
expect(out).toContain('Ready.');
delete process.env.ANTHROPIC_API_KEY;
delete process.env.OPENAI_API_KEY;
});
it('includes Postgres historic-SQL readiness in project doctor output', async () => {
it('returns blocked verdict when LLM is not configured', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
' url: env:WAREHOUSE_DATABASE_URL',
' historicSql:',
' enabled: true',
' dialect: postgres',
'ingest:',
' adapters:',
' - live-database',
' - historic-sql',
' driver: sqlite',
' path: ./warehouse.db',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
const runHistoricSqlDoctorChecks = vi.fn(async () => [
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'pass' as const,
detail:
'pg_stat_statements ready (PostgreSQL 16.4); info: pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
},
]);
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled', verbose: true },
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
runHistoricSqlDoctorChecks,
},
{},
),
).resolves.toBe(0);
).resolves.toBe(1);
expect(runHistoricSqlDoctorChecks).toHaveBeenCalledTimes(1);
expect(testIo.stdout()).toContain('✓ Postgres Historic SQL (warehouse): pg_stat_statements ready');
expect(testIo.stdout()).toContain('info: pg_stat_statements.max is 1000');
expect(testIo.stdout()).not.toContain('→ Update the Postgres parameter group or config');
expect(testIo.stdout()).toContain('no LLM configured');
expect(testIo.stdout()).toContain('ktx setup');
});
it('warns when semantic-search embeddings are not configured', async () => {
await writeProjectConfig(tempDir, ['backend: deterministic', 'model: deterministic', 'dimensions: 8']);
process.env.ANTHROPIC_API_KEY = 'test-key';
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'llm:',
' provider:',
' backend: anthropic',
'ingest:',
' adapters:',
' - live-database',
' embeddings:',
' backend: deterministic',
' model: deterministic',
' dimensions: 8',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
},
{},
),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('⚠ Semantic search');
expect(testIo.stdout()).toContain('ingest.embeddings.backend is deterministic.');
expect(testIo.stdout()).toContain(
'Semantic lane will be skipped; lexical, dictionary, and token lanes remain available.',
);
expect(testIo.stdout()).toContain(
`→ Run: ktx setup --project-dir ${tempDir} --no-input`,
);
});
it('probes configured semantic-search embeddings for project doctor', async () => {
await writeProjectConfig(tempDir, [
'backend: sentence-transformers',
'model: all-MiniLM-L6-v2',
'dimensions: 384',
'sentenceTransformers:',
' base_url: http://127.0.0.1:8765',
" pathPrefix: ''",
]);
const healthCheck = vi.fn<EmbeddingHealthCheck>(async () => ({ ok: true }));
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled', verbose: true },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
embeddingHealthCheck: healthCheck,
embeddingProbeTimeoutMs: 1234,
},
),
).resolves.toBe(0);
expect(healthCheck).toHaveBeenCalledWith(
{
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
sentenceTransformers: { baseURL: 'http://127.0.0.1:8765', pathPrefix: '' },
},
{ text: 'KTX semantic search doctor probe', timeoutMs: 1234 },
);
expect(testIo.stdout()).toContain(
'✓ Semantic search embeddings: sentence-transformers/all-MiniLM-L6-v2 (384d) probe succeeded',
);
});
it('allows local sentence-transformers semantic-search probes enough time for cold start', async () => {
await writeProjectConfig(tempDir, [
'backend: sentence-transformers',
'model: all-MiniLM-L6-v2',
'dimensions: 384',
'sentenceTransformers:',
' base_url: http://127.0.0.1:8765',
" pathPrefix: ''",
]);
const healthCheck = vi.fn<EmbeddingHealthCheck>(async () => ({ ok: true }));
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
embeddingHealthCheck: healthCheck,
},
),
).resolves.toBe(0);
expect(healthCheck).toHaveBeenCalledWith(
expect.objectContaining({
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
}),
{ text: 'KTX semantic search doctor probe', timeoutMs: 120_000 },
);
});
it('reports unhealthy semantic-search embeddings as a warning in JSON output', async () => {
await writeProjectConfig(tempDir, [
'backend: sentence-transformers',
'model: all-MiniLM-L6-v2',
'dimensions: 384',
'sentenceTransformers:',
' base_url: http://127.0.0.1:8765',
" pathPrefix: ''",
]);
const healthCheck = vi.fn<EmbeddingHealthCheck>(async () => ({
ok: false,
message: 'connect ECONNREFUSED 127.0.0.1:8765',
}));
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
embeddingHealthCheck: healthCheck,
},
),
).resolves.toBe(0);
const report = JSON.parse(testIo.stdout()) as {
checks: Array<{ id: string; label: string; status: string; detail: string; fix?: string }>;
};
expect(report.checks).toContainEqual({
id: 'semantic-search-embeddings',
label: 'Semantic search embeddings',
status: 'warn',
detail:
'sentence-transformers/all-MiniLM-L6-v2 (384d) probe failed: connect ECONNREFUSED 127.0.0.1:8765. Semantic lane will be skipped; lexical, dictionary, and token lanes remain available.',
fix: `Run: ktx setup --project-dir ${tempDir} --no-input`,
group: 'search',
});
expect(testIo.stdout()).toContain('Embeddings');
expect(testIo.stdout()).toContain('deterministic');
expect(testIo.stdout()).toContain('semantic search degraded');
delete process.env.ANTHROPIC_API_KEY;
});
});

View file

@ -4,9 +4,6 @@ import { access } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import type { KtxLocalProject, KtxProjectEmbeddingConfig } from '@ktx/context/project';
import type { KtxEmbeddingConfig, KtxEmbeddingHealthCheckOptions, KtxEmbeddingHealthCheckResult } from '@ktx/llm';
import type { HistoricSqlDoctorDeps } from './historic-sql-doctor.js';
const execFileAsync = promisify(execFile);
@ -57,20 +54,8 @@ interface SetupDoctorDeps {
importBetterSqlite3?: () => Promise<unknown>;
}
type EmbeddingHealthCheck = (
config: KtxEmbeddingConfig,
options?: KtxEmbeddingHealthCheckOptions,
) => Promise<KtxEmbeddingHealthCheckResult>;
interface SemanticSearchDoctorDeps {
env?: NodeJS.ProcessEnv;
embeddingHealthCheck?: EmbeddingHealthCheck;
embeddingProbeTimeoutMs?: number;
}
interface KtxDoctorDeps extends SemanticSearchDoctorDeps, HistoricSqlDoctorDeps {
interface KtxDoctorDeps {
runSetupChecks?: () => Promise<DoctorCheck[]>;
runHistoricSqlDoctorChecks?: (project: KtxLocalProject, deps: HistoricSqlDoctorDeps) => Promise<DoctorCheck[]>;
}
function workspaceRootDir(): string {
@ -131,99 +116,6 @@ function check(status: DoctorStatus, id: string, label: string, detail: string,
return fix ? { id, label, status, detail, fix } : { id, label, status, detail };
}
const SEMANTIC_SEARCH_HEALTH_TEXT = 'KTX semantic search doctor probe';
const SEMANTIC_SEARCH_HEALTH_TIMEOUT_MS = 5_000;
const SEMANTIC_SEARCH_LOCAL_HEALTH_TIMEOUT_MS = 120_000;
function semanticEmbeddingSetupFix(projectDir: string, backend: KtxProjectEmbeddingConfig['backend']): string {
if (backend === 'openai') {
return `Set OPENAI_API_KEY or rerun: ktx setup --project-dir ${projectDir} --embedding-backend openai --no-input`;
}
return `Run: ktx setup --project-dir ${projectDir} --no-input`;
}
function embeddingConfigLabel(config: KtxProjectEmbeddingConfig | KtxEmbeddingConfig): string {
const model = config.model?.trim() || 'model not configured';
return `${config.backend}/${model} (${config.dimensions}d)`;
}
function semanticLaneFallbackDetail(reason: string): string {
return `${reason}. Semantic lane will be skipped; lexical, dictionary, and token lanes remain available.`;
}
async function defaultEmbeddingHealthCheck(
config: KtxEmbeddingConfig,
options?: KtxEmbeddingHealthCheckOptions,
): Promise<KtxEmbeddingHealthCheckResult> {
const { runKtxEmbeddingHealthCheck } = await import('@ktx/llm');
return runKtxEmbeddingHealthCheck(config, options);
}
async function runSemanticSearchEmbeddingCheck(
config: KtxProjectEmbeddingConfig,
projectDir: string,
deps: SemanticSearchDoctorDeps = {},
): Promise<DoctorCheck> {
if (config.backend === 'none' || config.backend === 'deterministic') {
return check(
'warn',
'semantic-search-embeddings',
'Semantic search embeddings',
semanticLaneFallbackDetail(`ingest.embeddings.backend is ${config.backend}`),
semanticEmbeddingSetupFix(projectDir, config.backend),
);
}
try {
const { resolveLocalKtxEmbeddingConfig } = await import('@ktx/context');
const resolved = resolveLocalKtxEmbeddingConfig(config, deps.env ?? process.env);
if (!resolved) {
return check(
'warn',
'semantic-search-embeddings',
'Semantic search embeddings',
semanticLaneFallbackDetail(`No runtime embedding config resolved for ${embeddingConfigLabel(config)}`),
semanticEmbeddingSetupFix(projectDir, config.backend),
);
}
const healthCheck = deps.embeddingHealthCheck ?? defaultEmbeddingHealthCheck;
const timeoutMs =
deps.embeddingProbeTimeoutMs ??
(resolved.backend === 'sentence-transformers'
? SEMANTIC_SEARCH_LOCAL_HEALTH_TIMEOUT_MS
: SEMANTIC_SEARCH_HEALTH_TIMEOUT_MS);
const health = await healthCheck(resolved, {
text: SEMANTIC_SEARCH_HEALTH_TEXT,
timeoutMs,
});
if (health.ok) {
return check(
'pass',
'semantic-search-embeddings',
'Semantic search embeddings',
`${embeddingConfigLabel(resolved)} probe succeeded`,
);
}
return check(
'warn',
'semantic-search-embeddings',
'Semantic search embeddings',
semanticLaneFallbackDetail(`${embeddingConfigLabel(resolved)} probe failed: ${health.message}`),
semanticEmbeddingSetupFix(projectDir, config.backend),
);
} catch (error) {
return check(
'warn',
'semantic-search-embeddings',
'Semantic search embeddings',
semanticLaneFallbackDetail(`${embeddingConfigLabel(config)} probe failed: ${failureMessage(error)}`),
semanticEmbeddingSetupFix(projectDir, config.backend),
);
}
}
export async function runSetupDoctorChecks(deps: SetupDoctorDeps = {}): Promise<DoctorCheck[]> {
const env = deps.env ?? process.env;
const root = deps.workspaceRoot ?? workspaceRootDir();
@ -320,66 +212,6 @@ export async function runSetupDoctorChecks(deps: SetupDoctorDeps = {}): Promise<
return checks.map((entry) => ({ ...entry, group: 'toolchain' }));
}
interface ProjectChecksResult {
checks: DoctorCheck[];
projectName?: string;
}
async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): Promise<ProjectChecksResult> {
const { loadKtxProject } = await import('@ktx/context/project');
const checks: DoctorCheck[] = [];
let projectName: string | undefined;
const tag = (entry: DoctorCheck, group: DoctorGroup): DoctorCheck => ({ ...entry, group });
try {
const project = await loadKtxProject({ projectDir });
projectName = project.config.project;
checks.push(tag(check('pass', 'project-config', 'Project config', project.config.project), 'project'));
const connectionCount = Object.keys(project.config.connections).length;
checks.push(
tag(
connectionCount > 0
? check('pass', 'connections', 'Connections', `${connectionCount} configured`)
: check(
'warn',
'connections',
'Connections',
'0 configured',
'Add a connection to ktx.yaml or run `ktx setup`',
),
'project',
),
);
checks.push(
tag(
check('pass', 'storage', 'Storage', `${project.config.storage.state}/${project.config.storage.search}`),
'project',
),
);
checks.push(tag(check('pass', 'llm-provider', 'LLM provider', project.config.llm.provider.backend), 'project'));
checks.push(tag(await runSemanticSearchEmbeddingCheck(project.config.ingest.embeddings, projectDir, deps), 'search'));
const runHistoricSqlDoctorChecks =
deps.runHistoricSqlDoctorChecks ?? (await import('./historic-sql-doctor.js')).runPostgresHistoricSqlDoctorChecks;
const historic = await runHistoricSqlDoctorChecks(project, deps);
for (const entry of historic) {
checks.push(tag(entry, 'history'));
}
} catch (error) {
checks.push(
tag(
check(
'fail',
'project-config',
'Project config',
failureMessage(error),
`Run: ktx init ${projectDir} --name <project-name>`,
),
'project',
),
);
}
return { checks, projectName };
}
const STATUS_SYMBOL: Record<DoctorStatus, string> = { pass: '✓', warn: '⚠', fail: '✗' };
const GROUP_ORDER: DoctorGroup[] = ['toolchain', 'project', 'search', 'history'];
@ -625,28 +457,35 @@ export async function runKtxDoctor(
const startedAt = Date.now();
try {
const runSetupChecks = deps.runSetupChecks ?? (() => runSetupDoctorChecks());
const setupChecks = await runSetupChecks();
let projectName: string | undefined;
let projectDir: string | undefined;
let report: DoctorReport;
if (args.command === 'setup') {
report = { title: 'KTX status', checks: setupChecks };
} else {
const projectResult = await runProjectChecks(args.projectDir, deps);
projectName = projectResult.projectName;
projectDir = args.projectDir;
report = {
title: 'KTX status',
checks: [...setupChecks, ...projectResult.checks],
};
if (args.command === 'project') {
const { loadKtxProject } = await import('@ktx/context/project');
const { buildProjectStatus, renderProjectStatus } = await import('./status-project.js');
const project = await loadKtxProject({ projectDir: args.projectDir });
const projectStatus = buildProjectStatus(project);
const verbose = args.verbose ?? false;
const toolchainChecks = verbose ? await runSetupChecks() : undefined;
if (args.outputMode === 'json') {
io.stdout.write(`${JSON.stringify(projectStatus, null, 2)}\n`);
} else {
io.stdout.write(
renderProjectStatus(projectStatus, {
verbose,
useColor: shouldUseColor(io),
durationMs: Date.now() - startedAt,
toolchainChecks,
}),
);
}
return projectStatus.verdict === 'blocked' ? 1 : 0;
}
const setupChecks = await runSetupChecks();
const report: DoctorReport = { title: 'KTX status', checks: setupChecks };
const renderOptions: RenderOptions = {
verbose: args.verbose ?? false,
useColor: shouldUseColor(io),
durationMs: Date.now() - startedAt,
projectName,
projectDir,
command: args.command,
};
writeReport(report, args.outputMode, io, renderOptions);

View file

@ -1,202 +0,0 @@
import { buildDefaultKtxProjectConfig, type KtxProjectConnectionConfig } from '@ktx/context/project';
import { HistoricSqlExtensionMissingError } from '@ktx/context/ingest';
import { describe, expect, it, vi } from 'vitest';
import {
runPostgresHistoricSqlDoctorChecks,
type HistoricSqlDoctorProject,
type PostgresHistoricSqlDoctorProbe,
} from './historic-sql-doctor.js';
function projectWithConnections(connections: Record<string, KtxProjectConnectionConfig>): HistoricSqlDoctorProject {
return {
projectDir: '/tmp/ktx-project',
config: {
...buildDefaultKtxProjectConfig('warehouse'),
connections,
ingest: {
...buildDefaultKtxProjectConfig('warehouse').ingest,
adapters: ['live-database', 'historic-sql'],
},
},
};
}
describe('runPostgresHistoricSqlDoctorChecks', () => {
it('passes when no Postgres historic-SQL connections are enabled', async () => {
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: { driver: 'sqlite', path: './warehouse.db' },
}),
{
postgresHistoricSqlProbe: vi.fn<PostgresHistoricSqlDoctorProbe>(),
},
);
expect(checks).toEqual([
{
id: 'historic-sql-postgres',
label: 'Postgres Historic SQL',
status: 'pass',
detail: 'No enabled Postgres historic-SQL connections',
},
]);
});
it('passes when the PGSS probe succeeds without warnings', async () => {
const probe = vi.fn<PostgresHistoricSqlDoctorProbe>(async () => ({
pgServerVersion: 'PostgreSQL 16.4',
warnings: [],
}));
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: {
driver: 'postgres',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
}),
{ postgresHistoricSqlProbe: probe },
);
expect(probe).toHaveBeenCalledWith({
projectDir: '/tmp/ktx-project',
connectionId: 'warehouse',
connection: {
driver: 'postgres',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
env: process.env,
});
expect(checks).toEqual([
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'pass',
detail: 'pg_stat_statements ready (PostgreSQL 16.4)',
},
]);
});
it('passes with an informational note when only pg_stat_statements.max is below the recommended floor', async () => {
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: {
driver: 'postgres',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
}),
{
postgresHistoricSqlProbe: async () => ({
pgServerVersion: 'PostgreSQL 16.4',
warnings: [],
info: [
'pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
],
}),
},
);
expect(checks).toEqual([
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'pass',
detail:
'pg_stat_statements ready (PostgreSQL 16.4); info: pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
},
]);
});
it('warns when pg_stat_statements tracking is disabled', async () => {
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: {
driver: 'postgres',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
}),
{
postgresHistoricSqlProbe: async () => ({
pgServerVersion: 'PostgreSQL 16.4',
warnings: [
'pg_stat_statements.track is none; set it to top or all in the Postgres parameter group or config',
],
info: [
'pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
],
}),
},
);
expect(checks).toEqual([
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'warn',
detail:
'pg_stat_statements ready (PostgreSQL 16.4) with warnings: pg_stat_statements.track is none; set it to top or all in the Postgres parameter group or config; info: pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
fix: 'Update the Postgres parameter group or config, then rerun `ktx status --project-dir /tmp/ktx-project`',
},
]);
});
it('fails when a connection has postgres historic SQL but is not a Postgres driver', async () => {
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: {
driver: 'mysql',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
}),
{
postgresHistoricSqlProbe: vi.fn<PostgresHistoricSqlDoctorProbe>(),
},
);
expect(checks).toEqual([
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'fail',
detail: 'connections.warehouse.historicSql.dialect is postgres but driver is mysql',
fix: 'Set connections.warehouse.driver to postgres or disable historicSql for this connection',
},
]);
});
it('maps PGSS capability errors to actionable failures', async () => {
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: {
driver: 'postgres',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
}),
{
postgresHistoricSqlProbe: async () => {
throw new HistoricSqlExtensionMissingError({
dialect: 'postgres',
message: 'pg_stat_statements extension is not installed in the connection database.',
remediation: 'Run CREATE EXTENSION pg_stat_statements; against the connection database.',
});
},
},
);
expect(checks).toEqual([
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'fail',
detail: 'pg_stat_statements extension is not installed in the connection database.',
fix: 'Run CREATE EXTENSION pg_stat_statements; against the connection database.',
},
]);
});
});

View file

@ -1,167 +0,0 @@
import type { KtxProjectConfig, KtxProjectConnectionConfig } from '@ktx/context/project';
import type { DoctorCheck } from './doctor.js';
export interface HistoricSqlDoctorProject {
projectDir: string;
config: Pick<KtxProjectConfig, 'connections' | 'ingest'>;
}
export interface PostgresHistoricSqlDoctorProbeInput {
projectDir: string;
connectionId: string;
connection: KtxProjectConnectionConfig;
env: NodeJS.ProcessEnv;
}
export interface PostgresHistoricSqlDoctorProbeResult {
pgServerVersion: string;
warnings: string[];
info?: string[];
}
export type PostgresHistoricSqlDoctorProbe = (
input: PostgresHistoricSqlDoctorProbeInput,
) => Promise<PostgresHistoricSqlDoctorProbeResult>;
export interface HistoricSqlDoctorDeps {
env?: NodeJS.ProcessEnv;
postgresHistoricSqlProbe?: PostgresHistoricSqlDoctorProbe;
}
function check(status: DoctorCheck['status'], id: string, label: string, detail: string, fix?: string): DoctorCheck {
return fix ? { id, label, status, detail, fix } : { id, label, status, detail };
}
function historicSqlRecord(connection: KtxProjectConnectionConfig): Record<string, unknown> | null {
const historicSql = connection.historicSql;
return historicSql && typeof historicSql === 'object' && !Array.isArray(historicSql)
? (historicSql as Record<string, unknown>)
: null;
}
function isEnabledPostgresHistoricSql(connection: KtxProjectConnectionConfig): boolean {
const historicSql = historicSqlRecord(connection);
return historicSql?.enabled === true && historicSql.dialect === 'postgres';
}
function isPostgresDriver(connection: KtxProjectConnectionConfig): boolean {
const driver = String(connection.driver ?? '').toLowerCase();
return driver === 'postgres' || driver === 'postgresql';
}
function checkId(connectionId: string): string {
return `historic-sql-postgres-${connectionId.replace(/[^a-z0-9_-]+/gi, '-')}`;
}
function capabilityFailureFix(error: unknown, connectionId: string, projectDir: string): string {
if (error instanceof Error && error.name === 'HistoricSqlExtensionMissingError' && 'remediation' in error) {
return String(error.remediation);
}
if (error instanceof Error && error.name === 'HistoricSqlGrantsMissingError' && 'remediation' in error) {
return String(error.remediation);
}
if (error instanceof Error && error.name === 'HistoricSqlVersionUnsupportedError') {
return 'Use PostgreSQL 14 or newer, or disable historicSql for this connection';
}
return `Fix connections.${connectionId} Postgres settings, then rerun \`ktx status --project-dir ${projectDir}\``;
}
function failureDetail(error: unknown): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message.trim().split('\n')[0] ?? error.message.trim();
}
return String(error);
}
function readinessDetail(result: PostgresHistoricSqlDoctorProbeResult): string {
const warningText = result.warnings.length > 0 ? ` with warnings: ${result.warnings.join('; ')}` : '';
const info = result.info ?? [];
const infoText = info.length > 0 ? `; info: ${info.join('; ')}` : '';
return `pg_stat_statements ready (${result.pgServerVersion})${warningText}${infoText}`;
}
async function defaultPostgresHistoricSqlProbe(
input: PostgresHistoricSqlDoctorProbeInput,
): Promise<PostgresHistoricSqlDoctorProbeResult> {
const [{ PostgresPgssReader }, { KtxPostgresHistoricSqlQueryClient, isKtxPostgresConnectionConfig }] =
await Promise.all([import('@ktx/context/ingest'), import('@ktx/connector-postgres')]);
const inputDriver = input.connection.driver ?? 'unknown';
if (!isKtxPostgresConnectionConfig(input.connection)) {
throw new Error(`Native PostgreSQL connector cannot run driver "${inputDriver}"`);
}
const client = new KtxPostgresHistoricSqlQueryClient({
connectionId: input.connectionId,
connection: input.connection,
env: input.env,
});
try {
return await new PostgresPgssReader().probe(client);
} finally {
await client.cleanup();
}
}
export async function runPostgresHistoricSqlDoctorChecks(
project: HistoricSqlDoctorProject,
deps: HistoricSqlDoctorDeps = {},
): Promise<DoctorCheck[]> {
const targets = Object.entries(project.config.connections)
.filter(([, connection]) => isEnabledPostgresHistoricSql(connection))
.sort(([left], [right]) => left.localeCompare(right));
if (targets.length === 0) {
return [
check('pass', 'historic-sql-postgres', 'Postgres Historic SQL', 'No enabled Postgres historic-SQL connections'),
];
}
const probe = deps.postgresHistoricSqlProbe ?? defaultPostgresHistoricSqlProbe;
const env = deps.env ?? process.env;
const checks: DoctorCheck[] = [];
for (const [connectionId, connection] of targets) {
const label = `Postgres Historic SQL (${connectionId})`;
if (!isPostgresDriver(connection)) {
checks.push(
check(
'fail',
checkId(connectionId),
label,
`connections.${connectionId}.historicSql.dialect is postgres but driver is ${String(connection.driver)}`,
`Set connections.${connectionId}.driver to postgres or disable historicSql for this connection`,
),
);
continue;
}
try {
const result = await probe({ projectDir: project.projectDir, connectionId, connection, env });
if (result.warnings.length > 0) {
checks.push(
check(
'warn',
checkId(connectionId),
label,
readinessDetail(result),
`Update the Postgres parameter group or config, then rerun \`ktx status --project-dir ${project.projectDir}\``,
),
);
} else {
checks.push(check('pass', checkId(connectionId), label, readinessDetail(result)));
}
} catch (error) {
checks.push(
check(
'fail',
checkId(connectionId),
label,
failureDetail(error),
capabilityFailureFix(error, connectionId, project.projectDir),
),
);
}
}
return checks;
}

View file

@ -0,0 +1,614 @@
import type {
KtxLocalProject,
KtxProjectConfig,
KtxProjectConnectionConfig,
KtxProjectEmbeddingConfig,
KtxProjectLlmConfig,
} from '@ktx/context/project';
import type { DoctorCheck } from './doctor.js';
type ProjectStatusLevel = 'ok' | 'warn' | 'fail';
type ProjectVerdict = 'ready' | 'partial' | 'blocked';
interface ProjectStatusLine {
status: ProjectStatusLevel;
detail: string;
fix?: string;
}
interface LlmStatus extends ProjectStatusLine {
backend: string;
model?: string;
}
interface EmbeddingsStatus extends ProjectStatusLine {
backend: string;
model?: string;
dimensions?: number;
}
interface ConnectionStatus extends ProjectStatusLine {
name: string;
driver: string;
}
interface PipelineStatus {
adapters: string[];
enrichmentMode: string;
relationshipsEnabled: boolean;
relationshipsLlmProposals: boolean;
relationshipsValidationRequired: boolean;
agentEnabled: boolean;
agentTools: string[];
agentMaxIterations: number;
}
interface StorageStatus {
state: string;
search: string;
gitAutoCommit: boolean;
gitAuthor: string;
}
interface WarningItem {
message: string;
fix?: string;
}
export interface ProjectStatus {
projectName: string;
projectDir: string;
llm: LlmStatus;
embeddings: EmbeddingsStatus;
storage: StorageStatus;
connections: ConnectionStatus[];
pipeline: PipelineStatus;
warnings: WarningItem[];
verdict: ProjectVerdict;
verdictReason: string;
nextActions: string[];
promptCaching?: { enabled: boolean; systemTtl?: string; toolsTtl?: string; historyTtl?: string };
workUnits?: { stepBudget: number; maxConcurrency: number; failureMode: string };
memoryAutoCommit: boolean;
relationshipsDetail?: {
acceptThreshold: number;
reviewThreshold: number;
maxLlmTablesPerBatch: number;
validationConcurrency: number;
};
}
function resolveRef(value: unknown, env: NodeJS.ProcessEnv): { resolved: string; via: 'literal' | 'env' | 'file' | 'missing' } {
if (typeof value !== 'string') return { resolved: '', via: 'missing' };
const trimmed = value.trim();
if (trimmed.length === 0) return { resolved: '', via: 'missing' };
if (trimmed.startsWith('env:')) {
const name = trimmed.slice(4).trim();
const v = env[name];
return v && v.trim().length > 0 ? { resolved: v, via: 'env' } : { resolved: '', via: 'missing' };
}
if (trimmed.startsWith('file:')) {
return { resolved: trimmed.slice(5), via: 'file' };
}
return { resolved: trimmed, via: 'literal' };
}
function envHint(value: unknown): string | undefined {
if (typeof value === 'string' && value.trim().startsWith('env:')) {
return value.trim().slice(4).trim();
}
return undefined;
}
function buildLlmStatus(config: KtxProjectLlmConfig, env: NodeJS.ProcessEnv): LlmStatus {
const backend = config.provider.backend;
const model = config.models?.default;
if (backend === 'none') {
return {
backend,
model,
status: 'fail',
detail: 'no LLM configured — ktx ask will not work',
fix: 'Run: ktx setup (choose an LLM provider)',
};
}
if (backend === 'anthropic') {
const ref = config.provider.anthropic?.api_key;
const resolved = resolveRef(ref, env);
if (resolved.resolved.length > 0) {
return { backend, model, status: 'ok', detail: `key set${resolved.via === 'env' ? ` (env)` : ''}` };
}
if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_API_KEY.trim().length > 0) {
return { backend, model, status: 'ok', detail: 'key set (env: ANTHROPIC_API_KEY)' };
}
const hint = envHint(ref);
return {
backend,
model,
status: 'warn',
detail: hint ? `key missing (env: ${hint})` : 'key missing',
fix: hint ? `Set ${hint}` : 'Set ANTHROPIC_API_KEY or rerun `ktx setup`',
};
}
if (backend === 'vertex') {
const project = config.provider.vertex?.project;
if (project && project.length > 0) {
return { backend, model, status: 'ok', detail: `project=${project}` };
}
return { backend, model, status: 'warn', detail: 'vertex project not configured', fix: 'Rerun `ktx setup`' };
}
if (backend === 'gateway') {
const ref = config.provider.gateway?.api_key;
const resolved = resolveRef(ref, env);
if (resolved.resolved.length > 0) {
return { backend, model, status: 'ok', detail: 'key set' };
}
const hint = envHint(ref);
return {
backend,
model,
status: 'warn',
detail: hint ? `key missing (env: ${hint})` : 'key missing',
fix: hint ? `Set ${hint}` : 'Set the gateway api_key or rerun `ktx setup`',
};
}
return { backend, model, status: 'warn', detail: 'unknown LLM backend' };
}
function buildEmbeddingsStatus(config: KtxProjectEmbeddingConfig, env: NodeJS.ProcessEnv): EmbeddingsStatus {
const backend = config.backend;
const model = config.model;
const dimensions = config.dimensions;
if (backend === 'none') {
return {
backend,
model,
dimensions,
status: 'warn',
detail: 'disabled — semantic search will be skipped',
};
}
if (backend === 'deterministic') {
return {
backend,
model,
dimensions,
status: 'warn',
detail: 'deterministic — semantic search degraded (lexical/dictionary lanes still work)',
};
}
if (backend === 'openai') {
const ref = config.openai?.api_key;
const resolved = resolveRef(ref, env);
if (resolved.resolved.length > 0 || (env.OPENAI_API_KEY && env.OPENAI_API_KEY.trim().length > 0)) {
return { backend, model, dimensions, status: 'ok', detail: 'key set' };
}
const hint = envHint(ref);
return {
backend,
model,
dimensions,
status: 'warn',
detail: hint ? `key missing (env: ${hint})` : 'key missing',
fix: hint ? `Set ${hint}` : 'Set OPENAI_API_KEY or rerun `ktx setup`',
};
}
if (backend === 'sentence-transformers') {
const url = config.sentenceTransformers?.base_url;
if (typeof url === 'string' && url.length > 0) {
return { backend, model, dimensions, status: 'ok', detail: `service: ${url}` };
}
return {
backend,
model,
dimensions,
status: 'warn',
detail: 'no base_url configured',
fix: 'Rerun `ktx setup`',
};
}
return { backend, model, dimensions, status: 'warn', detail: 'unknown embedding backend' };
}
function buildConnectionStatus(
name: string,
conn: KtxProjectConnectionConfig,
env: NodeJS.ProcessEnv,
): ConnectionStatus {
const driver = (conn.driver ?? 'unknown').toLowerCase();
const ok = (detail: string): ConnectionStatus => ({ name, driver, status: 'ok', detail });
const warn = (detail: string, fix?: string): ConnectionStatus => ({ name, driver, status: 'warn', detail, fix });
switch (driver) {
case 'postgres':
case 'postgresql':
case 'mysql':
case 'clickhouse':
case 'sqlserver': {
const urlRef = resolveRef(conn.url, env);
if (urlRef.resolved.length > 0) return ok(`url configured`);
if (typeof (conn as Record<string, unknown>).host === 'string') return ok('host configured');
const hint = envHint(conn.url);
return warn(hint ? `url missing (env: ${hint})` : 'url not set', hint ? `Set ${hint}` : 'Rerun `ktx setup`');
}
case 'snowflake': {
const account = (conn as Record<string, unknown>).account;
if (typeof account === 'string' && account.length > 0) return ok(`account: ${account}`);
return warn('account not set', 'Rerun `ktx setup`');
}
case 'bigquery': {
const cred = resolveRef((conn as Record<string, unknown>).credentials_json, env);
if (cred.resolved.length > 0) return ok('credentials configured');
const hint = envHint((conn as Record<string, unknown>).credentials_json);
return warn(hint ? `credentials missing (env: ${hint})` : 'credentials not set', hint ? `Set ${hint}` : 'Rerun `ktx setup`');
}
case 'sqlite': {
const path = (conn as Record<string, unknown>).path;
if (typeof path === 'string' && path.length > 0) return ok(`path: ${path}`);
return warn('path not set', 'Rerun `ktx setup`');
}
case 'notion': {
const tokenRef =
(conn as Record<string, unknown>).auth_token_ref ??
(conn as Record<string, unknown>).auth_token;
const resolved = resolveRef(tokenRef, env);
if (resolved.resolved.length > 0) return ok('auth token configured');
const hint = envHint(tokenRef);
return warn(hint ? `auth token missing (env: ${hint})` : 'auth token not set', hint ? `Set ${hint}` : 'Rerun `ktx setup`');
}
case 'dbt':
case 'dbt-core':
case 'dbt-cloud': {
const repoUrl =
(conn as Record<string, unknown>).repoUrl ??
(conn as Record<string, unknown>).repo_url;
if (typeof repoUrl === 'string' && repoUrl.length > 0) return ok(`repo: ${repoUrl}`);
return warn('repoUrl not set', 'Rerun `ktx setup`');
}
case 'metabase': {
const url = (conn as Record<string, unknown>).url ?? (conn as Record<string, unknown>).base_url;
if (typeof url === 'string' && url.length > 0) return ok(`url: ${url}`);
return warn('url not set', 'Rerun `ktx setup`');
}
case 'looker':
case 'lookml': {
const url = (conn as Record<string, unknown>).base_url ?? (conn as Record<string, unknown>).url;
if (typeof url === 'string' && url.length > 0) return ok(`url: ${url}`);
return warn('base_url not set', 'Rerun `ktx setup`');
}
case 'metricflow': {
const repoUrl = (conn as Record<string, unknown>).repoUrl ?? (conn as Record<string, unknown>).repo_url;
if (typeof repoUrl === 'string' && repoUrl.length > 0) return ok(`repo: ${repoUrl}`);
return warn('repoUrl not set', 'Rerun `ktx setup`');
}
default:
return { name, driver, status: 'ok', detail: 'configured' };
}
}
const ADAPTER_DRIVER_REQUIREMENT: Record<string, string[]> = {
'live-database': ['postgres', 'postgresql', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'sqlserver'],
dbt: ['dbt', 'dbt-core', 'dbt-cloud'],
notion: ['notion'],
metabase: ['metabase'],
looker: ['looker', 'lookml'],
lookml: ['looker', 'lookml'],
metricflow: ['metricflow'],
};
function buildPipelineStatus(config: KtxProjectConfig): PipelineStatus {
return {
adapters: config.ingest.adapters,
enrichmentMode: config.scan.enrichment.mode,
relationshipsEnabled: config.scan.relationships.enabled,
relationshipsLlmProposals: config.scan.relationships.llmProposals,
relationshipsValidationRequired: config.scan.relationships.validationRequiredForManifest,
agentEnabled: config.agent.run_research.enabled,
agentTools: config.agent.run_research.default_toolset,
agentMaxIterations: config.agent.run_research.max_iterations,
};
}
function buildStorageStatus(config: KtxProjectConfig): StorageStatus {
return {
state: config.storage.state,
search: config.storage.search,
gitAutoCommit: config.storage.git.auto_commit,
gitAuthor: config.storage.git.author,
};
}
function buildWarnings(
config: KtxProjectConfig,
connections: ConnectionStatus[],
llm: LlmStatus,
embeddings: EmbeddingsStatus,
): WarningItem[] {
const warnings: WarningItem[] = [];
for (const adapter of config.ingest.adapters) {
const requiredDrivers = ADAPTER_DRIVER_REQUIREMENT[adapter];
if (!requiredDrivers) continue;
const hasMatching = connections.some((c) => requiredDrivers.includes(c.driver));
if (!hasMatching) {
warnings.push({
message: `Adapter "${adapter}" is enabled but no connection of type ${requiredDrivers.slice(0, 2).join('/')} is configured.`,
fix: 'Rerun `ktx setup` to add a connection, or remove the adapter from ingest.adapters.',
});
}
}
if (config.agent.run_research.enabled && llm.backend === 'none') {
warnings.push({
message: 'Research agent is enabled but LLM is not configured.',
fix: 'Set up an LLM provider via `ktx setup` or disable agent.run_research.enabled.',
});
}
if (embeddings.backend === 'none' && config.ingest.adapters.includes('live-database')) {
warnings.push({
message: 'Semantic search is off (embeddings backend = none). Lexical/dictionary lanes still work.',
});
}
return warnings;
}
function buildVerdict(
llm: LlmStatus,
embeddings: EmbeddingsStatus,
connections: ConnectionStatus[],
warnings: WarningItem[],
): { verdict: ProjectVerdict; reason: string; nextActions: string[] } {
if (llm.status === 'fail') {
return {
verdict: 'blocked',
reason: 'LLM not configured — `ktx ask` will not work.',
nextActions: ['ktx setup'],
};
}
const reasons: string[] = [];
if (llm.status === 'warn') reasons.push('LLM credentials missing');
if (embeddings.status === 'warn') {
if (embeddings.backend === 'deterministic' || embeddings.backend === 'none') {
reasons.push('semantic search disabled');
} else {
reasons.push('embedding credentials missing');
}
}
const missing = connections.filter((c) => c.status !== 'ok').length;
if (missing > 0) reasons.push(`${missing} connection${missing === 1 ? '' : 's'} need configuration`);
if (warnings.length > 0) reasons.push(`${warnings.length} config warning${warnings.length === 1 ? '' : 's'}`);
if (reasons.length === 0) {
return {
verdict: 'ready',
reason: 'Ready.',
nextActions: ['ktx scan', 'ktx wiki', 'ktx sl ask "…"'],
};
}
return {
verdict: 'partial',
reason: `Partially ready — ${reasons.join('; ')}.`,
nextActions: ['ktx setup'],
};
}
export interface BuildProjectStatusOptions {
env?: NodeJS.ProcessEnv;
}
export function buildProjectStatus(project: KtxLocalProject, options: BuildProjectStatusOptions = {}): ProjectStatus {
const env = options.env ?? process.env;
const config = project.config;
const llm = buildLlmStatus(config.llm, env);
const embeddings = buildEmbeddingsStatus(config.ingest.embeddings, env);
const storage = buildStorageStatus(config);
const connections = Object.entries(config.connections).map(([name, conn]) =>
buildConnectionStatus(name, conn, env),
);
const pipeline = buildPipelineStatus(config);
const warnings = buildWarnings(config, connections, llm, embeddings);
const { verdict, reason, nextActions } = buildVerdict(llm, embeddings, connections, warnings);
return {
projectName: config.project,
projectDir: project.projectDir,
llm,
embeddings,
storage,
connections,
pipeline,
warnings,
verdict,
verdictReason: reason,
nextActions,
promptCaching: config.llm.promptCaching
? {
enabled: config.llm.promptCaching.enabled ?? false,
systemTtl: config.llm.promptCaching.systemTtl,
toolsTtl: config.llm.promptCaching.toolsTtl,
historyTtl: config.llm.promptCaching.historyTtl,
}
: undefined,
workUnits: {
stepBudget: config.ingest.workUnits.stepBudget,
maxConcurrency: config.ingest.workUnits.maxConcurrency,
failureMode: config.ingest.workUnits.failureMode,
},
memoryAutoCommit: config.memory.auto_commit,
relationshipsDetail: {
acceptThreshold: config.scan.relationships.acceptThreshold,
reviewThreshold: config.scan.relationships.reviewThreshold,
maxLlmTablesPerBatch: config.scan.relationships.maxLlmTablesPerBatch,
validationConcurrency: config.scan.relationships.validationConcurrency,
},
};
}
// ─── Rendering ──────────────────────────────────────────────────────────────
const SYMBOL: Record<ProjectStatusLevel, string> = { ok: '✓', warn: '⚠', fail: '✗' };
function ansi(useColor: boolean, code: string, text: string, closer = '39'): string {
return useColor ? `\u001b[${code}m${text}\u001b[${closer}m` : text;
}
function colorFor(level: ProjectStatusLevel): string {
return level === 'ok' ? '32' : level === 'warn' ? '33' : '31';
}
function abbreviateHome(filePath: string, env: NodeJS.ProcessEnv): string {
const home = env.HOME;
if (home && (filePath === home || filePath.startsWith(`${home}/`))) {
return filePath === home ? '~' : `~${filePath.slice(home.length)}`;
}
return filePath;
}
export interface RenderProjectStatusOptions {
verbose?: boolean;
useColor?: boolean;
durationMs?: number;
toolchainChecks?: DoctorCheck[];
env?: NodeJS.ProcessEnv;
}
export function renderProjectStatus(status: ProjectStatus, options: RenderProjectStatusOptions = {}): string {
const verbose = options.verbose ?? false;
const useColor = options.useColor ?? false;
const env = options.env ?? process.env;
const dim = (s: string) => ansi(useColor, '2', s, '22');
const bold = (s: string) => ansi(useColor, '1', s, '22');
const color = (level: ProjectStatusLevel, s: string) => ansi(useColor, colorFor(level), s);
const sym = (level: ProjectStatusLevel) => color(level, SYMBOL[level]);
const lines: string[] = [];
const dirStr = abbreviateHome(status.projectDir, env);
lines.push(`${bold('KTX status')} ${dim('·')} ${status.projectName} ${dim(`(${dirStr})`)}`);
lines.push('');
const labelPad = 'Connections'.length;
const label = (text: string) => text.padEnd(labelPad);
// Core readiness rows
const llmDetail = [status.llm.backend, status.llm.model].filter(Boolean).join(` ${dim('·')} `);
lines.push(` ${label('LLM')} ${llmDetail} ${sym(status.llm.status)} ${dim(status.llm.detail)}`);
const embedParts = [status.embeddings.backend];
if (status.embeddings.model) embedParts.push(status.embeddings.model);
const embedDim = status.embeddings.dimensions ? `(${status.embeddings.dimensions}d)` : '';
const embedDetail = `${embedParts.join(` ${dim('·')} `)}${embedDim ? ` ${embedDim}` : ''}`;
lines.push(` ${label('Embeddings')} ${embedDetail} ${sym(status.embeddings.status)} ${dim(status.embeddings.detail)}`);
lines.push(` ${label('Storage')} ${dim(`${status.storage.state} (state) · ${status.storage.search} (search)`)}`);
lines.push('');
// Connections
if (status.connections.length === 0) {
lines.push(` ${bold('Connections')} ${dim('(none)')}`);
lines.push(` ${dim('No connections configured. Run `ktx setup` to add one.')}`);
} else {
lines.push(` ${bold('Connections')} ${dim(`(${status.connections.length})`)}`);
const nameWidth = Math.max(...status.connections.map((c) => c.name.length));
const driverWidth = Math.max(...status.connections.map((c) => c.driver.length));
for (const conn of status.connections) {
lines.push(
` ${sym(conn.status)} ${conn.name.padEnd(nameWidth)} ${dim(conn.driver.padEnd(driverWidth))} ${conn.detail}`,
);
if (conn.fix && conn.status !== 'ok') {
const indent = 6 + nameWidth + 3 + driverWidth + 3;
lines.push(`${' '.repeat(indent)}${dim(`${conn.fix}`)}`);
}
}
}
lines.push('');
// Pipeline
lines.push(` ${bold('Pipeline')}`);
const pipelineLabelWidth = Math.max('Adapters'.length, 'Enrichment'.length, 'Research agent'.length);
const pLabel = (text: string) => text.padEnd(pipelineLabelWidth);
lines.push(` ${pLabel('Adapters')} ${status.pipeline.adapters.length > 0 ? status.pipeline.adapters.join(', ') : dim('(none)')}`);
const enrichmentDetail = [`${status.pipeline.enrichmentMode} mode`];
if (status.pipeline.relationshipsEnabled) {
const bits = ['relationships on'];
if (status.pipeline.relationshipsLlmProposals) bits.push('LLM proposals');
if (status.pipeline.relationshipsValidationRequired) bits.push('validation required');
enrichmentDetail.push(bits.join(', '));
} else {
enrichmentDetail.push('relationships off');
}
lines.push(` ${pLabel('Enrichment')} ${enrichmentDetail.join(` ${dim('·')} `)}`);
const agentDetail = status.pipeline.agentEnabled
? `enabled ${dim(`(${status.pipeline.agentTools.length} tool${status.pipeline.agentTools.length === 1 ? '' : 's'})`)}`
: dim('disabled');
lines.push(` ${pLabel('Research agent')} ${agentDetail}`);
lines.push('');
// Warnings
if (status.warnings.length > 0) {
lines.push(` ${bold('Warnings')}`);
for (const w of status.warnings) {
lines.push(` ${color('warn', SYMBOL.warn)} ${w.message}`);
if (w.fix) lines.push(` ${dim(`${w.fix}`)}`);
}
lines.push('');
}
// Verbose extras
if (verbose) {
if (options.toolchainChecks && options.toolchainChecks.length > 0) {
lines.push(` ${bold('Toolchain')}`);
for (const check of options.toolchainChecks) {
const lv: ProjectStatusLevel = check.status === 'pass' ? 'ok' : check.status === 'warn' ? 'warn' : 'fail';
lines.push(` ${sym(lv)} ${check.label}: ${check.detail}`);
if (check.fix && lv !== 'ok') lines.push(` ${dim(`${check.fix}`)}`);
}
lines.push('');
}
if (status.promptCaching) {
const pc = status.promptCaching;
const bits = [`enabled=${pc.enabled}`];
if (pc.systemTtl) bits.push(`system=${pc.systemTtl}`);
if (pc.toolsTtl) bits.push(`tools=${pc.toolsTtl}`);
if (pc.historyTtl) bits.push(`history=${pc.historyTtl}`);
lines.push(` ${bold('Prompt caching')} ${dim(bits.join(', '))}`);
}
if (status.workUnits) {
const wu = status.workUnits;
lines.push(` ${bold('Work units')} ${dim(`stepBudget=${wu.stepBudget}, maxConcurrency=${wu.maxConcurrency}, failureMode=${wu.failureMode}`)}`);
}
if (status.relationshipsDetail) {
const r = status.relationshipsDetail;
lines.push(
` ${bold('Relationships')} ${dim(`accept=${r.acceptThreshold}, review=${r.reviewThreshold}, maxLlmTables=${r.maxLlmTablesPerBatch}, concurrency=${r.validationConcurrency}`)}`,
);
}
lines.push(
` ${bold('Agent')} ${dim(`max_iterations=${status.pipeline.agentMaxIterations}, tools=${status.pipeline.agentTools.join(', ') || '(none)'}`)}`,
);
lines.push(` ${bold('Memory')} ${dim(`auto_commit=${status.memoryAutoCommit}`)}`);
lines.push(
` ${bold('Git')} ${dim(`auto_commit=${status.storage.gitAutoCommit}, author=${status.storage.gitAuthor}`)}`,
);
lines.push('');
}
// Verdict + next steps
const verdictLevel: ProjectStatusLevel =
status.verdict === 'ready' ? 'ok' : status.verdict === 'partial' ? 'warn' : 'fail';
const duration = options.durationMs !== undefined ? ` ${dim(`(${(options.durationMs / 1000).toFixed(2)}s)`)}` : '';
if (status.verdict === 'ready') {
const hint = ` ${dim('Try:')} ${status.nextActions.join(dim(' · '))}`;
lines.push(`${color(verdictLevel, status.verdictReason)}${hint}${duration}`);
} else {
const hint = status.nextActions.length > 0 ? ` ${dim('Next:')} ${status.nextActions.join(dim(' · '))}` : '';
lines.push(`${color(verdictLevel, status.verdictReason)}${hint}${duration}`);
}
lines.push('');
return lines.join('\n');
}