import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { buildDefaultKtxProjectConfig, serializeKtxProjectConfig, type KtxProjectConfig } 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 { contextBuildCommands, readKtxSetupContextState, runKtxSetupContextStep, type KtxSetupContextDeps, writeKtxSetupContextState, } from '../src/setup-context.js'; function makeIo() { let stdout = ''; let stderr = ''; return { io: { stdout: { write: (chunk: string) => { stdout += chunk; }, }, stderr: { write: (chunk: string) => { stderr += chunk; }, }, }, stdout: () => stdout, stderr: () => stderr, }; } type ReadyProjectOverrides = Omit, 'ingest' | 'llm' | 'scan'> & { ingest?: Partial; llm?: Partial; scan?: Omit, 'enrichment' | 'relationships'> & { enrichment?: Partial; relationships?: Partial; }; }; async function writeReadyProject(projectDir: string, overrides: ReadyProjectOverrides = {}) { const defaults = buildDefaultKtxProjectConfig(); const readyConfig: KtxProjectConfig = { ...defaults, setup: { database_connection_ids: ['warehouse'] }, connections: { warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' }, docs: { driver: 'notion', auth_token_ref: 'env:NOTION_TOKEN', crawl_mode: 'all_accessible' }, }, llm: { provider: { backend: 'anthropic' }, models: { default: 'claude-sonnet-4-6' }, }, ingest: { ...defaults.ingest, embeddings: { backend: 'openai', model: 'text-embedding-3-small', dimensions: 1536, }, }, scan: { ...defaults.scan, enrichment: { mode: 'llm', embeddings: { backend: 'openai', model: 'text-embedding-3-small', dimensions: 1536, }, }, }, }; const nextConfig: KtxProjectConfig = { ...readyConfig, ...overrides, setup: overrides.setup ?? readyConfig.setup, connections: overrides.connections ?? readyConfig.connections, llm: { ...readyConfig.llm, ...overrides.llm, provider: overrides.llm?.provider ?? readyConfig.llm.provider, models: overrides.llm?.models ?? readyConfig.llm.models, }, ingest: { ...readyConfig.ingest, ...overrides.ingest, embeddings: overrides.ingest?.embeddings ?? readyConfig.ingest.embeddings, workUnits: overrides.ingest?.workUnits ?? readyConfig.ingest.workUnits, }, scan: { ...readyConfig.scan, ...overrides.scan, enrichment: { ...readyConfig.scan.enrichment, ...(overrides.scan?.enrichment ?? {}), }, relationships: { ...readyConfig.scan.relationships, ...(overrides.scan?.relationships ?? {}), }, }, }; await writeFile(join(projectDir, 'ktx.yaml'), serializeKtxProjectConfig(nextConfig), 'utf-8'); await writeKtxSetupState(projectDir, { completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources'], }); } async function writeScanReport( projectDir: string, syncId: string, report: { mode: string; tableDescriptions: string; columnDescriptions: string; embeddings: string; manifestShards?: string[]; completedStages?: string[]; relationships?: { accepted: number; review: number; rejected: number; skipped: number }; }, ) { const reportDir = join(projectDir, 'raw-sources', 'warehouse', 'live-database', syncId); await mkdir(reportDir, { recursive: true }); await writeFile( join(reportDir, 'scan-report.json'), `${JSON.stringify( { connectionId: 'warehouse', mode: report.mode, dryRun: false, artifactPaths: { manifestShards: report.manifestShards ?? ['semantic-layer/warehouse/_schema/public.yaml'], enrichmentArtifacts: report.mode === 'enriched' ? [`raw-sources/warehouse/live-database/${syncId}/enrichment/descriptions.json`] : [], }, enrichment: { tableDescriptions: report.tableDescriptions, columnDescriptions: report.columnDescriptions, embeddings: report.embeddings, ...(report.relationships ? { relationships: report.relationships } : {}), }, enrichmentState: { completedStages: report.completedStages ?? (report.tableDescriptions === 'completed' ? ['descriptions', 'embeddings'] : []), failedStages: report.tableDescriptions === 'failed' ? ['descriptions'] : [], }, createdAt: syncId, }, null, 2, )}\n`, ); } async function writeReadyEnrichedScanReport( projectDir: string, syncId = '2026-05-09T10:00:00.000Z', overrides: Partial[2]> = {}, ) { await writeScanReport(projectDir, syncId, { mode: 'enriched', tableDescriptions: 'completed', columnDescriptions: 'completed', embeddings: 'completed', completedStages: ['descriptions', 'embeddings', 'relationships'], relationships: { accepted: 0, review: 0, rejected: 0, skipped: 0 }, ...overrides, }); } describe('setup context build state', () => { let tempDir: string; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-context-')); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); it('reads missing state as not started and writes durable command metadata without secrets', async () => { await expect(readKtxSetupContextState(tempDir)).resolves.toMatchObject({ status: 'not_started' }); await writeKtxSetupContextState(tempDir, { runId: 'setup-context-local-abc123', status: 'stale', startedAt: '2026-05-09T10:00:00.000Z', updatedAt: '2026-05-09T10:00:00.000Z', primarySourceConnectionIds: ['warehouse'], contextSourceConnectionIds: ['docs'], reportIds: [], artifactPaths: [], retryableFailedTargets: [], commands: contextBuildCommands(tempDir), failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.', sourceProgress: [ { connectionId: 'warehouse', operation: 'database-ingest', status: 'running', percent: 42, message: 'Generating descriptions 4/10 tables', updatedAtMs: 1000, }, ], }); const state = await readKtxSetupContextState(tempDir); expect(state).toMatchObject({ runId: 'setup-context-local-abc123', status: 'stale', primarySourceConnectionIds: ['warehouse'], contextSourceConnectionIds: ['docs'], commands: { build: `ktx setup --project-dir ${tempDir}`, status: `ktx status --project-dir ${tempDir}`, }, failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.', sourceProgress: [ { connectionId: 'warehouse', operation: 'database-ingest', status: 'running', percent: 42, message: 'Generating descriptions 4/10 tables', updatedAtMs: 1000, }, ], }); expect(JSON.stringify(state)).not.toContain('DATABASE_URL'); expect(JSON.stringify(state)).not.toContain('NOTION_TOKEN'); }); it('runs setup context build, verifies readiness, and marks context complete', async () => { await writeReadyProject(tempDir); const io = makeIo(); const runContextBuildMock = vi.fn(async () => ({ exitCode: 0, reportIds: ['report-docs-1'], artifactPaths: ['raw-sources/warehouse/live-database/sync-1/scan-report.json'], })); const verifyContextReady = vi.fn(async () => ({ ready: true, agentContextReady: true, semanticSearchReady: true, details: ['warehouse: enriched scan complete', 'docs: memory update complete'], })); await expect( runKtxSetupContextStep( { projectDir: tempDir, inputMode: 'disabled' }, io.io, { runIdFactory: () => 'setup-context-local-abc123', now: () => new Date('2026-05-09T10:00:00.000Z'), runContextBuild: runContextBuildMock, verifyContextReady, }, ), ).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-abc123' }); expect(runContextBuildMock).toHaveBeenCalledWith( expect.objectContaining({ projectDir: tempDir }), expect.objectContaining({ projectDir: tempDir, inputMode: 'disabled', }), io.io, expect.objectContaining({ onSourceProgress: expect.any(Function) }), ); expect(verifyContextReady).toHaveBeenCalledWith(tempDir); expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:'); expect((await readKtxSetupState(tempDir)).completed_steps).toContain('context'); await expect(readKtxSetupContextState(tempDir)).resolves.toMatchObject({ runId: 'setup-context-local-abc123', status: 'completed', completedAt: '2026-05-09T10:00:00.000Z', reportIds: ['report-docs-1'], artifactPaths: ['raw-sources/warehouse/live-database/sync-1/scan-report.json'], }); expect(io.stdout()).toContain('KTX context is ready for agents.'); expect(io.stdout()).toContain('Databases:'); expect(io.stdout()).not.toContain(['Primary sources', ':'].join('')); }); it('records only failed sources as retryable when the context build fails', async () => { await writeReadyProject(tempDir); const io = makeIo(); const runContextBuildMock = vi.fn(async (_project, _args, _io, hooks) => { hooks.onSourceProgress?.([ { connectionId: 'warehouse', operation: 'database-ingest', status: 'done', elapsedMs: 1000 }, { connectionId: 'docs', operation: 'source-ingest', status: 'failed', elapsedMs: 2000 }, ]); return { exitCode: 1, reportIds: ['report-docs-failed'], artifactPaths: ['raw-sources/docs/notion/sync-1/ingest-report.json'], }; }); await expect( runKtxSetupContextStep( { projectDir: tempDir, inputMode: 'disabled' }, io.io, { runIdFactory: () => 'setup-context-local-failed', now: () => new Date('2026-05-09T10:00:00.000Z'), runContextBuild: runContextBuildMock, }, ), ).resolves.toEqual({ status: 'failed', projectDir: tempDir }); await expect(readKtxSetupContextState(tempDir)).resolves.toMatchObject({ runId: 'setup-context-local-failed', status: 'failed', reportIds: ['report-docs-failed'], artifactPaths: ['raw-sources/docs/notion/sync-1/ingest-report.json'], retryableFailedTargets: ['docs'], sourceProgress: [ { connectionId: 'warehouse', operation: 'database-ingest', status: 'done', elapsedMs: 1000 }, { connectionId: 'docs', operation: 'source-ingest', status: 'failed', elapsedMs: 2000 }, ], }); }); it('marks context complete without prompting when initial source ingest already made agent context', async () => { await writeReadyProject(tempDir); await mkdir(join(tempDir, 'semantic-layer', 'dbt-main'), { recursive: true }); await mkdir(join(tempDir, 'wiki', 'global'), { recursive: true }); await writeFile(join(tempDir, 'semantic-layer', 'dbt-main', 'mart_revenue_daily.yaml'), 'name: mart_revenue_daily\n'); await writeFile(join(tempDir, 'wiki', 'global', 'metrics.md'), '# Metrics\n'); await writeReadyEnrichedScanReport(tempDir); const io = makeIo(); const runContextBuildMock = vi.fn>(async () => ({ exitCode: 0, })); await expect( runKtxSetupContextStep( { projectDir: tempDir, inputMode: 'auto' }, io.io, { prompts: { select: vi.fn(async () => { throw new Error('setup should not prompt when context is already ready'); }), cancel: vi.fn(), }, runIdFactory: () => 'setup-context-local-existing', now: () => new Date('2026-05-09T10:00:00.000Z'), runContextBuild: runContextBuildMock, }, ), ).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-existing' }); expect(runContextBuildMock).not.toHaveBeenCalled(); expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:'); expect((await readKtxSetupState(tempDir)).completed_steps).toContain('context'); await expect(readKtxSetupContextState(tempDir)).resolves.toMatchObject({ runId: 'setup-context-local-existing', status: 'completed', completedAt: '2026-05-09T10:00:00.000Z', contextSourceConnectionIds: ['docs'], }); expect(io.stdout()).toContain('KTX context is ready for agents.'); expect(io.stdout()).not.toContain(['Primary sources', ':'].join('')); }); it('does not mark context ready until primary scans have completed description enrichment', async () => { await writeReadyProject(tempDir); await mkdir(join(tempDir, 'semantic-layer', 'dbt-main'), { recursive: true }); await writeFile(join(tempDir, 'semantic-layer', 'dbt-main', 'mart_revenue_daily.yaml'), 'name: mart_revenue_daily\n'); await writeScanReport(tempDir, '2026-05-09T09:59:00.000Z', { mode: 'structural', tableDescriptions: 'skipped', columnDescriptions: 'skipped', embeddings: 'skipped', }); const io = makeIo(); const runContextBuildMock = vi.fn(async () => { await writeReadyEnrichedScanReport(tempDir, '2026-05-09T10:00:00.000Z'); return { exitCode: 0 }; }); await expect( runKtxSetupContextStep( { projectDir: tempDir, inputMode: 'disabled' }, io.io, { runIdFactory: () => 'setup-context-local-enriched-scan', now: () => new Date('2026-05-09T10:00:00.000Z'), runContextBuild: runContextBuildMock, }, ), ).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-enriched-scan' }); expect(runContextBuildMock).toHaveBeenCalledOnce(); expect(io.stdout()).not.toContain('Existing context artifacts were found from setup ingest.'); }); it('requires completed relationships for database context when relationship discovery is enabled', async () => { await writeReadyProject(tempDir, { connections: { warehouse: { driver: 'postgres', readonly: true }, }, scan: { relationships: { enabled: true } }, }); await mkdir(join(tempDir, 'semantic-layer', 'dbt-main'), { recursive: true }); await writeFile(join(tempDir, 'semantic-layer', 'dbt-main', 'mart_revenue_daily.yaml'), 'name: mart_revenue_daily\n'); await writeReadyEnrichedScanReport(tempDir, '2026-05-09T10:00:00.000Z', { completedStages: ['descriptions', 'embeddings'], relationships: { accepted: 0, review: 0, rejected: 0, skipped: 0 }, }); const io = makeIo(); const runContextBuildMock = vi.fn(async () => { await writeReadyEnrichedScanReport(tempDir, '2026-05-09T10:01:00.000Z', { completedStages: ['descriptions', 'embeddings', 'relationships'], relationships: { accepted: 0, review: 0, rejected: 0, skipped: 0 }, }); return { exitCode: 0 }; }); await expect( runKtxSetupContextStep( { projectDir: tempDir, inputMode: 'disabled' }, io.io, { runContextBuild: runContextBuildMock }, ), ).resolves.toMatchObject({ status: 'ready' }); expect(runContextBuildMock).toHaveBeenCalledOnce(); }); it('does not require relationships for database context when relationship discovery is disabled', async () => { await writeReadyProject(tempDir, { connections: { warehouse: { driver: 'postgres', readonly: true }, }, scan: { relationships: { enabled: false } }, }); await mkdir(join(tempDir, 'semantic-layer', 'dbt-main'), { recursive: true }); await writeFile(join(tempDir, 'semantic-layer', 'dbt-main', 'mart_revenue_daily.yaml'), 'name: mart_revenue_daily\n'); await writeReadyEnrichedScanReport(tempDir, '2026-05-09T10:00:00.000Z', { completedStages: ['descriptions', 'embeddings'], }); const io = makeIo(); const runContextBuildMock = vi.fn(async () => ({ exitCode: 0 })); await expect( runKtxSetupContextStep( { projectDir: tempDir, inputMode: 'disabled' }, io.io, { runContextBuild: runContextBuildMock }, ), ).resolves.toMatchObject({ status: 'ready' }); expect(runContextBuildMock).not.toHaveBeenCalled(); }); it('refuses empty setup context builds', async () => { await writeFile( join(tempDir, 'ktx.yaml'), [ 'connections: {}', 'llm:', ' provider:', ' backend: anthropic', ' models:', ' default: claude-sonnet-4-6', 'ingest:', ' embeddings:', ' backend: openai', ' model: text-embedding-3-small', ' dimensions: 1536', '', ].join('\n'), 'utf-8', ); const io = makeIo(); await expect( runKtxSetupContextStep( { projectDir: tempDir, inputMode: 'disabled' }, io.io, { runIdFactory: () => 'setup-context-local-empty' }, ), ).resolves.toEqual({ status: 'failed', projectDir: tempDir }); expect(io.stderr()).toContain('No databases or context sources are configured for a KTX context build.'); }); it('starts a fresh foreground build when stale state is found', async () => { await writeReadyProject(tempDir, { connections: { warehouse: { driver: 'postgres', readonly: true } }, }); await writeKtxSetupContextState(tempDir, { runId: 'setup-context-local-stale', status: 'stale', startedAt: '2026-05-09T09:00:00.000Z', updatedAt: '2026-05-09T09:00:00.000Z', primarySourceConnectionIds: ['warehouse'], contextSourceConnectionIds: [], reportIds: [], artifactPaths: [], retryableFailedTargets: [], commands: contextBuildCommands(tempDir), failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.', }); const io = makeIo(); const runContextBuildMock = vi.fn(async () => ({ exitCode: 0 })); const verifyContextReady = vi.fn(async () => ({ ready: true, agentContextReady: true, semanticSearchReady: true, details: ['ready'], })); await expect( runKtxSetupContextStep( { projectDir: tempDir, inputMode: 'disabled' }, io.io, { runContextBuild: runContextBuildMock, verifyContextReady }, ), ).resolves.toMatchObject({ status: 'ready' }); expect(runContextBuildMock).toHaveBeenCalledOnce(); }); });