mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-19 08:28:06 +02:00
488 lines
17 KiB
TypeScript
488 lines
17 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { constants as fsConstants } from 'node:fs';
|
|
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);
|
|
|
|
type DoctorStatus = 'pass' | 'warn' | 'fail';
|
|
type KtxDoctorOutputMode = 'plain' | 'json';
|
|
type KtxDoctorInputMode = 'auto' | 'disabled';
|
|
|
|
export interface DoctorCheck {
|
|
id: string;
|
|
label: string;
|
|
status: DoctorStatus;
|
|
detail: string;
|
|
fix?: string;
|
|
}
|
|
|
|
interface DoctorReport {
|
|
title: string;
|
|
checks: DoctorCheck[];
|
|
}
|
|
|
|
export type KtxDoctorArgs =
|
|
| { command: 'setup'; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode }
|
|
| { command: 'project'; projectDir: string; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode }
|
|
| { command: 'demo'; projectDir: string; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode };
|
|
|
|
interface KtxDoctorIo {
|
|
stdout: { write(chunk: string): void };
|
|
stderr: { write(chunk: string): void };
|
|
}
|
|
|
|
interface SetupDoctorDeps {
|
|
env?: NodeJS.ProcessEnv;
|
|
workspaceRoot?: string;
|
|
execText?: (command: string, args: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) => Promise<string>;
|
|
pathExists?: (path: string) => Promise<boolean>;
|
|
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 {
|
|
runSetupChecks?: () => Promise<DoctorCheck[]>;
|
|
runHistoricSqlDoctorChecks?: (project: KtxLocalProject, deps: HistoricSqlDoctorDeps) => Promise<DoctorCheck[]>;
|
|
}
|
|
|
|
function workspaceRootDir(): string {
|
|
return resolve(fileURLToPath(new URL('../../../', import.meta.url)));
|
|
}
|
|
|
|
async function defaultExecText(
|
|
command: string,
|
|
args: string[],
|
|
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
|
): Promise<string> {
|
|
const result = await execFileAsync(command, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
encoding: 'utf8',
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
return `${result.stdout}${result.stderr}`.trim();
|
|
}
|
|
|
|
async function defaultPathExists(path: string): Promise<boolean> {
|
|
try {
|
|
await access(path, fsConstants.F_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function failureMessage(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 parseVersion(value: string): number[] {
|
|
const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
if (!match) {
|
|
return [];
|
|
}
|
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
}
|
|
|
|
function versionAtLeast(value: string, minimum: [number, number, number]): boolean {
|
|
const parsed = parseVersion(value);
|
|
if (parsed.length !== 3) {
|
|
return false;
|
|
}
|
|
for (let index = 0; index < minimum.length; index += 1) {
|
|
if (parsed[index] > minimum[index]) return true;
|
|
if (parsed[index] < minimum[index]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function check(status: DoctorStatus, id: string, label: string, detail: string, fix?: string): DoctorCheck {
|
|
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();
|
|
const execText = deps.execText ?? defaultExecText;
|
|
const pathExists = deps.pathExists ?? defaultPathExists;
|
|
const importBetterSqlite3 = deps.importBetterSqlite3 ?? (() => import('better-sqlite3'));
|
|
const checks: DoctorCheck[] = [];
|
|
|
|
const nodeDetail = `${process.version} ABI ${process.versions.modules}`;
|
|
checks.push(
|
|
versionAtLeast(process.version, [22, 0, 0])
|
|
? check('pass', 'node', 'Node 22+', nodeDetail)
|
|
: check('fail', 'node', 'Node 22+', nodeDetail, 'Install Node 22 or newer, then rerun `pnpm run setup:dev`'),
|
|
);
|
|
|
|
try {
|
|
const pnpmVersion = await execText('pnpm', ['--version'], { cwd: root, env });
|
|
checks.push(
|
|
versionAtLeast(pnpmVersion, [10, 20, 0])
|
|
? check('pass', 'pnpm', 'pnpm 10.20+', pnpmVersion)
|
|
: check(
|
|
'fail',
|
|
'pnpm',
|
|
'pnpm 10.20+',
|
|
pnpmVersion,
|
|
'Run: corepack enable && corepack prepare pnpm@10.28.0 --activate',
|
|
),
|
|
);
|
|
} catch (error) {
|
|
checks.push(
|
|
check(
|
|
'fail',
|
|
'pnpm',
|
|
'pnpm 10.20+',
|
|
failureMessage(error),
|
|
'Run: corepack enable && corepack prepare pnpm@10.28.0 --activate',
|
|
),
|
|
);
|
|
}
|
|
|
|
try {
|
|
const corepackVersion = await execText('corepack', ['--version'], { cwd: root, env });
|
|
checks.push(check('pass', 'corepack', 'Corepack', corepackVersion));
|
|
} catch (error) {
|
|
checks.push(check('warn', 'corepack', 'Corepack', failureMessage(error), 'Run: corepack enable'));
|
|
}
|
|
|
|
try {
|
|
const uvVersion = await execText('uv', ['--version'], { cwd: root, env });
|
|
checks.push(check('pass', 'uv', 'uv', uvVersion));
|
|
} catch (error) {
|
|
checks.push(check('fail', 'uv', 'uv', failureMessage(error), 'Install uv, then rerun `pnpm run setup:dev`'));
|
|
}
|
|
|
|
try {
|
|
await importBetterSqlite3();
|
|
checks.push(check('pass', 'native-sqlite', 'Native SQLite', 'better-sqlite3 loaded'));
|
|
} catch (error) {
|
|
checks.push(
|
|
check('fail', 'native-sqlite', 'Native SQLite', failureMessage(error), 'Run: pnpm run native:rebuild'),
|
|
);
|
|
}
|
|
|
|
const cliBin = join(root, 'packages/cli/dist/bin.js');
|
|
if (await pathExists(cliBin)) {
|
|
checks.push(check('pass', 'package-build', 'TypeScript package build', 'packages/cli/dist/bin.js exists'));
|
|
} else {
|
|
checks.push(
|
|
check(
|
|
'fail',
|
|
'package-build',
|
|
'TypeScript package build',
|
|
'Missing packages/cli/dist/bin.js',
|
|
'Run: pnpm run build',
|
|
),
|
|
);
|
|
}
|
|
|
|
try {
|
|
const output = await execText(process.execPath, [cliBin, '--version'], { cwd: root, env });
|
|
checks.push(check('pass', 'workspace-cli', 'Workspace-local CLI', output));
|
|
} catch (error) {
|
|
checks.push(
|
|
check(
|
|
'fail',
|
|
'workspace-cli',
|
|
'Workspace-local CLI',
|
|
failureMessage(error),
|
|
'Run: pnpm run build && pnpm run ktx -- --version',
|
|
),
|
|
);
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
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 demo init`',
|
|
),
|
|
);
|
|
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>`,
|
|
),
|
|
);
|
|
}
|
|
return checks;
|
|
}
|
|
|
|
async function runDemoProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): Promise<DoctorCheck[]> {
|
|
const env = deps.env ?? process.env;
|
|
const { DEMO_CONNECTION_ID, DEMO_REPLAY_FILE } = await import('./demo-assets.js');
|
|
const { loadKtxProject } = await import('@ktx/context/project');
|
|
const checks: DoctorCheck[] = [];
|
|
const requiredPaths = [
|
|
['demo-config', 'Demo config', 'ktx.yaml'],
|
|
['demo-database', 'Demo dataset', 'demo.db'],
|
|
['demo-state', 'Demo state database', 'state.sqlite'],
|
|
['demo-replay', 'Demo replay', join('replays', DEMO_REPLAY_FILE)],
|
|
['demo-raw-sources', 'Demo raw sources directory', 'raw-sources'],
|
|
['demo-semantic-layer', 'Demo semantic-layer directory', 'semantic-layer'],
|
|
['demo-knowledge', 'Demo knowledge directory', 'knowledge'],
|
|
] as const;
|
|
|
|
for (const [id, label, relativePath] of requiredPaths) {
|
|
const absolutePath = join(projectDir, relativePath);
|
|
checks.push(
|
|
(await defaultPathExists(absolutePath))
|
|
? check('pass', id, label, relativePath)
|
|
: check(
|
|
'fail',
|
|
id,
|
|
label,
|
|
`Missing ${relativePath}`,
|
|
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
|
|
),
|
|
);
|
|
}
|
|
|
|
try {
|
|
const project = await loadKtxProject({ projectDir });
|
|
const connection = project.config.connections[DEMO_CONNECTION_ID];
|
|
checks.push(
|
|
connection?.driver === 'sqlite'
|
|
? check('pass', 'demo-connection', 'Demo connection', `${DEMO_CONNECTION_ID} uses sqlite`)
|
|
: check(
|
|
'fail',
|
|
'demo-connection',
|
|
'Demo connection',
|
|
`${DEMO_CONNECTION_ID} is missing or is not sqlite`,
|
|
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
|
|
),
|
|
);
|
|
const provider = project.config.llm.provider.backend;
|
|
checks.push(
|
|
provider === 'anthropic' || provider === 'vertex' || provider === 'gateway'
|
|
? check('pass', 'demo-llm-provider', 'Demo LLM provider', provider)
|
|
: check(
|
|
'fail',
|
|
'demo-llm-provider',
|
|
'Demo LLM provider',
|
|
provider,
|
|
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
|
|
),
|
|
);
|
|
if (provider === 'anthropic' && !env.ANTHROPIC_API_KEY) {
|
|
checks.push(
|
|
check(
|
|
'warn',
|
|
'anthropic-credentials',
|
|
'Anthropic credentials',
|
|
'ANTHROPIC_API_KEY is not set',
|
|
'Export ANTHROPIC_API_KEY to run `ktx setup demo --mode full --no-input`',
|
|
),
|
|
);
|
|
} else {
|
|
checks.push(check('pass', 'anthropic-credentials', 'Anthropic credentials', 'Configured for current provider'));
|
|
}
|
|
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',
|
|
'demo-config-parse',
|
|
'Demo config parse',
|
|
failureMessage(error),
|
|
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
|
|
),
|
|
);
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
lines.push('');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function hasFailures(report: DoctorReport): boolean {
|
|
return report.checks.some((item) => item.status === 'fail');
|
|
}
|
|
|
|
function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io: KtxDoctorIo): void {
|
|
if (outputMode === 'json') {
|
|
io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
return;
|
|
}
|
|
io.stdout.write(formatDoctorReport(report));
|
|
}
|
|
|
|
export async function runKtxDoctor(
|
|
args: KtxDoctorArgs,
|
|
io: KtxDoctorIo = process,
|
|
deps: KtxDoctorDeps = {},
|
|
): Promise<number> {
|
|
try {
|
|
const runSetupChecks = deps.runSetupChecks ?? (() => runSetupDoctorChecks());
|
|
const setupChecks = await runSetupChecks();
|
|
const report: DoctorReport =
|
|
args.command === 'setup'
|
|
? { title: 'KTX setup doctor', checks: setupChecks }
|
|
: args.command === 'demo'
|
|
? {
|
|
title: 'KTX demo doctor',
|
|
checks: [...setupChecks, ...(await runDemoProjectChecks(args.projectDir, deps))],
|
|
}
|
|
: {
|
|
title: 'KTX project doctor',
|
|
checks: [...setupChecks, ...(await runProjectChecks(args.projectDir, deps))],
|
|
};
|
|
|
|
writeReport(report, args.outputMode, io);
|
|
return hasFailures(report) ? 1 : 0;
|
|
} catch (error) {
|
|
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
return 1;
|
|
}
|
|
}
|