Improve setup daemon diagnostics

This commit is contained in:
Andrey Avtomonov 2026-05-25 16:14:22 +02:00
parent 853ab10be2
commit 3a3086b7cd
11 changed files with 236 additions and 24 deletions

View file

@ -161,6 +161,7 @@ describe('pickDatabaseScope', () => {
'public.events',
'public.sessions',
]);
expect([...(capture.state?.expanded ?? [])].sort()).toEqual(['analytics', 'public']);
expect(capture.state?.byId.get('public.events')?.title).toBe('events (view)');
});

View file

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ManagedPythonDaemonStartError,
readManagedPythonDaemonStatus,
startManagedPythonDaemon,
stopAllManagedPythonDaemons,
@ -244,6 +245,76 @@ describe('KTX daemon lifecycle', () => {
});
});
it('kills the spawned daemon when the startup health check times out', async () => {
const spawnDaemon = makeSpawn(7777);
const killProcess = vi.fn();
const fetch = vi.fn<ManagedPythonDaemonFetch>().mockRejectedValue(new Error('fetch failed'));
await expect(
startManagedPythonDaemon({
...daemonOptionsBase(tempDir),
features: ['core'],
installRuntime: vi.fn(async () => installResult(tempDir)),
spawnDaemon,
fetch,
processAlive: vi.fn(() => true),
killProcess,
allocatePort: vi.fn(async () => 61234),
now: () => new Date('2026-05-11T00:00:00.000Z'),
startupTimeoutMs: 5,
pollIntervalMs: 1,
}),
).rejects.toBeInstanceOf(ManagedPythonDaemonStartError);
expect(killProcess).toHaveBeenCalledWith(7777);
await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
});
it('surfaces the underlying fetch cause in the startup failure message', async () => {
const cause = new Error('connect ECONNREFUSED 127.0.0.1:61234');
const fetchError = new Error('fetch failed');
(fetchError as Error & { cause?: unknown }).cause = cause;
const error = await startManagedPythonDaemon({
...daemonOptionsBase(tempDir),
features: ['core'],
installRuntime: vi.fn(async () => installResult(tempDir)),
spawnDaemon: makeSpawn(7778),
fetch: vi.fn<ManagedPythonDaemonFetch>().mockRejectedValue(fetchError),
processAlive: vi.fn(() => false),
killProcess: vi.fn(),
allocatePort: vi.fn(async () => 61234),
now: () => new Date('2026-05-11T00:00:00.000Z'),
startupTimeoutMs: 5,
pollIntervalMs: 1,
}).catch((value: unknown) => value);
expect(error).toBeInstanceOf(ManagedPythonDaemonStartError);
const startError = error as ManagedPythonDaemonStartError;
expect(startError.detail).toContain('fetch failed');
expect(startError.detail).toContain('ECONNREFUSED');
expect(startError.message).toContain('ECONNREFUSED');
});
it('exposes the daemon stderr log path on startup failure', async () => {
const error = await startManagedPythonDaemon({
...daemonOptionsBase(tempDir),
features: ['core'],
installRuntime: vi.fn(async () => installResult(tempDir)),
spawnDaemon: makeSpawn(7779),
fetch: vi.fn<ManagedPythonDaemonFetch>().mockRejectedValue(new Error('fetch failed')),
processAlive: vi.fn(() => false),
killProcess: vi.fn(),
allocatePort: vi.fn(async () => 61234),
now: () => new Date('2026-05-11T00:00:00.000Z'),
startupTimeoutMs: 5,
pollIntervalMs: 1,
}).catch((value: unknown) => value);
expect(error).toBeInstanceOf(ManagedPythonDaemonStartError);
expect((error as ManagedPythonDaemonStartError).stderrLog).toBe(layout(tempDir).daemonStderrPath);
});
it('reuses a healthy daemon with the requested feature set', async () => {
await mkdir(layout(tempDir).daemonStateDir, { recursive: true });
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);

View file

@ -164,13 +164,13 @@ describe('setup databases step', () => {
'Which databases should KTX connect to?\n' +
'Use Up/Down to move, Space to select or unselect, Enter to confirm, Escape to go back, or Ctrl+C to exit.',
options: [
{ value: 'sqlite', label: 'SQLite' },
{ value: 'postgres', label: 'PostgreSQL' },
{ value: 'bigquery', label: 'BigQuery' },
{ value: 'snowflake', label: 'Snowflake' },
{ value: 'mysql', label: 'MySQL' },
{ value: 'clickhouse', label: 'ClickHouse' },
{ value: 'sqlserver', label: 'SQL Server' },
{ value: 'bigquery', label: 'BigQuery' },
{ value: 'snowflake', label: 'Snowflake' },
{ value: 'sqlite', label: 'SQLite' },
],
required: true,
});
@ -381,12 +381,16 @@ describe('setup databases step', () => {
it('emits debug telemetry when setup writes a database connection', async () => {
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
vi.stubEnv('KTX_TELEMETRY_DISABLED', '');
vi.stubEnv('DO_NOT_TRACK', '');
vi.stubEnv('CI', '');
const io = makeIo();
const prompts = makePromptAdapter({
selectValues: ['url'],
textValues: ['', 'env:DATABASE_URL'],
});
const listSchemas = vi.fn(async () => []);
const listTables = vi.fn(async () => []);
const result = await runKtxSetupDatabasesStep(
{
@ -397,7 +401,13 @@ describe('setup databases step', () => {
skipDatabases: false,
},
io.io,
{ prompts, testConnection: vi.fn(async () => 0), scanConnection: vi.fn(async () => 0) },
{
prompts,
testConnection: vi.fn(async () => 0),
scanConnection: vi.fn(async () => 0),
listSchemas,
listTables,
},
);
expect(result.status).toBe('ready');

View file

@ -5,6 +5,7 @@ import { initKtxProject } from '../src/context/project/project.js';
import { parseKtxProjectConfig } from '../src/context/project/config.js';
import { readKtxSetupState, writeKtxSetupState } from '../src/context/project/setup-config.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ManagedPythonDaemonStartError } from '../src/managed-python-daemon.js';
import { type KtxSetupEmbeddingsPromptAdapter, runKtxSetupEmbeddingsStep } from '../src/setup-embeddings.js';
const EMBEDDING_OPTION_PROMPT_MESSAGE = [
@ -366,6 +367,40 @@ describe('setup embeddings step', () => {
expect(io.stderr()).not.toContain('daemon traceback line 5');
});
it('prints the daemon stderr tail when the daemon fails to start', async () => {
const io = makeIo();
const stderrLog = join(tempDir, '.ktx', 'runtime', 'daemon.stderr.log');
await mkdir(join(tempDir, '.ktx', 'runtime'), { recursive: true });
await writeFile(
stderrLog,
Array.from({ length: 45 }, (_value, index) => `daemon startup traceback ${index + 1}`).join('\n'),
);
const result = await runKtxSetupEmbeddingsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
cliVersion: '0.2.0',
runtimeInstallPolicy: 'auto',
skipEmbeddings: false,
},
io.io,
{
env: {},
ensureLocalEmbeddings: vi.fn(async () => {
throw new ManagedPythonDaemonStartError('fetch failed: connect ECONNREFUSED 127.0.0.1:61234', stderrLog);
}),
},
);
expect(result.status).toBe('failed');
expect(io.stderr()).toContain('Local embedding health check failed: fetch failed: connect ECONNREFUSED');
expect(io.stderr()).toContain('Recent KTX daemon stderr:');
expect(io.stderr()).toContain('daemon startup traceback 6');
expect(io.stderr()).toContain('daemon startup traceback 45');
expect(io.stderr()).not.toContain('daemon startup traceback 5');
});
it('does not print daemon stderr diagnostics when the log is unavailable or empty', async () => {
const io = makeIo();