mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
Merge origin/main into merge-scan-into-ingest-v1
This commit is contained in:
commit
857aaeeba0
7 changed files with 1266 additions and 775 deletions
|
|
@ -16,8 +16,9 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
|
|||
.command('status')
|
||||
.description('Check current KTX setup and project readiness')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('-v, --verbose', 'Show every check, including passing ones', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (options: { json?: boolean; input?: boolean }, command) => {
|
||||
.action(async (options: { json?: boolean; verbose?: boolean; input?: boolean }, command) => {
|
||||
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor;
|
||||
const explicitOrEnvProjectDir = resolveCommandProjectDirOverride(command);
|
||||
const nearestProjectDir = explicitOrEnvProjectDir ? undefined : findNearestKtxProjectDir(process.cwd());
|
||||
|
|
@ -27,6 +28,7 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
|
|||
{
|
||||
command: 'setup',
|
||||
outputMode: outputMode(options),
|
||||
verbose: options.verbose === true,
|
||||
...inputMode(options),
|
||||
},
|
||||
context.io,
|
||||
|
|
@ -40,6 +42,7 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
|
|||
command: 'project',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
outputMode: outputMode(options),
|
||||
verbose: options.verbose === true,
|
||||
...inputMode(options),
|
||||
},
|
||||
context.io,
|
||||
|
|
|
|||
|
|
@ -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,53 +30,65 @@ 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('prints exact fixes for failing setup checks', () => {
|
||||
it('shows the failing check and its fix in plain output', () => {
|
||||
const checks: DoctorCheck[] = [
|
||||
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
|
||||
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127', group: 'toolchain' },
|
||||
{
|
||||
id: 'native-sqlite',
|
||||
label: 'Native SQLite',
|
||||
status: 'fail',
|
||||
detail: 'Cannot load better-sqlite3',
|
||||
fix: 'Run: pnpm run native:rebuild',
|
||||
group: 'toolchain',
|
||||
},
|
||||
];
|
||||
|
||||
expect(formatDoctorReport({ title: 'KTX setup doctor', checks })).toBe(
|
||||
[
|
||||
'KTX setup doctor',
|
||||
'PASS Node 22+: v22.16.0 ABI 127',
|
||||
'FAIL Native SQLite: Cannot load better-sqlite3',
|
||||
' Fix: Run: pnpm run native:rebuild',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const output = formatDoctorReport({ title: 'KTX status', checks });
|
||||
expect(output).toContain('KTX status');
|
||||
expect(output).toContain('✗ Environment');
|
||||
expect(output).toContain('1 of 2 need attention');
|
||||
expect(output).toContain('✗ Native SQLite: Cannot load better-sqlite3');
|
||||
expect(output).toContain('→ Run: pnpm run native:rebuild');
|
||||
expect(output).toContain('1 issue to fix.');
|
||||
});
|
||||
|
||||
it('lists what was checked when a group has all passing checks', () => {
|
||||
const checks: DoctorCheck[] = [
|
||||
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0', group: 'toolchain' },
|
||||
{ id: 'pnpm', label: 'pnpm 10.20+', status: 'pass', detail: '10.28.0', group: 'toolchain' },
|
||||
];
|
||||
|
||||
const output = formatDoctorReport({ title: 'KTX status', checks });
|
||||
expect(output).toContain('✓ Environment');
|
||||
expect(output).toContain('Node 22+ · pnpm 10.20+');
|
||||
expect(output).not.toContain('v22.16.0');
|
||||
expect(output).toContain('Everything ready.');
|
||||
});
|
||||
|
||||
it('shows the underlying detail for a single-check group on the group line', () => {
|
||||
const checks: DoctorCheck[] = [
|
||||
{
|
||||
id: 'semantic-search-embeddings',
|
||||
label: 'Semantic search embeddings',
|
||||
status: 'pass',
|
||||
detail: 'openai/text-embedding-3-small (1536d) probe succeeded',
|
||||
group: 'search',
|
||||
},
|
||||
];
|
||||
|
||||
const output = formatDoctorReport({ title: 'KTX status', checks });
|
||||
expect(output).toContain('✓ Semantic search');
|
||||
expect(output).toContain('openai/text-embedding-3-small (1536d) probe succeeded');
|
||||
});
|
||||
|
||||
it('lists every check in verbose mode', () => {
|
||||
const checks: DoctorCheck[] = [
|
||||
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0', group: 'toolchain' },
|
||||
];
|
||||
|
||||
const output = formatDoctorReport({ title: 'KTX status', checks }, { verbose: true });
|
||||
expect(output).toContain('✓ Node 22+: v22.16.0');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -127,6 +138,7 @@ describe('runSetupDoctorChecks', () => {
|
|||
status: 'fail',
|
||||
detail: 'pnpm not found',
|
||||
fix: 'Run: corepack enable && corepack prepare pnpm@10.28.0 --activate',
|
||||
group: 'toolchain',
|
||||
});
|
||||
expect(checks).toContainEqual({
|
||||
id: 'package-build',
|
||||
|
|
@ -134,6 +146,7 @@ describe('runSetupDoctorChecks', () => {
|
|||
status: 'fail',
|
||||
detail: 'Missing packages/cli/dist/bin.js',
|
||||
fix: 'Run: pnpm run build',
|
||||
group: 'toolchain',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -154,9 +167,11 @@ describe('runSetupDoctorChecks', () => {
|
|||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxDoctor({ command: 'setup', outputMode: 'plain', inputMode: 'disabled' }, testIo.io, {
|
||||
runSetupChecks: async () => checks,
|
||||
}),
|
||||
runKtxDoctor(
|
||||
{ command: 'setup', outputMode: 'plain', inputMode: 'disabled', verbose: true },
|
||||
testIo.io,
|
||||
{ runSetupChecks: async () => checks },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(checks).toContainEqual({
|
||||
|
|
@ -165,8 +180,9 @@ describe('runSetupDoctorChecks', () => {
|
|||
status: 'warn',
|
||||
detail: 'spawn corepack ENOENT',
|
||||
fix: 'Run: corepack enable',
|
||||
group: 'toolchain',
|
||||
});
|
||||
expect(testIo.stdout()).toContain('WARN Corepack: spawn corepack ENOENT');
|
||||
expect(testIo.stdout()).toContain('⚠ Corepack: spawn corepack ENOENT');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -204,12 +220,45 @@ describe('runKtxDoctor', () => {
|
|||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stdout()).toContain('KTX setup doctor');
|
||||
expect(testIo.stdout()).toContain('FAIL TypeScript package build: Missing packages/cli/dist/bin.js');
|
||||
expect(testIo.stdout()).toContain('Fix: Run: pnpm run build');
|
||||
expect(testIo.stdout()).toContain('KTX status');
|
||||
expect(testIo.stdout()).toContain('No project here yet.');
|
||||
expect(testIo.stdout()).toContain('Before you can run');
|
||||
expect(testIo.stdout()).toContain('✗ TypeScript package build: Missing packages/cli/dist/bin.js');
|
||||
expect(testIo.stdout()).toContain('→ Run: pnpm run build');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('leads with `ktx setup` and hides toolchain warnings when no project exists', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxDoctor(
|
||||
{ command: 'setup', outputMode: 'plain', inputMode: 'disabled' },
|
||||
testIo.io,
|
||||
{
|
||||
runSetupChecks: async () => [
|
||||
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0', group: 'toolchain' },
|
||||
{
|
||||
id: 'corepack',
|
||||
label: 'Corepack',
|
||||
status: 'warn',
|
||||
detail: 'spawn corepack ENOENT',
|
||||
fix: 'Run: corepack enable',
|
||||
group: 'toolchain',
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const out = testIo.stdout();
|
||||
expect(out).toContain('No project here yet.');
|
||||
expect(out).toContain('Run');
|
||||
expect(out).toContain('ktx setup');
|
||||
expect(out).not.toContain('Corepack');
|
||||
expect(out).not.toContain('Node 22+');
|
||||
});
|
||||
|
||||
it('prints JSON setup report', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
|
|
@ -226,12 +275,13 @@ describe('runKtxDoctor', () => {
|
|||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(testIo.stdout())).toEqual({
|
||||
title: 'KTX setup doctor',
|
||||
title: 'KTX status',
|
||||
checks: [{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('runs project checks against a valid ktx.yaml', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
|
|
@ -240,33 +290,48 @@ 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 project doctor');
|
||||
expect(testIo.stdout()).toContain('PASS Project config: warehouse');
|
||||
expect(testIo.stdout()).toContain('PASS Connections: 1 configured');
|
||||
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 query-history readiness in project doctor output', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
process.env.OPENAI_API_KEY = 'test-key';
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
|
|
@ -278,182 +343,118 @@ describe('runKtxDoctor', () => {
|
|||
' context:',
|
||||
' queryHistory:',
|
||||
' enabled: true',
|
||||
'llm:',
|
||||
' provider:',
|
||||
' backend: anthropic',
|
||||
'ingest:',
|
||||
' adapters:',
|
||||
' - live-database',
|
||||
' - historic-sql',
|
||||
' embeddings:',
|
||||
' backend: openai',
|
||||
' model: text-embedding-3-small',
|
||||
' dimensions: 1536',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const testIo = makeIo();
|
||||
const runHistoricSqlDoctorChecks = vi.fn(async () => [
|
||||
{
|
||||
id: 'query-history-postgres-warehouse',
|
||||
label: 'Postgres query history (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',
|
||||
},
|
||||
]);
|
||||
let probeCalls = 0;
|
||||
|
||||
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' },
|
||||
],
|
||||
runHistoricSqlDoctorChecks,
|
||||
postgresQueryHistoryProbe: async () => {
|
||||
probeCalls += 1;
|
||||
return {
|
||||
pgServerVersion: 'PostgreSQL 16.4',
|
||||
warnings: [],
|
||||
info: [
|
||||
'pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(runHistoricSqlDoctorChecks).toHaveBeenCalledTimes(1);
|
||||
expect(testIo.stdout()).toContain('PASS Postgres query history (warehouse): pg_stat_statements ready');
|
||||
expect(testIo.stdout()).toContain('info: pg_stat_statements.max is 1000');
|
||||
expect(testIo.stdout()).not.toContain('Fix: Update the Postgres parameter group or config');
|
||||
const out = testIo.stdout();
|
||||
expect(probeCalls).toBe(1);
|
||||
expect(out).toContain('Query history');
|
||||
expect(out).toContain('warehouse');
|
||||
expect(out).toContain('pg_stat_statements ready (PostgreSQL 16.4)');
|
||||
expect(out).toContain('info: pg_stat_statements.max is 1000');
|
||||
expect(out).not.toContain('Update the Postgres parameter group or config');
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
});
|
||||
|
||||
it('returns blocked verdict when LLM is not configured', async () => {
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlite',
|
||||
' path: ./warehouse.db',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxDoctor(
|
||||
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
||||
testIo.io,
|
||||
{},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
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('WARN Semantic search embeddings: 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(
|
||||
`Fix: 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' },
|
||||
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(
|
||||
'PASS 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`,
|
||||
});
|
||||
expect(testIo.stdout()).toContain('Embeddings');
|
||||
expect(testIo.stdout()).toContain('deterministic');
|
||||
expect(testIo.stdout()).toContain('semantic search degraded');
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,15 +4,14 @@ 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';
|
||||
import type { BuildProjectStatusOptions } from './status-project.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type DoctorStatus = 'pass' | 'warn' | 'fail';
|
||||
type KtxDoctorOutputMode = 'plain' | 'json';
|
||||
type KtxDoctorInputMode = 'auto' | 'disabled';
|
||||
type DoctorGroup = 'toolchain' | 'project' | 'search' | 'history';
|
||||
|
||||
export interface DoctorCheck {
|
||||
id: string;
|
||||
|
|
@ -20,6 +19,7 @@ export interface DoctorCheck {
|
|||
status: DoctorStatus;
|
||||
detail: string;
|
||||
fix?: string;
|
||||
group?: DoctorGroup;
|
||||
}
|
||||
|
||||
interface DoctorReport {
|
||||
|
|
@ -28,11 +28,22 @@ interface DoctorReport {
|
|||
}
|
||||
|
||||
export type KtxDoctorArgs =
|
||||
| { command: 'setup'; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode }
|
||||
| { command: 'project'; projectDir: string; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode };
|
||||
| {
|
||||
command: 'setup';
|
||||
outputMode: KtxDoctorOutputMode;
|
||||
inputMode?: KtxDoctorInputMode;
|
||||
verbose?: boolean;
|
||||
}
|
||||
| {
|
||||
command: 'project';
|
||||
projectDir: string;
|
||||
outputMode: KtxDoctorOutputMode;
|
||||
inputMode?: KtxDoctorInputMode;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
interface KtxDoctorIo {
|
||||
stdout: { write(chunk: string): void };
|
||||
stdout: { isTTY?: boolean; write(chunk: string): void };
|
||||
stderr: { write(chunk: string): void };
|
||||
}
|
||||
|
||||
|
|
@ -44,20 +55,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 extends BuildProjectStatusOptions {
|
||||
runSetupChecks?: () => Promise<DoctorCheck[]>;
|
||||
runHistoricSqlDoctorChecks?: (project: KtxLocalProject, deps: HistoricSqlDoctorDeps) => Promise<DoctorCheck[]>;
|
||||
}
|
||||
|
||||
function workspaceRootDir(): string {
|
||||
|
|
@ -118,99 +117,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();
|
||||
|
|
@ -304,56 +210,231 @@ export async function runSetupDoctorChecks(deps: SetupDoctorDeps = {}): Promise<
|
|||
);
|
||||
}
|
||||
|
||||
return checks;
|
||||
return checks.map((entry) => ({ ...entry, group: 'toolchain' }));
|
||||
}
|
||||
|
||||
async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): Promise<DoctorCheck[]> {
|
||||
const { loadKtxProject } = await import('@ktx/context/project');
|
||||
const checks: DoctorCheck[] = [];
|
||||
try {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
checks.push(check('pass', 'project-config', 'Project config', project.config.project));
|
||||
const connectionCount = Object.keys(project.config.connections).length;
|
||||
checks.push(
|
||||
connectionCount > 0
|
||||
? check('pass', 'connections', 'Connections', `${connectionCount} configured`)
|
||||
: check(
|
||||
'warn',
|
||||
'connections',
|
||||
'Connections',
|
||||
'0 configured',
|
||||
'Add a connection to ktx.yaml or run `ktx setup`',
|
||||
),
|
||||
);
|
||||
checks.push(check('pass', 'storage', 'Storage', `${project.config.storage.state}/${project.config.storage.search}`));
|
||||
checks.push(check('pass', 'llm-provider', 'LLM provider', project.config.llm.provider.backend));
|
||||
checks.push(await runSemanticSearchEmbeddingCheck(project.config.ingest.embeddings, projectDir, deps));
|
||||
const runHistoricSqlDoctorChecks =
|
||||
deps.runHistoricSqlDoctorChecks ?? (await import('./historic-sql-doctor.js')).runPostgresHistoricSqlDoctorChecks;
|
||||
checks.push(...(await runHistoricSqlDoctorChecks(project, deps)));
|
||||
} catch (error) {
|
||||
checks.push(
|
||||
check(
|
||||
'fail',
|
||||
'project-config',
|
||||
'Project config',
|
||||
failureMessage(error),
|
||||
`Run: ktx init ${projectDir} --name <project-name>`,
|
||||
),
|
||||
);
|
||||
const STATUS_SYMBOL: Record<DoctorStatus, string> = { pass: '✓', warn: '⚠', fail: '✗' };
|
||||
|
||||
const GROUP_ORDER: DoctorGroup[] = ['toolchain', 'project', 'search', 'history'];
|
||||
|
||||
const GROUP_LABEL: Record<DoctorGroup, string> = {
|
||||
toolchain: 'Environment',
|
||||
project: 'Project',
|
||||
search: 'Semantic search',
|
||||
history: 'Query history',
|
||||
};
|
||||
|
||||
function shouldUseColor(io: KtxDoctorIo): boolean {
|
||||
if (io.stdout.isTTY !== true) return false;
|
||||
const env = process.env;
|
||||
return !env.NO_COLOR && env.TERM !== 'dumb' && !env.CI;
|
||||
}
|
||||
|
||||
function styleStatus(useColor: boolean, status: DoctorStatus, text: string): string {
|
||||
if (!useColor) return text;
|
||||
const code = status === 'pass' ? 32 : status === 'warn' ? 33 : 31;
|
||||
return `\u001b[${code}m${text}\u001b[39m`;
|
||||
}
|
||||
|
||||
function styleDim(useColor: boolean, text: string): string {
|
||||
return useColor ? `\u001b[2m${text}\u001b[22m` : text;
|
||||
}
|
||||
|
||||
function styleBold(useColor: boolean, text: string): string {
|
||||
return useColor ? `\u001b[1m${text}\u001b[22m` : text;
|
||||
}
|
||||
|
||||
function groupOf(entry: DoctorCheck): DoctorGroup {
|
||||
return entry.group ?? 'project';
|
||||
}
|
||||
|
||||
function aggregateStatus(checks: DoctorCheck[]): DoctorStatus {
|
||||
if (checks.some((c) => c.status === 'fail')) return 'fail';
|
||||
if (checks.some((c) => c.status === 'warn')) return 'warn';
|
||||
return 'pass';
|
||||
}
|
||||
|
||||
function abbreviateHome(filePath: string | undefined): string | undefined {
|
||||
if (!filePath) return filePath;
|
||||
const home = process.env.HOME;
|
||||
if (home && (filePath === home || filePath.startsWith(`${home}/`))) {
|
||||
return filePath === home ? '~' : `~${filePath.slice(home.length)}`;
|
||||
}
|
||||
return checks;
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function formatDoctorReport(report: DoctorReport): string {
|
||||
const lines = [report.title];
|
||||
for (const item of report.checks) {
|
||||
lines.push(`${item.status.toUpperCase()} ${item.label}: ${item.detail}`);
|
||||
if (item.fix) {
|
||||
lines.push(` Fix: ${item.fix}`);
|
||||
function groupSummaryWhenAllPass(entries: DoctorCheck[]): string {
|
||||
if (entries.length === 1) {
|
||||
const only = entries[0]!;
|
||||
return only.detail || only.label;
|
||||
}
|
||||
return entries.map((c) => c.label).join(' · ');
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
verbose: boolean;
|
||||
useColor: boolean;
|
||||
durationMs?: number;
|
||||
projectName?: string;
|
||||
projectDir?: string;
|
||||
command?: 'setup' | 'project';
|
||||
}
|
||||
|
||||
const NEXT_STEPS_PROJECT = ['ktx scan', 'ktx wiki', 'ktx sl ask "…"'];
|
||||
|
||||
export function formatDoctorReport(report: DoctorReport, options: Partial<RenderOptions> = {}): string {
|
||||
const opts: RenderOptions = {
|
||||
verbose: options.verbose ?? false,
|
||||
useColor: options.useColor ?? false,
|
||||
durationMs: options.durationMs,
|
||||
projectName: options.projectName,
|
||||
projectDir: options.projectDir,
|
||||
command: options.command,
|
||||
};
|
||||
return renderPlainReport(report, opts);
|
||||
}
|
||||
|
||||
function renderSetupReport(report: DoctorReport, options: RenderOptions): string {
|
||||
const { verbose, useColor } = options;
|
||||
const dim = (text: string) => styleDim(useColor, text);
|
||||
const bold = (text: string) => styleBold(useColor, text);
|
||||
const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text);
|
||||
const symbol = (s: DoctorStatus) => status(s, STATUS_SYMBOL[s]);
|
||||
|
||||
const fails = report.checks.filter((c) => c.status === 'fail');
|
||||
const lines: string[] = [];
|
||||
lines.push(bold(report.title));
|
||||
lines.push('');
|
||||
lines.push(` No project here yet.`);
|
||||
lines.push('');
|
||||
|
||||
if (fails.length > 0) {
|
||||
lines.push(` Before you can run ${bold('ktx setup')}, fix this:`);
|
||||
for (const entry of fails) {
|
||||
lines.push(` ${symbol('fail')} ${entry.label}: ${entry.detail}`);
|
||||
if (entry.fix) {
|
||||
lines.push(` ${dim(`→ ${entry.fix}`)}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
} else {
|
||||
lines.push(` Run ${bold('ktx setup')} to get started.`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
lines.push(dim(' Toolchain:'));
|
||||
for (const entry of report.checks) {
|
||||
lines.push(` ${symbol(entry.status)} ${entry.label}: ${entry.detail}`);
|
||||
if (entry.fix && entry.status !== 'pass') {
|
||||
lines.push(` ${dim(`→ ${entry.fix}`)}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderPlainReport(report: DoctorReport, options: RenderOptions): string {
|
||||
if (options.command === 'setup') return renderSetupReport(report, options);
|
||||
const { verbose, useColor, durationMs, projectName, projectDir } = options;
|
||||
const dim = (text: string) => styleDim(useColor, text);
|
||||
const bold = (text: string) => styleBold(useColor, text);
|
||||
const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text);
|
||||
const symbol = (s: DoctorStatus) => status(s, STATUS_SYMBOL[s]);
|
||||
|
||||
const lines: string[] = [];
|
||||
const titleParts: string[] = [bold(report.title)];
|
||||
if (projectName) titleParts.push(projectName);
|
||||
const abbreviatedDir = abbreviateHome(projectDir);
|
||||
const titleLine = titleParts.join(` ${dim('·')} `);
|
||||
const dirSuffix = abbreviatedDir ? ` ${dim(`(${abbreviatedDir})`)}` : '';
|
||||
lines.push(`${titleLine}${dirSuffix}`);
|
||||
lines.push('');
|
||||
|
||||
const groups = new Map<DoctorGroup, DoctorCheck[]>();
|
||||
for (const entry of report.checks) {
|
||||
const group = groupOf(entry);
|
||||
const bucket = groups.get(group) ?? [];
|
||||
bucket.push(entry);
|
||||
groups.set(group, bucket);
|
||||
}
|
||||
|
||||
const orderedGroups: DoctorGroup[] = [];
|
||||
for (const g of GROUP_ORDER) {
|
||||
if (groups.has(g)) orderedGroups.push(g);
|
||||
}
|
||||
for (const g of groups.keys()) {
|
||||
if (!orderedGroups.includes(g)) orderedGroups.push(g);
|
||||
}
|
||||
|
||||
const labelWidth = orderedGroups.reduce(
|
||||
(max, g) => Math.max(max, (GROUP_LABEL[g] ?? g).length),
|
||||
0,
|
||||
);
|
||||
|
||||
for (const group of orderedGroups) {
|
||||
const entries = groups.get(group) ?? [];
|
||||
const head = aggregateStatus(entries);
|
||||
const nonPass = entries.filter((c) => c.status !== 'pass');
|
||||
const label = (GROUP_LABEL[group] ?? group).padEnd(labelWidth);
|
||||
|
||||
if (nonPass.length === 0) {
|
||||
lines.push(` ${symbol(head)} ${label} ${dim(groupSummaryWhenAllPass(entries))}`);
|
||||
if (verbose) {
|
||||
for (const entry of entries) {
|
||||
lines.push(` ${symbol(entry.status)} ${entry.label}: ${entry.detail}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entries.length === 1) {
|
||||
const only = entries[0]!;
|
||||
lines.push(` ${symbol(only.status)} ${label} ${only.detail}`);
|
||||
if (only.fix) {
|
||||
lines.push(` ${' '.repeat(2 + labelWidth + 4)}${dim(`→ ${only.fix}`)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(` ${symbol(head)} ${label} ${dim(`${nonPass.length} of ${entries.length} need attention`)}`);
|
||||
for (const entry of entries) {
|
||||
if (entry.status === 'pass' && !verbose) continue;
|
||||
lines.push(` ${symbol(entry.status)} ${entry.label}: ${entry.detail}`);
|
||||
if (entry.fix) {
|
||||
lines.push(` ${dim(`→ ${entry.fix}`)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
const totalFail = report.checks.filter((c) => c.status === 'fail').length;
|
||||
const totalWarn = report.checks.filter((c) => c.status === 'warn').length;
|
||||
const durationText = durationMs !== undefined ? ` ${dim(`(${(durationMs / 1000).toFixed(2)}s)`)}` : '';
|
||||
|
||||
if (totalFail === 0 && totalWarn === 0) {
|
||||
const hint = ` ${dim('Try:')} ${NEXT_STEPS_PROJECT.join(dim(' · '))}`;
|
||||
lines.push(`${status('pass', 'Everything ready.')}${hint}${durationText}`);
|
||||
} else if (totalFail === 0) {
|
||||
const word = totalWarn === 1 ? 'warning' : 'warnings';
|
||||
lines.push(
|
||||
`${status('warn', `${totalWarn} ${word}.`)} ${dim('Run')} ktx status --verbose ${dim('for full details.')}${durationText}`,
|
||||
);
|
||||
} else {
|
||||
const fWord = totalFail === 1 ? 'issue' : 'issues';
|
||||
const warnSuffix =
|
||||
totalWarn > 0
|
||||
? ` ${dim('·')} ${status('warn', `${totalWarn} ${totalWarn === 1 ? 'warning' : 'warnings'}`)}`
|
||||
: '';
|
||||
lines.push(
|
||||
`${status('fail', `${totalFail} ${fWord} to fix.`)}${warnSuffix}${durationText}`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -361,12 +442,12 @@ function hasFailures(report: DoctorReport): boolean {
|
|||
return report.checks.some((item) => item.status === 'fail');
|
||||
}
|
||||
|
||||
function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io: KtxDoctorIo): void {
|
||||
function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io: KtxDoctorIo, options: RenderOptions): void {
|
||||
if (outputMode === 'json') {
|
||||
io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
io.stdout.write(formatDoctorReport(report));
|
||||
io.stdout.write(renderPlainReport(report, options));
|
||||
}
|
||||
|
||||
export async function runKtxDoctor(
|
||||
|
|
@ -374,18 +455,41 @@ export async function runKtxDoctor(
|
|||
io: KtxDoctorIo = process,
|
||||
deps: KtxDoctorDeps = {},
|
||||
): Promise<number> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const runSetupChecks = deps.runSetupChecks ?? (() => runSetupDoctorChecks());
|
||||
const setupChecks = await runSetupChecks();
|
||||
const report: DoctorReport =
|
||||
args.command === 'setup'
|
||||
? { title: 'KTX setup doctor', checks: setupChecks }
|
||||
: {
|
||||
title: 'KTX project doctor',
|
||||
checks: [...setupChecks, ...(await runProjectChecks(args.projectDir, deps))],
|
||||
};
|
||||
|
||||
writeReport(report, args.outputMode, io);
|
||||
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 = await buildProjectStatus(project, deps);
|
||||
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,
|
||||
command: args.command,
|
||||
};
|
||||
writeReport(report, args.outputMode, io, renderOptions);
|
||||
return hasFailures(report) ? 1 : 0;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
|
|
|
|||
|
|
@ -1,230 +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 query-history connections are enabled', async () => {
|
||||
const checks = await runPostgresHistoricSqlDoctorChecks(
|
||||
projectWithConnections({
|
||||
warehouse: { driver: 'sqlite', path: './warehouse.db' },
|
||||
}),
|
||||
{
|
||||
postgresHistoricSqlProbe: vi.fn<PostgresHistoricSqlDoctorProbe>(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(checks).toEqual([
|
||||
{
|
||||
id: 'query-history-postgres',
|
||||
label: 'Postgres query history',
|
||||
status: 'pass',
|
||||
detail: 'No enabled Postgres query-history 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',
|
||||
context: { queryHistory: { enabled: true } },
|
||||
},
|
||||
}),
|
||||
{ postgresHistoricSqlProbe: probe },
|
||||
);
|
||||
|
||||
expect(probe).toHaveBeenCalledWith({
|
||||
projectDir: '/tmp/ktx-project',
|
||||
connectionId: 'warehouse',
|
||||
connection: {
|
||||
driver: 'postgres',
|
||||
url: 'env:WAREHOUSE_DATABASE_URL',
|
||||
context: { queryHistory: { enabled: true } },
|
||||
},
|
||||
env: process.env,
|
||||
});
|
||||
expect(checks).toEqual([
|
||||
{
|
||||
id: 'query-history-postgres-warehouse',
|
||||
label: 'Postgres query history (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',
|
||||
context: { queryHistory: { enabled: true } },
|
||||
},
|
||||
}),
|
||||
{
|
||||
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: 'query-history-postgres-warehouse',
|
||||
label: 'Postgres query history (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',
|
||||
context: { queryHistory: { enabled: true } },
|
||||
},
|
||||
}),
|
||||
{
|
||||
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: 'query-history-postgres-warehouse',
|
||||
label: 'Postgres query history (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('still checks legacy historicSql blocks before setup migration', 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',
|
||||
readonly: true,
|
||||
historicSql: { enabled: true, dialect: 'postgres' },
|
||||
},
|
||||
}),
|
||||
{ postgresHistoricSqlProbe: probe },
|
||||
);
|
||||
|
||||
expect(checks).toEqual([
|
||||
{
|
||||
id: 'query-history-postgres-warehouse',
|
||||
label: 'Postgres query history (warehouse)',
|
||||
status: 'pass',
|
||||
detail: 'pg_stat_statements ready (PostgreSQL 16.4)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails when a connection has postgres query history but is not a Postgres driver', async () => {
|
||||
const checks = await runPostgresHistoricSqlDoctorChecks(
|
||||
projectWithConnections({
|
||||
warehouse: {
|
||||
driver: 'mysql',
|
||||
url: 'env:WAREHOUSE_DATABASE_URL',
|
||||
context: { queryHistory: { enabled: true } },
|
||||
},
|
||||
}),
|
||||
{
|
||||
postgresHistoricSqlProbe: vi.fn<PostgresHistoricSqlDoctorProbe>(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(checks).toEqual([
|
||||
{
|
||||
id: 'query-history-postgres-warehouse',
|
||||
label: 'Postgres query history (warehouse)',
|
||||
status: 'fail',
|
||||
detail: 'connections.warehouse.context.queryHistory is enabled but driver is mysql',
|
||||
fix: 'Set connections.warehouse.driver to postgres or disable query history 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',
|
||||
context: { queryHistory: { enabled: true } },
|
||||
},
|
||||
}),
|
||||
{
|
||||
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: 'query-history-postgres-warehouse',
|
||||
label: 'Postgres query history (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.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,177 +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 recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function queryHistoryRecord(connection: KtxProjectConnectionConfig): Record<string, unknown> | null {
|
||||
const context = recordValue(connection.context);
|
||||
return recordValue(context?.queryHistory);
|
||||
}
|
||||
|
||||
function legacyHistoricSqlRecord(connection: KtxProjectConnectionConfig): Record<string, unknown> | null {
|
||||
return recordValue(connection.historicSql);
|
||||
}
|
||||
|
||||
function isEnabledPostgresQueryHistory(connection: KtxProjectConnectionConfig): boolean {
|
||||
const queryHistory = queryHistoryRecord(connection);
|
||||
if (queryHistory) {
|
||||
return queryHistory.enabled === true;
|
||||
}
|
||||
const legacy = legacyHistoricSqlRecord(connection);
|
||||
return legacy?.enabled === true && legacy.dialect === 'postgres';
|
||||
}
|
||||
|
||||
function isPostgresDriver(connection: KtxProjectConnectionConfig): boolean {
|
||||
const driver = String(connection.driver ?? '').toLowerCase();
|
||||
return driver === 'postgres' || driver === 'postgresql';
|
||||
}
|
||||
|
||||
function checkId(connectionId: string): string {
|
||||
return `query-history-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 query history 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]) => isEnabledPostgresQueryHistory(connection))
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
|
||||
if (targets.length === 0) {
|
||||
return [
|
||||
check('pass', 'query-history-postgres', 'Postgres query history', 'No enabled Postgres query-history connections'),
|
||||
];
|
||||
}
|
||||
|
||||
const probe = deps.postgresHistoricSqlProbe ?? defaultPostgresHistoricSqlProbe;
|
||||
const env = deps.env ?? process.env;
|
||||
const checks: DoctorCheck[] = [];
|
||||
for (const [connectionId, connection] of targets) {
|
||||
const label = `Postgres query history (${connectionId})`;
|
||||
if (!isPostgresDriver(connection)) {
|
||||
checks.push(
|
||||
check(
|
||||
'fail',
|
||||
checkId(connectionId),
|
||||
label,
|
||||
`connections.${connectionId}.context.queryHistory is enabled but driver is ${String(connection.driver)}`,
|
||||
`Set connections.${connectionId}.driver to postgres or disable query history 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;
|
||||
}
|
||||
|
|
@ -955,7 +955,7 @@ describe('runKtxCli', () => {
|
|||
|
||||
expect(setup).not.toHaveBeenCalled();
|
||||
expect(doctor).toHaveBeenCalledWith(
|
||||
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
|
||||
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled', verbose: false },
|
||||
statusIo.io,
|
||||
);
|
||||
expect(statusIo.stderr()).toBe('');
|
||||
|
|
@ -978,7 +978,7 @@ describe('runKtxCli', () => {
|
|||
await expect(runKtxCli(['status', '--json', '--no-input'], statusIo.io, { doctor })).resolves.toBe(0);
|
||||
|
||||
expect(doctor).toHaveBeenCalledWith(
|
||||
{ command: 'setup', outputMode: 'json', inputMode: 'disabled' },
|
||||
{ command: 'setup', outputMode: 'json', inputMode: 'disabled', verbose: false },
|
||||
statusIo.io,
|
||||
);
|
||||
expect(statusIo.stderr()).toBe('');
|
||||
|
|
|
|||
790
packages/cli/src/status-project.ts
Normal file
790
packages/cli/src/status-project.ts
Normal file
|
|
@ -0,0 +1,790 @@
|
|||
import type {
|
||||
KtxLocalProject,
|
||||
KtxProjectConfig,
|
||||
KtxProjectConnectionConfig,
|
||||
KtxProjectEmbeddingConfig,
|
||||
KtxProjectLlmConfig,
|
||||
} from '@ktx/context/project';
|
||||
import type { PostgresPgssProbeResult } from '@ktx/context/ingest';
|
||||
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 QueryHistoryStatus extends ProjectStatusLine {
|
||||
connection: string;
|
||||
dialect: 'postgres';
|
||||
}
|
||||
|
||||
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[];
|
||||
queryHistory: QueryHistoryStatus[];
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
||||
interface PostgresQueryHistoryProbeInput {
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
connection: KtxProjectConnectionConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
type PostgresQueryHistoryProbe = (
|
||||
input: PostgresQueryHistoryProbeInput,
|
||||
) => Promise<PostgresPgssProbeResult>;
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function queryHistoryRecord(connection: KtxProjectConnectionConfig): Record<string, unknown> | null {
|
||||
const context = recordValue(connection.context);
|
||||
return recordValue(context?.queryHistory);
|
||||
}
|
||||
|
||||
function legacyHistoricSqlRecord(connection: KtxProjectConnectionConfig): Record<string, unknown> | null {
|
||||
return recordValue(connection.historicSql);
|
||||
}
|
||||
|
||||
function isEnabledPostgresQueryHistory(connection: KtxProjectConnectionConfig): boolean {
|
||||
const queryHistory = queryHistoryRecord(connection);
|
||||
if (queryHistory) {
|
||||
return queryHistory.enabled === true;
|
||||
}
|
||||
const legacy = legacyHistoricSqlRecord(connection);
|
||||
return legacy?.enabled === true && legacy.dialect === 'postgres';
|
||||
}
|
||||
|
||||
function isPostgresDriver(connection: KtxProjectConnectionConfig): boolean {
|
||||
const driver = String(connection.driver ?? '').toLowerCase();
|
||||
return driver === 'postgres' || driver === 'postgresql';
|
||||
}
|
||||
|
||||
function queryHistoryFailureFix(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 query history 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: PostgresPgssProbeResult): 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 defaultPostgresQueryHistoryProbe(
|
||||
input: PostgresQueryHistoryProbeInput,
|
||||
): Promise<PostgresPgssProbeResult> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildQueryHistoryStatus(
|
||||
project: KtxLocalProject,
|
||||
options: BuildProjectStatusOptions,
|
||||
): Promise<QueryHistoryStatus[]> {
|
||||
const targets = Object.entries(project.config.connections)
|
||||
.filter(([, connection]) => isEnabledPostgresQueryHistory(connection))
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
|
||||
const probe = options.postgresQueryHistoryProbe ?? defaultPostgresQueryHistoryProbe;
|
||||
const env = options.env ?? process.env;
|
||||
const statuses: QueryHistoryStatus[] = [];
|
||||
for (const [connectionId, connection] of targets) {
|
||||
if (!isPostgresDriver(connection)) {
|
||||
statuses.push({
|
||||
connection: connectionId,
|
||||
dialect: 'postgres',
|
||||
status: 'fail',
|
||||
detail: `connections.${connectionId}.context.queryHistory is enabled but driver is ${String(connection.driver)}`,
|
||||
fix: `Set connections.${connectionId}.driver to postgres or disable query history for this connection`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await probe({ projectDir: project.projectDir, connectionId, connection, env });
|
||||
statuses.push({
|
||||
connection: connectionId,
|
||||
dialect: 'postgres',
|
||||
status: result.warnings.length > 0 ? 'warn' : 'ok',
|
||||
detail: readinessDetail(result),
|
||||
...(result.warnings.length > 0
|
||||
? {
|
||||
fix: `Update the Postgres parameter group or config, then rerun \`ktx status --project-dir ${project.projectDir}\``,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
statuses.push({
|
||||
connection: connectionId,
|
||||
dialect: 'postgres',
|
||||
status: 'fail',
|
||||
detail: failureDetail(error),
|
||||
fix: queryHistoryFailureFix(error, connectionId, project.projectDir),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
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[],
|
||||
queryHistory: QueryHistoryStatus[],
|
||||
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 failedQueryHistory = queryHistory.filter((entry) => entry.status === 'fail').length;
|
||||
if (failedQueryHistory > 0) {
|
||||
return {
|
||||
verdict: 'blocked',
|
||||
reason: `Query history readiness failed for ${failedQueryHistory} connection${failedQueryHistory === 1 ? '' : 's'}.`,
|
||||
nextActions: ['ktx status --verbose'],
|
||||
};
|
||||
}
|
||||
|
||||
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`);
|
||||
const queryHistoryWarnings = queryHistory.filter((entry) => entry.status === 'warn').length;
|
||||
if (queryHistoryWarnings > 0) {
|
||||
reasons.push(`${queryHistoryWarnings} query history warning${queryHistoryWarnings === 1 ? '' : 's'}`);
|
||||
}
|
||||
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;
|
||||
postgresQueryHistoryProbe?: PostgresQueryHistoryProbe;
|
||||
}
|
||||
|
||||
export async function buildProjectStatus(project: KtxLocalProject, options: BuildProjectStatusOptions = {}): Promise<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 queryHistory = await buildQueryHistoryStatus(project, options);
|
||||
const pipeline = buildPipelineStatus(config);
|
||||
const warnings = buildWarnings(config, connections, llm, embeddings);
|
||||
const { verdict, reason, nextActions } = buildVerdict(llm, embeddings, connections, queryHistory, warnings);
|
||||
|
||||
return {
|
||||
projectName: config.project,
|
||||
projectDir: project.projectDir,
|
||||
llm,
|
||||
embeddings,
|
||||
storage,
|
||||
connections,
|
||||
queryHistory,
|
||||
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('');
|
||||
|
||||
if (status.queryHistory.length > 0) {
|
||||
lines.push(` ${bold('Query history')}`);
|
||||
const connectionWidth = Math.max(...status.queryHistory.map((entry) => entry.connection.length));
|
||||
for (const entry of status.queryHistory) {
|
||||
lines.push(
|
||||
` ${sym(entry.status)} ${entry.connection.padEnd(connectionWidth)} ${dim(entry.dialect)} ${entry.detail}`,
|
||||
);
|
||||
if (entry.fix && entry.status !== 'ok') {
|
||||
const indent = 6 + connectionWidth + 3 + entry.dialect.length + 3;
|
||||
lines.push(`${' '.repeat(indent)}${dim(`→ ${entry.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');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue