Merge origin/main into fix-metabase-readiness

This commit is contained in:
Andrey Avtomonov 2026-05-12 10:28:35 +02:00
commit 60b29bb1e6
173 changed files with 9803 additions and 1140 deletions

View file

@ -1,7 +1,8 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initKtxProject, parseKtxProjectConfig } from '@ktx/context/project';
import type { MetabaseRuntimeClient } from '@ktx/context/ingest';
import { initKtxProject, parseKtxProjectConfig, serializeKtxProjectConfig } from '@ktx/context/project';
import type { KtxConnectionDriver, KtxScanConnector, KtxSchemaSnapshot } from '@ktx/context/scan';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runKtxConnection } from './connection.js';
@ -598,6 +599,61 @@ describe('runKtxConnection', () => {
expect(io.stdout()).toContain('Tables: 2');
});
it('tests a configured Metabase connection through the Metabase runtime client', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
const projectConfig = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
await writeFile(
join(projectDir, 'ktx.yaml'),
serializeKtxProjectConfig({
...projectConfig,
connections: {
...projectConfig.connections,
prod_metabase: {
driver: 'metabase',
api_url: 'http://metabase.example.test',
api_key: 'mb_test',
},
},
}),
'utf-8',
);
const testConnection = vi.fn(async () => ({ success: true as const }));
const getDatabases = vi.fn(async () => [
{ id: 1, name: 'Analytics', engine: 'postgres', details: {}, is_sample: false },
{ id: 2, name: 'Sample Database', engine: 'h2', details: {}, is_sample: true },
]);
const cleanup = vi.fn(async () => undefined);
const createMetabaseClient = vi.fn(
async (): Promise<Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>> => ({
testConnection,
getDatabases,
cleanup,
}),
);
const createScanConnector = vi.fn(async () => {
throw new Error('native scanner should not be used for Metabase');
});
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'prod_metabase' }, io.io, {
createScanConnector,
createMetabaseClient,
}),
).resolves.toBe(0);
expect(createScanConnector).not.toHaveBeenCalled();
expect(createMetabaseClient).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'prod_metabase');
expect(testConnection).toHaveBeenCalledTimes(1);
expect(getDatabases).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(io.stdout()).toContain('Connection test passed: prod_metabase');
expect(io.stdout()).toContain('Driver: metabase');
expect(io.stdout()).toContain('Databases: 1');
expect(io.stderr()).toBe('');
});
it('cleans up the native scan connector when connection testing fails', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });

View file

@ -1,4 +1,10 @@
import { cancel, confirm, isCancel } from '@clack/prompts';
import {
DEFAULT_METABASE_CLIENT_CONFIG,
DefaultMetabaseConnectionClientFactory,
type MetabaseRuntimeClient,
metabaseRuntimeConfigFromLocalConnection,
} from '@ktx/context/ingest';
import { type KtxLocalProject, loadKtxProject, serializeKtxProjectConfig } from '@ktx/context/project';
import type { KtxScanConnector } from '@ktx/context/scan';
import type { KtxConnectionMappingArgs } from './commands/connection-mapping.js';
@ -61,6 +67,7 @@ interface KtxConnectionIo extends KtxCliIo {
interface KtxConnectionDeps {
createScanConnector?: typeof createKtxCliScanConnector;
createMetabaseClient?: typeof createDefaultMetabaseClient;
runMapping?: (argv: string[], io: KtxCliIo) => Promise<number>;
prompts?: KtxConnectionPromptAdapter;
}
@ -104,6 +111,12 @@ async function cleanupConnector(connector: KtxScanConnector | null): Promise<voi
}
}
function normalizedConnectionDriver(project: KtxLocalProject, connectionId: string): string {
return String(project.config.connections[connectionId]?.driver ?? '')
.trim()
.toLowerCase();
}
async function testNativeConnection(
project: KtxLocalProject,
connectionId: string,
@ -131,6 +144,48 @@ async function testNativeConnection(
}
}
async function createDefaultMetabaseClient(
project: KtxLocalProject,
connectionId: string,
): Promise<Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>> {
const factory = new DefaultMetabaseConnectionClientFactory(
(metabaseConnectionId) =>
metabaseRuntimeConfigFromLocalConnection(
metabaseConnectionId,
project.config.connections[metabaseConnectionId],
),
DEFAULT_METABASE_CLIENT_CONFIG,
);
return factory.createClient(connectionId);
}
async function testMetabaseConnection(
project: KtxLocalProject,
connectionId: string,
createMetabaseClient: typeof createDefaultMetabaseClient,
): Promise<{ driver: 'metabase'; databaseCount: number }> {
let client: Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'> | null = null;
try {
client = await createMetabaseClient(project, connectionId);
const testResult = await client.testConnection();
if (!testResult.success) {
throw new Error(
`Metabase connection test failed: ${testResult.error ?? testResult.message ?? 'unknown error'}`,
);
}
const databases = await client.getDatabases();
const databaseCount = databases.filter((database) => database.is_sample !== true).length;
if (databaseCount === 0) {
throw new Error('Metabase auth worked but no usable databases were returned');
}
return { driver: 'metabase', databaseCount };
} finally {
await client?.cleanup();
}
}
interface BufferedIo extends KtxCliIo {
stdoutText(): string;
stderrText(): string;
@ -399,6 +454,18 @@ export async function runKtxConnection(
return 0;
}
if (normalizedConnectionDriver(project, args.connectionId) === 'metabase') {
const result = await testMetabaseConnection(
project,
args.connectionId,
deps.createMetabaseClient ?? createDefaultMetabaseClient,
);
io.stdout.write(`Connection test passed: ${args.connectionId}\n`);
io.stdout.write(`Driver: ${result.driver}\n`);
io.stdout.write(`Databases: ${result.databaseCount}\n`);
return 0;
}
const result = await testNativeConnection(
project,
args.connectionId,

View file

@ -91,22 +91,17 @@ describe('demo assets', () => {
expect(manifest.sources.bi.explores).toBeGreaterThanOrEqual(2);
expect(manifest.sources.bi.dashboards).toBeGreaterThanOrEqual(2);
expect(manifest.sources.notion.pages).toBeGreaterThanOrEqual(5);
expect(manifest.generated.semanticLayer.sourceCount).toBeGreaterThanOrEqual(5);
expect(manifest.generated.knowledge.pageCount).toBeGreaterThanOrEqual(10);
expect(manifest.generated.semanticLayer.sourceCount).toBeGreaterThanOrEqual(40);
expect(manifest.generated.knowledge.pageCount).toBeGreaterThanOrEqual(20);
expect(manifest.generated.links.linkCount).toBeGreaterThanOrEqual(10);
const dbStat = await stat(packagedDemoAssetPath('demo.db'));
expect(dbStat.size).toBeGreaterThan(0);
expect(dbStat.size).toBeLessThan(10 * 1024 * 1024);
await expect(access(packagedDemoAssetPath('raw-sources/warehouse/accounts.csv'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('raw-sources/dbt/schema.yml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('raw-sources/bi/revenue_exec.dashboard.lookml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('raw-sources/notion/revenue-reporting-policy.md'))).resolves.toBeUndefined();
expect(manifest.generated.semanticLayer.path).toBe('semantic-layer/orbit_demo');
await expect(access(packagedDemoAssetPath('semantic-layer/orbit_demo/accounts.yaml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('knowledge/global/arr-contract-first.md'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('semantic-layer/dbt-main/mart_arr_daily.yaml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('semantic-layer/postgres-warehouse/mart_account_activity.yaml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('knowledge/global/orbit-company-overview.md'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('links/provenance.json'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('reports/seeded-demo-report.json'))).resolves.toBeUndefined();
});

View file

@ -45,14 +45,9 @@ const REQUIRED_SEEDED_ASSET_PATHS = [
'demo.db',
'manifest.json',
DEMO_REPLAY_FILE,
join('raw-sources', 'warehouse', 'accounts.csv'),
join('raw-sources', 'dbt', 'schema.yml'),
join('raw-sources', 'bi', 'revenue_exec.dashboard.lookml'),
join('raw-sources', 'notion', 'revenue-reporting-policy.md'),
join('semantic-layer', 'orbit_demo', 'accounts.yaml'),
join('knowledge', 'global', 'arr-contract-first.md'),
join('links', 'provenance.json'),
join('reports', 'seeded-demo-report.json'),
join('semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'),
join('semantic-layer', 'postgres-warehouse', 'mart_account_activity.yaml'),
join('knowledge', 'global', 'orbit-company-overview.md'),
] as const;
function assetDir(): string {

View file

@ -53,8 +53,8 @@ describe('seeded demo inspect contract', () => {
notion: { label: 'Notion', path: 'raw-sources/notion', pageCount: 8 },
},
generatedOutputs: {
semanticLayer: { path: 'semantic-layer/orbit_demo', manifestSourceCount: 6, fileCount: 6 },
knowledge: { path: 'knowledge/global', manifestPageCount: 10, fileCount: 10 },
semanticLayer: { path: 'semantic-layer', manifestSourceCount: 46, fileCount: 46 },
knowledge: { path: 'knowledge/global', manifestPageCount: 28, fileCount: 28 },
links: { path: 'links/provenance.json', manifestLinkCount: 23, linkCount: 23 },
reports: { primaryPath: 'reports/seeded-demo-report.json', fileCount: 1 },
replays: { primaryPath: 'replays/replay.memory-flow.v1.json', latestPath: 'replays/latest.memory-flow.v1.json' },
@ -83,8 +83,8 @@ describe('seeded demo inspect contract', () => {
expect(output).toContain('dbt: 3 models, 8 source tables');
expect(output).toContain('BI: 5 explores, 2 dashboards');
expect(output).toContain('Notion: 8 pages');
expect(output).toContain('Semantic-layer sources: 6 manifest, 6 files');
expect(output).toContain('Knowledge pages: 10 manifest, 10 files');
expect(output).toContain('Semantic-layer sources: 46 manifest, 46 files');
expect(output).toContain('Knowledge pages: 28 manifest, 28 files');
expect(output).toContain('Evidence links: 23 manifest, 23 links');
expect(output).toContain('Report: reports/seeded-demo-report.json');
expect(output).toContain('Replay: replays/replay.memory-flow.v1.json');

View file

@ -71,12 +71,9 @@ const REQUIRED_SEEDED_PROJECT_PATHS = [
'state.sqlite',
'manifest.json',
join('replays', 'replay.memory-flow.v1.json'),
join('raw-sources', 'warehouse', 'accounts.csv'),
join('raw-sources', 'dbt', 'schema.yml'),
join('raw-sources', 'bi', 'revenue_exec.dashboard.lookml'),
join('raw-sources', 'notion', 'revenue-reporting-policy.md'),
join('semantic-layer', 'orbit_demo', 'accounts.yaml'),
join('knowledge', 'global', 'arr-contract-first.md'),
join('semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'),
join('semantic-layer', 'postgres-warehouse', 'mart_account_activity.yaml'),
join('knowledge', 'global', 'orbit-company-overview.md'),
join('links', 'provenance.json'),
join('reports', 'seeded-demo-report.json'),
] as const;

View file

@ -19,11 +19,9 @@ describe('demo seeded mode', () => {
await expect(access(join(projectDir, 'demo.db'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'ktx.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'manifest.json'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'semantic-layer/orbit_demo/accounts.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'knowledge/global/arr-contract-first.md'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'raw-sources/dbt/schema.yml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'raw-sources/bi/revenue_exec.dashboard.lookml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'raw-sources/notion/revenue-reporting-policy.md'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'semantic-layer/dbt-main/mart_arr_daily.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'semantic-layer/postgres-warehouse/mart_account_activity.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'knowledge/global/orbit-company-overview.md'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'links/provenance.json'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'reports/seeded-demo-report.json'))).resolves.toBeUndefined();
});
@ -88,8 +86,8 @@ describe('demo seeded mode', () => {
it('SL YAML validates correctly', async () => {
await ensureSeededDemoProject({ projectDir, force: false });
const slYaml = await readFile(join(projectDir, 'semantic-layer/orbit_demo/accounts.yaml'), 'utf-8');
expect(slYaml).toContain('name: accounts');
const slYaml = await readFile(join(projectDir, 'semantic-layer/dbt-main/mart_arr_daily.yaml'), 'utf-8');
expect(slYaml).toContain('name: mart_arr_daily');
expect(slYaml).toContain('grain:');
expect(slYaml).toContain('columns:');
expect(slYaml).toContain('measures:');
@ -98,11 +96,11 @@ describe('demo seeded mode', () => {
it('wiki pages have valid frontmatter', async () => {
await ensureSeededDemoProject({ projectDir, force: false });
const wiki = await readFile(join(projectDir, 'knowledge/global/arr-contract-first.md'), 'utf-8');
const wiki = await readFile(join(projectDir, 'knowledge/global/orbit-company-overview.md'), 'utf-8');
expect(wiki).toContain('---');
expect(wiki).toContain('summary:');
expect(wiki).toContain('tags:');
expect(wiki).toContain('sl_refs:');
expect(wiki).toContain('refs:');
expect(wiki).toContain('usage_mode: auto');
});

View file

@ -32,6 +32,7 @@ import {
writeWarehouseConfig,
} from './ingest.test-utils.js';
import { resetVizFallbackWarningsForTest } from './viz-fallback.js';
import { runKtxSetup } from './setup.js';
describe('runKtxIngest', () => {
let tempDir: string;
@ -105,6 +106,75 @@ describe('runKtxIngest', () => {
expect(statusIo.stderr()).toBe('');
});
it('prints provider setup guidance when a skip-llm setup project runs dev ingest', async () => {
const projectDir = join(tempDir, 'project');
const setupIo = makeIo();
await expect(
runKtxSetup(
{
command: 'run',
projectDir,
mode: 'new',
agents: false,
agentScope: 'project',
agentInstallMode: 'cli',
skipAgents: true,
inputMode: 'disabled',
yes: true,
cliVersion: '0.0.0-test',
skipLlm: true,
skipEmbeddings: true,
databaseDrivers: ['postgres'],
databaseConnectionId: 'warehouse',
databaseUrl: 'env:WAREHOUSE_URL',
databaseSchemas: [],
enableHistoricSql: true,
skipDatabases: false,
skipSources: true,
},
setupIo.io,
{
databasesDeps: {
testConnection: async (_projectDir, _connectionId, io) => {
io.stdout.write('Driver: postgres\nTables: 1\n');
return 0;
},
scanConnection: async () => 0,
historicSqlProbe: async () => ({ ok: true, lines: ['PASS Historic SQL probe skipped in test'] }),
},
context: async () => ({ status: 'skipped', projectDir }),
},
),
).resolves.toBe(0);
const sourceDir = join(tempDir, 'source');
await mkdir(join(sourceDir, 'orders'), { recursive: true });
await writeFile(join(sourceDir, 'orders', 'orders.json'), '{"name":"orders"}\n', 'utf-8');
const runIo = makeIo();
await expect(
runKtxIngest(
{
command: 'run',
projectDir,
connectionId: 'warehouse',
adapter: 'historic-sql',
sourceDir,
outputMode: 'plain',
},
runIo.io,
),
).resolves.toBe(1);
expect(runIo.stdout()).toBe('');
expect(runIo.stderr()).toContain(
'ktx dev ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
);
expect(runIo.stderr()).toContain(
`ktx setup --project-dir ${projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
);
});
it('routes metabase scheduled pulls to the fan-out runner and prints child summaries', async () => {
const projectDir = join(tempDir, 'project');
await writeMetabaseConfig(projectDir);
@ -918,6 +988,97 @@ describe('runKtxIngest', () => {
expect(io.stderr()).toBe('');
});
it('prints plain WorkUnit step progress during long-running local ingest', async () => {
const projectDir = join(tempDir, 'historic-sql-step-progress-project');
await mkdir(projectDir, { recursive: true });
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: historic-sql-step-progress-project',
'connections:',
' warehouse:',
' driver: postgres',
' url: env:WAREHOUSE_DATABASE_URL',
' historicSql:',
' enabled: true',
' dialect: postgres',
' minExecutions: 2',
'ingest:',
' adapters:',
' - historic-sql',
'',
].join('\n'),
'utf-8',
);
const createdAdapters: SourceAdapter[] = [
{ source: 'historic-sql', skillNames: [], detect: async () => true, chunk: async () => ({ workUnits: [] }) },
];
const runLocal = vi.fn(async (input: RunLocalIngestOptions) => {
input.memoryFlow?.update({
plannedWorkUnits: [
{
unitKey: 'historic-sql-table-public-orders',
rawFiles: ['tables/public/orders.json'],
peerFileCount: 0,
dependencyCount: 0,
},
{
unitKey: 'historic-sql-table-public-customers',
rawFiles: ['tables/public/customers.json'],
peerFileCount: 0,
dependencyCount: 0,
},
],
});
input.memoryFlow?.emit({ type: 'chunks_planned', chunkCount: 2, workUnitCount: 2, evictionCount: 0 });
input.memoryFlow?.emit({
type: 'work_unit_started',
unitKey: 'historic-sql-table-public-orders',
skills: ['historic_sql_table_digest'],
stepBudget: 40,
});
input.memoryFlow?.emit({
type: 'work_unit_step',
unitKey: 'historic-sql-table-public-orders',
stepIndex: 7,
stepBudget: 40,
});
input.memoryFlow?.emit({
type: 'work_unit_finished',
unitKey: 'historic-sql-table-public-orders',
status: 'success',
});
input.memoryFlow?.finish('done');
return completedLocalBundleRun(input, input.jobId ?? 'historic-step-progress-job');
});
const io = makeIo({ isTTY: true });
await expect(
runKtxIngest(
{
command: 'run',
projectDir,
connectionId: 'warehouse',
adapter: 'historic-sql',
outputMode: 'plain',
},
io.io,
{
env: interactiveEnv(),
createAdapters: vi.fn(() => createdAdapters as never),
runLocalIngest: runLocal,
jobIdFactory: () => 'historic-step-progress-job',
},
),
).resolves.toBe(0);
const stdout = io.stdout();
expect(stdout).toContain('[45%] Planned 2 work units');
expect(stdout).toContain('[55%] Processing 1/2 work units: historic-sql-table-public-orders');
expect(stdout).toContain('[58%] Processing 1/2 work units: historic-sql-table-public-orders step 7/40');
expect(stdout).toContain('[68%] Processed 1/2 work units');
});
it('passes local Looker pull-config options and agent runner into scheduled ingest for Looker scheduled ingest', async () => {
const projectDir = join(tempDir, 'project');
await writeWarehouseConfig(projectDir);

View file

@ -168,13 +168,37 @@ function formatDiffProgress(event: Extract<MemoryFlowEvent, { type: 'diff_comput
return `+${event.added}/~${event.modified}/-${event.deleted}/=${event.unchanged}`;
}
function completedWorkUnitCount(snapshot: MemoryFlowReplayInput): number {
return snapshot.events.filter((event) => event.type === 'work_unit_finished').length;
function workUnitEventsThrough(snapshot: MemoryFlowReplayInput, eventIndex: number): MemoryFlowEvent[] {
return snapshot.events.slice(0, eventIndex + 1);
}
function completedWorkUnitCountThrough(snapshot: MemoryFlowReplayInput, eventIndex: number): number {
return workUnitEventsThrough(snapshot, eventIndex).filter((event) => event.type === 'work_unit_finished').length;
}
function plannedWorkUnitCountThrough(snapshot: MemoryFlowReplayInput, eventIndex: number): number {
if (snapshot.plannedWorkUnits.length > 0) {
return snapshot.plannedWorkUnits.length;
}
const planEvent = workUnitEventsThrough(snapshot, eventIndex)
.filter((event) => event.type === 'chunks_planned')
.at(-1);
return planEvent?.workUnitCount ?? completedWorkUnitCountThrough(snapshot, eventIndex);
}
function workUnitOrdinalThrough(snapshot: MemoryFlowReplayInput, eventIndex: number, unitKey: string): number {
const events = workUnitEventsThrough(snapshot, eventIndex);
const startedIndex = events.findIndex((event) => event.type === 'work_unit_started' && event.unitKey === unitKey);
if (startedIndex === -1) {
return completedWorkUnitCountThrough(snapshot, eventIndex) + 1;
}
return events.slice(0, startedIndex + 1).filter((event) => event.type === 'work_unit_started').length;
}
function plainIngestEventProgress(
event: MemoryFlowEvent,
snapshot: MemoryFlowReplayInput,
eventIndex: number,
): { percent: number; message: string } | null {
switch (event.type) {
case 'source_acquired':
@ -196,11 +220,27 @@ function plainIngestEventProgress(
};
case 'stage_skipped':
return { percent: 45, message: `Skipped ${event.stage}: ${event.reason}` };
case 'work_unit_started':
return { percent: 55, message: `Processing ${event.unitKey}` };
case 'work_unit_started': {
const total = plannedWorkUnitCountThrough(snapshot, eventIndex);
const ordinal = workUnitOrdinalThrough(snapshot, eventIndex, event.unitKey);
const progress = total > 0 ? `${ordinal}/${total} work units: ` : '';
return { percent: 55, message: `Processing ${progress}${event.unitKey}` };
}
case 'work_unit_step': {
const total = plannedWorkUnitCountThrough(snapshot, eventIndex);
const completed = completedWorkUnitCountThrough(snapshot, eventIndex);
const ordinal = workUnitOrdinalThrough(snapshot, eventIndex, event.unitKey);
const stepFraction = event.stepBudget > 0 ? Math.min(1, event.stepIndex / event.stepBudget) : 0;
const percent = total > 0 ? 55 + Math.ceil(((completed + stepFraction) / total) * 25) : 55;
const progress = total > 0 ? `${ordinal}/${total} work units: ` : '';
return {
percent,
message: `Processing ${progress}${event.unitKey} step ${event.stepIndex}/${event.stepBudget}`,
};
}
case 'work_unit_finished': {
const total = snapshot.plannedWorkUnits.length || completedWorkUnitCount(snapshot);
const completed = completedWorkUnitCount(snapshot);
const total = plannedWorkUnitCountThrough(snapshot, eventIndex);
const completed = completedWorkUnitCountThrough(snapshot, eventIndex);
const percent = total > 0 ? 55 + Math.round((completed / total) * 25) : 80;
return {
percent,
@ -225,7 +265,6 @@ function plainIngestEventProgress(
case 'report_created':
return { percent: 98, message: `Created ingest report ${event.reportPath ?? event.runId}` };
case 'scope_detected':
case 'work_unit_step':
case 'candidate_action':
return null;
}
@ -259,11 +298,12 @@ function createPlainIngestProgressRenderer(
},
update(snapshot) {
while (printedEvents < snapshot.events.length) {
const eventIndex = printedEvents;
const event = snapshot.events[printedEvents++];
if (!event) {
continue;
}
const progress = plainIngestEventProgress(event, snapshot);
const progress = plainIngestEventProgress(event, snapshot, eventIndex);
if (progress) {
write(progress.percent, progress.message);
}

View file

@ -170,6 +170,41 @@ describe('managed Python daemon lifecycle', () => {
});
});
it('makes a final health probe before reporting startup failure', async () => {
const spawnDaemon = makeSpawn(5556);
const installRuntime = vi.fn(async () => installResult(tempDir));
const fetch = vi
.fn<ManagedPythonDaemonFetch>()
.mockRejectedValueOnce(new Error('fetch failed'))
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ status: 'healthy', version: '0.2.0' }),
text: async () => '',
});
const result = await startManagedPythonDaemon({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
features: ['core'],
installRuntime,
spawnDaemon,
fetch,
allocatePort: vi.fn(async () => 61234),
now: () => new Date('2026-05-11T00:00:00.000Z'),
startupTimeoutMs: 5,
pollIntervalMs: 20,
});
expect(result.status).toBe('started');
expect(fetch).toHaveBeenCalledTimes(2);
expect(JSON.parse(await readFile(layout(tempDir).daemonStatePath, 'utf8'))).toMatchObject({
pid: 5556,
port: 61234,
version: '0.2.0',
});
});
it('reuses a healthy daemon with the requested feature set', async () => {
await mkdir(layout(tempDir).versionDir, { recursive: true });
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);

View file

@ -273,6 +273,15 @@ async function waitForHealth(input: {
lastDetail = health.detail;
await delay(input.pollIntervalMs);
}
const finalHealth = await healthOk({
state: input.state,
cliVersion: input.cliVersion,
fetch: input.fetch,
});
if (finalHealth.ok) {
return;
}
lastDetail = finalHealth.detail;
throw new Error(`KTX Python daemon failed to start: ${lastDetail}. stderr: ${input.state.stderrLog}`);
}

View file

@ -161,6 +161,14 @@ describe('verifyRuntimeAsset', () => {
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(/Unsafe runtime wheel filename/);
});
it('reports the source-checkout artifact command when the bundled manifest is missing', async () => {
const assetDir = join(tempDir, 'packages', 'cli', 'assets', 'python');
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
/Missing bundled Python runtime manifest.*pnpm run artifacts:build/s,
);
});
});
describe('installManagedPythonRuntime', () => {
@ -210,6 +218,30 @@ describe('installManagedPythonRuntime', () => {
expect(manifest.python.daemonExecutable).toBe(result.layout.daemonPath);
});
it('disables repo uv config for managed runtime uv commands', async () => {
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
const commands: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = [];
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args, options) => {
commands.push({ command, args, env: options?.env });
return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.11.13\n' : '', stderr: '' };
});
await installManagedPythonRuntime({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
assetDir,
env: { PATH: '/opt/homebrew/bin', UV_NO_CONFIG: '0' },
features: ['core'],
exec,
});
expect(commands.map((call) => [call.command, call.args[0], call.env?.UV_NO_CONFIG, call.env?.PATH])).toEqual([
['uv', '--version', '1', '/opt/homebrew/bin'],
['uv', 'venv', '1', '/opt/homebrew/bin'],
['uv', 'pip', '1', '/opt/homebrew/bin'],
]);
});
it('installs the local-embeddings extra when requested', async () => {
const { assetDir } = await writeAsset(tempDir, 'embedding-wheel');
const commands: Array<{ command: string; args: string[] }> = [];

View file

@ -186,9 +186,28 @@ async function readJsonFile(path: string): Promise<unknown> {
return JSON.parse(await readFile(path, 'utf8')) as unknown;
}
function isErrnoException(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
export async function verifyRuntimeAsset(input: { assetDir: string }): Promise<ManagedRuntimeAsset> {
const manifestPath = join(input.assetDir, 'manifest.json');
const manifest = runtimeAssetManifestSchema.parse(await readJsonFile(manifestPath));
let manifestData: unknown;
try {
manifestData = await readJsonFile(manifestPath);
} catch (error) {
if (isErrnoException(error, 'ENOENT')) {
throw new Error(
[
`Missing bundled Python runtime manifest: ${manifestPath}`,
'In a source checkout, build the local runtime assets with: pnpm run artifacts:build',
'Then retry the runtime-backed KTX command.',
].join('\n'),
);
}
throw error;
}
const manifest = runtimeAssetManifestSchema.parse(manifestData);
assertSafeWheelFilename(manifest.wheel.file);
const wheelPath = join(input.assetDir, manifest.wheel.file);
const wheel = await readFile(wheelPath);
@ -243,10 +262,11 @@ async function runLogged(input: {
command: string;
args: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ stdout: string; stderr: string }> {
await appendFile(input.logPath, `$ ${input.command} ${input.args.join(' ')}\n`);
try {
const result = await input.exec(input.command, input.args, { cwd: input.cwd });
const result = await input.exec(input.command, input.args, { cwd: input.cwd, env: input.env });
if (result.stdout) {
await appendFile(input.logPath, result.stdout.endsWith('\n') ? result.stdout : `${result.stdout}\n`);
}
@ -266,9 +286,13 @@ async function runLogged(input: {
}
}
async function ensureUv(exec: ManagedPythonRuntimeExec): Promise<string> {
function managedRuntimeUvEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return { ...baseEnv, UV_NO_CONFIG: '1' };
}
async function ensureUv(exec: ManagedPythonRuntimeExec, env?: NodeJS.ProcessEnv): Promise<string> {
try {
const result = await exec('uv', ['--version']);
const result = await exec('uv', ['--version'], { env });
return result.stdout.trim() || 'uv available';
} catch {
throw new Error(MISSING_UV_RUNTIME_INSTALL_MESSAGE);
@ -282,6 +306,7 @@ export async function installManagedPythonRuntime(
const exec = options.exec ?? defaultExec;
const features = normalizeFeatures(options.features);
const asset = await verifyRuntimeAsset({ assetDir: layout.assetDir });
const uvEnv = managedRuntimeUvEnv(options.env ?? process.env);
const existing = await readInstalledManifest(layout.manifestPath);
if (
options.force !== true &&
@ -298,14 +323,21 @@ export async function installManagedPythonRuntime(
await rm(layout.versionDir, { recursive: true, force: true });
await mkdir(layout.versionDir, { recursive: true });
await writeFile(layout.installLogPath, '');
await ensureUv(exec);
await runLogged({ exec, logPath: layout.installLogPath, command: 'uv', args: ['venv', layout.venvDir] });
await ensureUv(exec, uvEnv);
await runLogged({
exec,
logPath: layout.installLogPath,
command: 'uv',
args: ['venv', layout.venvDir],
env: uvEnv,
});
const wheelSpec = features.includes('local-embeddings') ? `${asset.wheelPath}[local-embeddings]` : asset.wheelPath;
await runLogged({
exec,
logPath: layout.installLogPath,
command: 'uv',
args: ['pip', 'install', '--python', layout.pythonPath, wheelSpec],
env: uvEnv,
});
const manifest: InstalledKtxRuntimeManifest = {
@ -371,7 +403,7 @@ export async function doctorManagedPythonRuntime(
const exec = options.exec ?? defaultExec;
const checks: ManagedPythonRuntimeDoctorCheck[] = [];
try {
const version = await ensureUv(exec);
const version = await ensureUv(exec, managedRuntimeUvEnv(options.env ?? process.env));
checks.push(check('pass', { id: 'uv', label: 'uv', detail: version }));
} catch (error) {
checks.push(

View file

@ -1295,6 +1295,7 @@ describe('setup databases step', () => {
expect(config.connections.warehouse.historicSql).not.toHaveProperty('redactionPatterns');
expect(config.connections.warehouse.historicSql).not.toHaveProperty(legacyHistoricSqlServiceAccountPatternsKey);
expect(config.ingest.adapters).toContain('historic-sql');
expect(config.ingest.workUnits.maxConcurrency).toBe(6);
expect(io.stdout()).toContain('Historic SQL probe...');
expect(io.stdout()).toContain('pg_stat_statements ready');
});

View file

@ -14,6 +14,8 @@ import { runKtxScan } from './scan.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
import { writeProjectLocalSecretReference } from './setup-secrets.js';
const HISTORIC_SQL_WORK_UNIT_MAX_CONCURRENCY = 6;
export type KtxSetupDatabaseDriver =
| 'sqlite'
| 'postgres'
@ -843,7 +845,7 @@ async function writeConnectionConfig(input: {
? (input.connection.historicSql as Record<string, unknown>)
: null;
if (historicSql?.enabled === true) {
await ensureHistoricSqlAdapterEnabled(input.projectDir);
await ensureHistoricSqlIngestDefaults(input.projectDir);
}
}
@ -954,9 +956,19 @@ async function maybeConfigurePostgresSchemas(input: {
return true;
}
async function ensureHistoricSqlAdapterEnabled(projectDir: string): Promise<void> {
async function ensureHistoricSqlIngestDefaults(projectDir: string): Promise<void> {
const project = await loadKtxProject({ projectDir });
if (project.config.ingest.adapters.includes('historic-sql')) {
const adapters = project.config.ingest.adapters.includes('historic-sql')
? project.config.ingest.adapters
: [...project.config.ingest.adapters, 'historic-sql'];
const maxConcurrency = Math.max(
project.config.ingest.workUnits.maxConcurrency,
HISTORIC_SQL_WORK_UNIT_MAX_CONCURRENCY,
);
if (
adapters === project.config.ingest.adapters &&
maxConcurrency === project.config.ingest.workUnits.maxConcurrency
) {
return;
}
await writeFile(
@ -965,7 +977,11 @@ async function ensureHistoricSqlAdapterEnabled(projectDir: string): Promise<void
...project.config,
ingest: {
...project.config.ingest,
adapters: [...project.config.ingest.adapters, 'historic-sql'],
adapters,
workUnits: {
...project.config.ingest.workUnits,
maxConcurrency,
},
},
}),
'utf-8',

View file

@ -0,0 +1,270 @@
import { describe, expect, it, vi } from 'vitest';
import type { KtxSetupAgentsResult } from './setup-agents.js';
import {
buildDemoReplayTimeline,
DEMO_REPLAY_TARGETS,
renderDemoAgentTransition,
renderDemoBanner,
renderDemoCardContent,
renderDemoCompletionSummary,
runDemoTour,
} from './setup-demo-tour.js';
/** Strip ANSI escape sequences for plain-text assertions. */
function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, '');
}
describe('renderDemoBanner', () => {
it('contains "Demo mode"', () => {
const plain = stripAnsi(renderDemoBanner());
expect(plain).toContain('Demo mode');
});
it('mentions pre-processed data', () => {
const plain = stripAnsi(renderDemoBanner());
expect(plain).toContain('pre-processed');
});
it('mentions read-only', () => {
const plain = stripAnsi(renderDemoBanner());
expect(plain).toContain('read-only');
});
});
describe('renderDemoCardContent', () => {
it('contains the title', () => {
const plain = stripAnsi(renderDemoCardContent('Database connection', ['Postgres']));
expect(plain).toContain('Database connection');
});
it('contains each selection', () => {
const plain = stripAnsi(renderDemoCardContent('Sources', ['dbt', 'metabase']));
expect(plain).toContain('dbt');
expect(plain).toContain('metabase');
});
it('contains navigation hints', () => {
const plain = stripAnsi(renderDemoCardContent('Title', ['a']));
expect(plain).toContain('Press Enter to continue');
expect(plain).toContain('Escape to go back');
});
it('works with multiple selections', () => {
const result = renderDemoCardContent('Pick', ['one', 'two', 'three']);
const plain = stripAnsi(result);
expect(plain).toContain('one');
expect(plain).toContain('two');
expect(plain).toContain('three');
// Each selection gets a ▸ bullet
const bullets = (plain.match(/▸/g) ?? []).length;
expect(bullets).toBe(3);
});
});
describe('renderDemoAgentTransition', () => {
it('contains "Demo project is ready"', () => {
const plain = stripAnsi(renderDemoAgentTransition());
expect(plain).toContain('Demo project is ready');
});
it('mentions connecting an agent', () => {
const plain = stripAnsi(renderDemoAgentTransition());
expect(plain).toContain('connect your agent');
});
});
describe('renderDemoCompletionSummary', () => {
const projectDir = '/tmp/ktx-demo-123';
it('includes the project path', () => {
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
expect(plain).toContain(projectDir);
});
it('includes a temp directory warning', () => {
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
expect(plain).toContain('temporary directory');
});
it('points to ktx setup for real data', () => {
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
expect(plain).toContain('ktx setup');
});
it('shows agent-connected message when installed', () => {
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
expect(plain).toContain('agent is connected');
});
it('includes star headline', () => {
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
expect(plain).toContain('★ KTX demo is ready');
});
it('shows manual instructions when agent not installed', () => {
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, false));
expect(plain).toContain('--agents');
expect(plain).toContain(`--project-dir ${projectDir}`);
});
});
describe('buildDemoReplayTimeline', () => {
const timeline = buildDemoReplayTimeline();
const connectionIds = new Set(timeline.map((e) => e.connectionId));
it('produces events for all 4 targets', () => {
expect(connectionIds.size).toBe(4);
expect(connectionIds).toContain('postgres-warehouse');
expect(connectionIds).toContain('dbt-main');
expect(connectionIds).toContain('metabase-main');
expect(connectionIds).toContain('notion-main');
});
it('all targets end as done', () => {
for (const id of connectionIds) {
const events = timeline.filter((e) => e.connectionId === id);
const last = events[events.length - 1];
expect(last.status).toBe('done');
}
});
it('events are sorted by delayMs', () => {
for (let i = 1; i < timeline.length; i++) {
expect(timeline[i].delayMs).toBeGreaterThanOrEqual(timeline[i - 1].delayMs);
}
});
});
describe('DEMO_REPLAY_TARGETS', () => {
it('has 1 primary source', () => {
expect(DEMO_REPLAY_TARGETS.primarySources).toHaveLength(1);
});
it('has 3 context sources', () => {
expect(DEMO_REPLAY_TARGETS.contextSources).toHaveLength(3);
});
it('primary source is a scan operation', () => {
expect(DEMO_REPLAY_TARGETS.primarySources[0].operation).toBe('scan');
});
it('context sources are source-ingest operations', () => {
for (const source of DEMO_REPLAY_TARGETS.contextSources) {
expect(source.operation).toBe('source-ingest');
}
});
});
describe('runDemoTour', () => {
function createMockIo() {
const chunks: string[] = [];
return {
io: {
stdout: { isTTY: true, columns: 80, write: (chunk: string) => { chunks.push(chunk); } },
stderr: { write: () => {} },
},
chunks,
};
}
it('returns 0 on successful tour with agent installed', async () => {
const { io, chunks } = createMockIo();
const mockAgents = vi.fn().mockResolvedValue({
status: 'ready',
projectDir: '/tmp/test',
installs: [{ target: 'claude-code', scope: 'project', mode: 'both' }],
} satisfies KtxSetupAgentsResult);
const navigation = vi.fn().mockResolvedValue('forward');
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
agents: mockAgents,
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
expect(mockAgents).toHaveBeenCalled();
// Should have rendered completion summary
const allOutput = chunks.join('');
expect(allOutput).toContain('agent is connected');
});
it('handles back navigation from first step by exiting', async () => {
const { io } = createMockIo();
const navigation = vi.fn().mockResolvedValue('back');
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
// Navigation called once for databases step, then exits
expect(navigation).toHaveBeenCalledTimes(1);
});
it('goes back from sources to databases', async () => {
const { io } = createMockIo();
let callCount = 0;
const navigation = vi.fn().mockImplementation(() => {
callCount++;
// First call (databases): forward
// Second call (sources): back
// Third call (databases again): back (exit)
if (callCount === 1) return Promise.resolve('forward');
return Promise.resolve('back');
});
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
expect(navigation).toHaveBeenCalledTimes(3);
});
it('handles agent step returning back', async () => {
const { io } = createMockIo();
let navCount = 0;
const navigation = vi.fn().mockImplementation(() => {
navCount++;
// Forward through databases, sources, context
// Then back from context (after agents returns back)
// Then back from sources, then back from databases (exit)
if (navCount <= 3) return Promise.resolve('forward');
return Promise.resolve('back');
});
const mockAgents = vi.fn().mockResolvedValue({
status: 'back',
projectDir: '/tmp/test',
} satisfies KtxSetupAgentsResult);
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
agents: mockAgents,
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
});
});

View file

@ -0,0 +1,390 @@
import type { KtxCliIo } from './cli-runtime.js';
import type {
ContextBuildTargetState,
ContextBuildViewState,
} from './context-build-view.js';
import { createRepainter, renderContextBuildView } from './context-build-view.js';
import { defaultDemoProjectDir, ensureSeededDemoProject } from './demo-assets.js';
import type { KtxPublicIngestPlanTarget } from './public-ingest.js';
import type { KtxSetupAgentsResult } from './setup-agents.js';
import { runKtxSetupAgentsStep } from './setup-agents.js';
import { KtxSetupExitError } from './setup-interrupt.js';
// ---------------------------------------------------------------------------
// ANSI helpers (internal)
// ---------------------------------------------------------------------------
const ESC = String.fromCharCode(0x1b);
function cyan(text: string): string {
return `${ESC}[36m${text}${ESC}[39m`;
}
function dim(text: string): string {
return `${ESC}[2m${text}${ESC}[22m`;
}
// ---------------------------------------------------------------------------
// Demo target helpers (internal)
// ---------------------------------------------------------------------------
function createDemoTarget(
connectionId: string,
operation: 'scan' | 'source-ingest',
driver: string,
): KtxPublicIngestPlanTarget {
const adapter = operation === 'source-ingest' ? driver : undefined;
return {
connectionId,
driver,
operation,
...(adapter ? { adapter } : {}),
debugCommand: `ktx setup context build --target ${connectionId}`,
steps: operation === 'scan'
? ['scan', 'enrich', 'memory-update']
: ['source-ingest', 'enrich', 'memory-update'],
};
}
function createTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTargetState {
return {
target,
status: 'queued',
detailLine: null,
summaryText: null,
startedAt: null,
elapsedMs: 0,
};
}
// ---------------------------------------------------------------------------
// Pure rendering functions
// ---------------------------------------------------------------------------
export function renderDemoBanner(): string {
const lines = [
'',
`${cyan('Demo mode')} — data has been pre-processed and KTX context is already built.`,
'│ This walkthrough illustrates the setup steps. Selections are pre-filled and read-only.',
];
return lines.join('\n');
}
export function renderDemoCardContent(title: string, selections: string[]): string {
const lines = [
`${title}`,
'│',
...selections.map((s) => `${cyan('▸')} ${s}`),
'│',
`${dim('Press Enter to continue, Escape to go back')}`,
'└',
];
return lines.join('\n');
}
export function renderDemoAgentTransition(): string {
const lines = [
'┌ Demo project is ready — let\'s connect your agent',
'│',
'│ Your KTX context has been built with demo data.',
'│ Select an agent to start using it.',
'└',
];
return lines.join('\n');
}
export function renderDemoCompletionSummary(projectDir: string, agentInstalled: boolean): string {
const lines: string[] = [
'',
`${cyan('★')} KTX demo is ready`,
'',
];
if (agentInstalled) {
lines.push(' Your agent is connected to a demo KTX project.');
} else {
lines.push(' Demo project created. Connect an agent to start using it:');
lines.push(` $ ${cyan(`ktx setup --agents --project-dir ${projectDir}`)}`);
}
lines.push(
'',
` ${dim('⚠')} This project is in a temporary directory and will be`,
' cleaned up by your system. To set up KTX with your own',
' data, run: ktx setup',
'',
` Project: ${projectDir}`,
);
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Keypress navigation
// ---------------------------------------------------------------------------
export async function waitForDemoNavigation(
stdin?: NodeJS.ReadStream,
): Promise<'forward' | 'back'> {
const input = stdin ?? process.stdin;
const hadRawMode = input.isRaw ?? false;
return new Promise<'forward' | 'back'>((resolve, reject) => {
if (typeof input.setRawMode === 'function') {
input.setRawMode(true);
}
input.resume();
const cleanup = () => {
input.off('data', onData);
if (typeof input.setRawMode === 'function') {
input.setRawMode(hadRawMode);
}
};
const onData = (data: Buffer) => {
const char = data.toString();
if (char === '\r' || char === '\n') {
cleanup();
resolve('forward');
} else if (char === '\x1b') {
cleanup();
resolve('back');
} else if (char === '\x03') {
cleanup();
reject(new KtxSetupExitError());
}
};
input.on('data', onData);
});
}
// ---------------------------------------------------------------------------
// Interactive card
// ---------------------------------------------------------------------------
export async function renderDemoCard(
title: string,
selections: string[],
io: KtxCliIo,
stdin?: NodeJS.ReadStream,
waitNav: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'> = waitForDemoNavigation,
): Promise<'forward' | 'back'> {
io.stdout.write(renderDemoBanner() + '\n\n');
io.stdout.write(renderDemoCardContent(title, selections) + '\n');
return waitNav(stdin);
}
// ---------------------------------------------------------------------------
// Context build replay
// ---------------------------------------------------------------------------
export interface DemoReplayEvent {
delayMs: number;
connectionId: string;
status: 'running' | 'done';
detailLine: string | null;
summaryText: string | null;
}
export const DEMO_REPLAY_TARGETS = {
primarySources: [
createDemoTarget('postgres-warehouse', 'scan', 'postgres'),
],
contextSources: [
createDemoTarget('dbt-main', 'source-ingest', 'dbt'),
createDemoTarget('metabase-main', 'source-ingest', 'metabase'),
createDemoTarget('notion-main', 'source-ingest', 'notion'),
],
} as const;
export function buildDemoReplayTimeline(): DemoReplayEvent[] {
return [
// postgres-warehouse: scan
{ delayMs: 0, connectionId: 'postgres-warehouse', status: 'running', detailLine: null, summaryText: null },
{ delayMs: 1200, connectionId: 'postgres-warehouse', status: 'running', detailLine: '[50%] scanning tables...', summaryText: null },
{ delayMs: 2400, connectionId: 'postgres-warehouse', status: 'done', detailLine: null, summaryText: '56 tables scanned' },
// dbt-main
{ delayMs: 2400, connectionId: 'dbt-main', status: 'running', detailLine: null, summaryText: null },
{ delayMs: 3600, connectionId: 'dbt-main', status: 'running', detailLine: '[60%] ingesting models...', summaryText: null },
{ delayMs: 4400, connectionId: 'dbt-main', status: 'done', detailLine: null, summaryText: '34 models ingested' },
// metabase-main
{ delayMs: 4400, connectionId: 'metabase-main', status: 'running', detailLine: null, summaryText: null },
{ delayMs: 5600, connectionId: 'metabase-main', status: 'done', detailLine: null, summaryText: '80 cards ingested' },
// notion-main
{ delayMs: 5600, connectionId: 'notion-main', status: 'running', detailLine: null, summaryText: null },
{ delayMs: 6800, connectionId: 'notion-main', status: 'done', detailLine: null, summaryText: '9 pages ingested' },
];
}
function renderDemoContextCompletionSummary(): string {
const lines = [
'',
`${cyan('★')} KTX finished building context`,
'',
' KTX created:',
` ${cyan('📊')} 46 semantic layer definitions`,
` ${cyan('📝')} 28 knowledge pages`,
'',
` ${dim('Press Enter to continue, Escape to go back')}`,
'',
];
return lines.join('\n');
}
export async function runDemoContextReplay(
io: KtxCliIo,
stdin?: NodeJS.ReadStream,
): Promise<'forward' | 'back'> {
const allPrimary = DEMO_REPLAY_TARGETS.primarySources.map(createTargetState);
const allContext = DEMO_REPLAY_TARGETS.contextSources.map(createTargetState);
const state: ContextBuildViewState = {
primarySources: allPrimary,
contextSources: allContext,
frame: 0,
startedAt: Date.now(),
totalElapsedMs: 0,
};
const allTargets = [...allPrimary, ...allContext];
const timeline = buildDemoReplayTimeline();
const repainter = createRepainter(io);
const paint = () => repainter.paint(renderContextBuildView(state, { styled: true }));
paint();
let eventIndex = 0;
const startTime = Date.now();
await new Promise<void>((resolve) => {
const frameInterval = setInterval(() => {
const elapsed = Date.now() - startTime;
state.frame++;
state.totalElapsedMs = elapsed;
// Apply all events up to the current elapsed time
while (eventIndex < timeline.length && timeline[eventIndex].delayMs <= elapsed) {
const event = timeline[eventIndex];
const target = allTargets.find((t) => t.target.connectionId === event.connectionId);
if (target) {
target.status = event.status;
target.detailLine = event.detailLine;
if (event.summaryText !== null) {
target.summaryText = event.summaryText;
}
if (event.status === 'running' && target.startedAt === null) {
target.startedAt = Date.now();
}
if (event.status === 'done') {
target.elapsedMs = target.startedAt !== null ? Date.now() - target.startedAt : 0;
}
}
eventIndex++;
}
// Update running target elapsed times
for (const t of allTargets) {
if (t.status === 'running' && t.startedAt !== null) {
t.elapsedMs = Date.now() - t.startedAt;
}
}
paint();
// Check if all events have been applied
if (eventIndex >= timeline.length) {
clearInterval(frameInterval);
resolve();
}
}, 120);
});
// Final paint with all done
paint();
// Show completion summary and wait for navigation
io.stdout.write(renderDemoContextCompletionSummary() + '\n');
return waitForDemoNavigation(stdin);
}
// ---------------------------------------------------------------------------
// Demo tour orchestrator
// ---------------------------------------------------------------------------
type DemoStep = 'databases' | 'sources' | 'context' | 'agents';
const DEMO_STEPS: DemoStep[] = ['databases', 'sources', 'context', 'agents'];
export interface DemoTourDeps {
agents?: (args: Parameters<typeof runKtxSetupAgentsStep>[0], io: KtxCliIo) => Promise<KtxSetupAgentsResult>;
waitForNavigation?: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'>;
ensureProject?: typeof ensureSeededDemoProject;
skipReplayAnimation?: boolean;
}
export async function runDemoTour(
args: { inputMode: 'auto' | 'disabled' },
io: KtxCliIo,
deps: DemoTourDeps = {},
): Promise<number> {
const waitNav = deps.waitForNavigation ?? waitForDemoNavigation;
const ensureProject = deps.ensureProject ?? ensureSeededDemoProject;
const projectDir = defaultDemoProjectDir();
await ensureProject({ projectDir, force: false });
let stepIndex = 0;
while (stepIndex < DEMO_STEPS.length) {
const step = DEMO_STEPS[stepIndex]!;
let direction: 'forward' | 'back';
if (step === 'databases') {
direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav);
} else if (step === 'sources') {
direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav);
} else if (step === 'context') {
io.stdout.write(renderDemoBanner() + '\n\n');
if (deps.skipReplayAnimation) {
direction = await waitNav();
} else {
direction = await runDemoContextReplay(io);
}
} else {
// agents step — real interactive
io.stdout.write(renderDemoAgentTransition() + '\n');
const agentsRunner = deps.agents ?? runKtxSetupAgentsStep;
const agentsResult = await agentsRunner(
{
projectDir,
inputMode: args.inputMode,
yes: false,
agents: true,
scope: 'project',
mode: 'both',
skipAgents: false,
},
io,
);
const agentInstalled = agentsResult.status === 'ready';
if (agentsResult.status === 'back') {
direction = 'back';
} else {
io.stdout.write(renderDemoCompletionSummary(projectDir, agentInstalled) + '\n');
return 0;
}
}
if (direction === 'back') {
if (stepIndex === 0) return 0;
stepIndex -= 1;
} else {
stepIndex += 1;
}
}
return 0;
}

View file

@ -676,4 +676,53 @@ describe('setup Anthropic model step', () => {
).resolves.toMatchObject({ status: 'ready' });
expect(healthCheck).not.toHaveBeenCalled();
});
it.each([
{
backend: 'vertex',
providerLines: [' backend: vertex', ' vertex:', ' project: kaelio-dev', ' location: us-east5'],
model: 'claude-sonnet-4-6',
},
{
backend: 'gateway',
providerLines: [' backend: gateway', ' gateway:', ' api_key: env:AI_GATEWAY_API_KEY'],
model: 'anthropic/claude-sonnet-4-6',
},
])('preserves already configured $backend llm setup without asking for Anthropic credentials', async (fixture) => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'setup:',
' database_connection_ids: []',
' completed_steps:',
' - project',
' - llm',
'connections: {}',
'llm:',
' provider:',
...fixture.providerLines,
' models:',
` default: ${fixture.model}`,
'ingest:',
' embeddings:',
' backend: deterministic',
' model: deterministic',
' dimensions: 8',
].join('\n'),
'utf-8',
);
const healthCheck = vi.fn(async () => ({ ok: true as const }));
const io = makeIo();
await expect(
runKtxSetupAnthropicModelStep({ projectDir: tempDir, inputMode: 'disabled', skipLlm: false }, io.io, {
healthCheck,
}),
).resolves.toMatchObject({ status: 'ready' });
expect(healthCheck).not.toHaveBeenCalled();
expect(io.stdout()).toContain(`LLM ready: yes (${fixture.model})`);
expect(io.stderr()).not.toContain('Anthropic');
});
});

View file

@ -1,5 +1,6 @@
import { writeFile } from 'node:fs/promises';
import { cancel, isCancel, password, select, text } from '@clack/prompts';
import { resolveLocalKtxLlmConfig } from '@ktx/context';
import { resolveKtxConfigReference } from '@ktx/context/core';
import {
type KtxProjectConfig,
@ -170,13 +171,26 @@ export async function fetchAnthropicModels(
return models.map((item, index) => ({ ...item, recommended: index === Math.max(recommendedIndex, 0) }));
}
function hasCompletedLlm(config: KtxProjectConfig): boolean {
return (
config.setup?.completed_steps.includes('llm') === true &&
config.llm.provider.backend === 'anthropic' &&
typeof config.llm.models.default === 'string' &&
config.llm.models.default.length > 0
);
export function isKtxSetupLlmConfigReady(config: KtxProjectLlmConfig): boolean {
let resolved: KtxLlmConfig | null;
try {
resolved = resolveLocalKtxLlmConfig(config, process.env);
} catch {
return false;
}
if (!resolved) {
return false;
}
if (resolved.backend === 'vertex') {
return typeof resolved.vertex?.location === 'string' && resolved.vertex.location.trim().length > 0;
}
return resolved.backend === 'anthropic' || resolved.backend === 'gateway';
}
function hasUsableConfiguredLlm(config: KtxProjectConfig): boolean {
return isKtxSetupLlmConfigReady(config.llm);
}
function buildProjectLlmConfig(
@ -386,7 +400,7 @@ export async function runKtxSetupAnthropicModelStep(
const project = await loadKtxProject({ projectDir: args.projectDir });
if (
args.forcePrompt !== true &&
hasCompletedLlm(project.config) &&
hasUsableConfiguredLlm(project.config) &&
!args.anthropicApiKeyEnv &&
!args.anthropicApiKeyFile &&
!args.anthropicModel

View file

@ -5,8 +5,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js';
import { runDemoTour } from './setup-demo-tour.js';
import { readKtxSetupStatus, runKtxSetup } from './setup.js';
vi.mock('./setup-demo-tour.js', () => ({
runDemoTour: vi.fn(async () => 0),
}));
function makeIo() {
let stdout = '';
let stderr = '';
@ -83,6 +88,38 @@ describe('setup status', () => {
});
});
it.each([
{
backend: 'vertex',
providerLines: [' backend: vertex', ' vertex:', ' project: kaelio-dev', ' location: us-east5'],
model: 'claude-sonnet-4-6',
},
{
backend: 'gateway',
providerLines: [' backend: gateway', ' gateway:', ' api_key: env:AI_GATEWAY_API_KEY'],
model: 'anthropic/claude-sonnet-4-6',
},
])('reports configured $backend llm backends as setup-ready', async (fixture) => {
await mkdir(tempDir, { recursive: true });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'llm:',
' provider:',
...fixture.providerLines,
' models:',
` default: ${fixture.model}`,
'connections: {}',
].join('\n'),
'utf-8',
);
await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
llm: { backend: fixture.backend, ready: true, model: fixture.model },
});
});
it('uses setup database connection ids when present', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
@ -404,10 +441,10 @@ describe('setup status', () => {
expect(labels).toEqual([
'Set up KTX for my data',
'Check setup status',
'Try KTX with packaged demo data',
'Explore a pre-built KTX project',
'Exit',
]);
expect(labels.indexOf('Try KTX with packaged demo data')).toBe(labels.length - 2);
expect(labels.indexOf('Explore a pre-built KTX project')).toBe(labels.length - 2);
return 'exit';
});
const cancel = vi.fn();
@ -453,7 +490,7 @@ describe('setup status', () => {
'Create a new KTX project',
'Connect a coding agent to KTX',
'Check setup status',
'Try KTX with packaged demo data',
'Explore a pre-built KTX project',
'Exit',
]);
return 'exit';
@ -748,9 +785,8 @@ describe('setup status', () => {
);
});
it('runs the seeded demo when the first setup intent menu chooses packaged demo data', async () => {
it('runs the demo tour when the first setup intent menu chooses demo', async () => {
const testIo = makeIo();
const demo = vi.fn(async (_args: { projectDir: string }, _io: unknown) => 0);
await expect(
runKtxSetup(
@ -771,19 +807,15 @@ describe('setup status', () => {
showEntryMenu: true,
},
testIo.io,
{ entryMenuDeps: { prompts: { select: vi.fn(async () => 'demo'), cancel: vi.fn() } }, demo },
{ entryMenuDeps: { prompts: { select: vi.fn(async () => 'demo'), cancel: vi.fn() } } },
),
).resolves.toBe(0);
expect(demo).toHaveBeenCalledWith(
expect.objectContaining({
command: 'seeded',
outputMode: 'viz',
inputMode: 'auto',
}),
expect(runDemoTour).toHaveBeenCalledWith(
{ inputMode: 'auto' },
testIo.io,
expect.objectContaining({}),
);
expect(demo.mock.calls[0]?.[0].projectDir).toMatch(/ktx-demo-/);
});
it('creates a project through run mode when --new is selected', async () => {
@ -1231,6 +1263,77 @@ describe('setup status', () => {
expect(calls).toEqual(['model', 'embeddings', 'databases', 'sources']);
});
it.each([
{
backend: 'vertex',
providerLines: [' backend: vertex', ' vertex:', ' project: kaelio-dev', ' location: us-east5'],
model: 'claude-sonnet-4-6',
},
{
backend: 'gateway',
providerLines: [' backend: gateway', ' gateway:', ' api_key: env:AI_GATEWAY_API_KEY'],
model: 'anthropic/claude-sonnet-4-6',
},
])('adds a dbt source in non-interactive setup with existing $backend llm config', async (fixture) => {
const io = makeIo();
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
' completed_steps:',
' - project',
' - databases',
'connections:',
' warehouse:',
' driver: postgres',
' url: env:WAREHOUSE_URL',
'llm:',
' provider:',
...fixture.providerLines,
' models:',
` default: ${fixture.model}`,
].join('\n'),
'utf-8',
);
await expect(
runKtxSetup(
{
command: 'run',
projectDir: tempDir,
mode: 'existing',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: true,
cliVersion: '0.2.0',
skipLlm: false,
skipEmbeddings: true,
skipDatabases: true,
source: 'dbt',
sourceConnectionId: 'dbt-main',
sourceGitUrl: 'https://github.com/Kaelio/klo-dbt-demo',
sourceBranch: 'main',
sourceProjectName: 'orbit_analytics',
sourceWarehouseConnectionId: 'warehouse',
skipSources: false,
databaseSchemas: [],
},
io.io,
{
sourcesDeps: { validateDbt: vi.fn(async () => ({ ok: true as const, detail: 'dbt project valid' })) },
context: vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir, runId: 'setup-context-test' })),
},
),
).resolves.toBe(0);
expect(io.stderr()).not.toContain('Anthropic');
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).toContain('dbt-main:');
});
it('does not fail context build when prerequisites were explicitly skipped and agents are skipped', async () => {
const calls: string[] = [];
const io = makeIo();

View file

@ -4,8 +4,6 @@ import { cancel, isCancel, select } from '@clack/prompts';
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
import { ktxLocalStateDbPath, loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import type { KtxDemoArgs } from './demo.js';
import { defaultDemoProjectDir } from './demo-assets.js';
import { formatSetupNextStepLines } from './next-steps.js';
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
import {
@ -23,7 +21,7 @@ import {
runKtxSetupDatabasesStep,
} from './setup-databases.js';
import { type KtxSetupEmbeddingsDeps, runKtxSetupEmbeddingsStep } from './setup-embeddings.js';
import { type KtxSetupModelDeps, runKtxSetupAnthropicModelStep } from './setup-models.js';
import { type KtxSetupModelDeps, isKtxSetupLlmConfigReady, runKtxSetupAnthropicModelStep } from './setup-models.js';
import { type KtxSetupProjectDeps, runKtxSetupProjectStep } from './setup-project.js';
import {
isKtxPreAgentSetupReady,
@ -149,11 +147,9 @@ export interface KtxSetupDeps {
removeAgents?: typeof removeKtxAgentInstall;
readyMenuDeps?: KtxSetupReadyMenuDeps;
entryMenuDeps?: KtxSetupEntryMenuDeps;
demo?: (args: KtxDemoArgs, io: KtxCliIo) => Promise<number>;
}
const SOURCE_DRIVERS = new Set(['dbt', 'metricflow', 'metabase', 'looker', 'lookml', 'notion']);
const READY_LLM_BACKENDS = new Set(['anthropic', 'vertex', 'gateway']);
type KtxSetupEntryAction = 'setup' | 'new-project' | 'agents' | 'status' | 'demo' | 'exit';
type KtxSetupFlowStep = 'models' | 'embeddings' | 'databases' | 'sources' | 'context' | 'agents';
@ -202,13 +198,13 @@ async function runKtxSetupEntryMenu(
{ value: 'new-project', label: 'Create a new KTX project' },
{ value: 'agents', label: 'Connect a coding agent to KTX' },
{ value: 'status', label: 'Check setup status' },
{ value: 'demo', label: 'Try KTX with packaged demo data' },
{ value: 'demo', label: 'Explore a pre-built KTX project' },
{ value: 'exit', label: 'Exit' },
]
: [
{ value: 'setup', label: 'Set up KTX for my data' },
{ value: 'status', label: 'Check setup status' },
{ value: 'demo', label: 'Try KTX with packaged demo data' },
{ value: 'demo', label: 'Explore a pre-built KTX project' },
{ value: 'exit', label: 'Exit' },
];
const action = (await prompts.select({
@ -223,24 +219,11 @@ async function runKtxSetupDemoFromEntryMenu(
io: KtxCliIo,
deps: KtxSetupDeps,
): Promise<number> {
const runner = deps.demo ?? (await import('./demo.js')).runKtxDemo;
return await runner(
{
command: 'seeded',
projectDir: defaultDemoProjectDir(),
outputMode: 'viz',
inputMode: args.inputMode,
},
const { runDemoTour } = await import('./setup-demo-tour.js');
return await runDemoTour(
{ inputMode: args.inputMode },
io,
);
}
function llmReady(status: KtxSetupStatus['llm']): boolean {
return (
status.backend !== undefined &&
READY_LLM_BACKENDS.has(status.backend) &&
typeof status.model === 'string' &&
status.model.length > 0
{ agents: deps.agents },
);
}
@ -308,10 +291,9 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
const project = await loadKtxProject({ projectDir: resolvedProjectDir });
const llm = {
backend: project.config.llm.provider.backend,
ready: false,
ready: isKtxSetupLlmConfigReady(project.config.llm),
model: project.config.llm.models.default,
};
llm.ready = llmReady(llm);
const embeddings = {
backend: project.config.ingest.embeddings.backend,
@ -419,7 +401,7 @@ function setupStatusReady(status: KtxSetupStatus): boolean {
return true;
}
return (
llmReady(status.llm) &&
status.llm.ready &&
embeddingsReady(status.embeddings) &&
status.databases.every((database) => database.ready) &&
status.sources.every((source) => source.ready)