From ca61f3e08eaae8c7a2717cc6a69e534e463110c2 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 13 May 2026 18:32:13 +0200 Subject: [PATCH] fix(setup): keep context build foreground only --- packages/cli/src/context-build-view.test.ts | 69 ++--- packages/cli/src/context-build-view.ts | 102 +------ packages/cli/src/setup-context.test.ts | 287 +++---------------- packages/cli/src/setup-context.ts | 296 ++------------------ packages/cli/src/setup.test.ts | 169 +---------- 5 files changed, 83 insertions(+), 840 deletions(-) diff --git a/packages/cli/src/context-build-view.test.ts b/packages/cli/src/context-build-view.test.ts index f0d085d1..a6db9b9e 100644 --- a/packages/cli/src/context-build-view.test.ts +++ b/packages/cli/src/context-build-view.test.ts @@ -337,16 +337,23 @@ describe('renderContextBuildView', () => { expect(output).toContain('Context sources:'); }); - it('preserves detach hint while targets are active', () => { + it('renders foreground-only progress hints without detach or resume commands', () => { const state = initViewState([ - { connectionId: 'warehouse', driver: 'postgres', operation: 'database-ingest', debugCommand: '', steps: ['database-schema'] }, + { + connectionId: 'warehouse', + driver: 'postgres', + operation: 'database-ingest', + debugCommand: 'ktx ingest warehouse --debug', + steps: ['database-schema'], + }, ]); state.primarySources[0].status = 'running'; - const output = renderContextBuildView(state, { styled: false, showHint: true, projectDir: '/tmp/project' }); - expect(output).toContain('d to detach'); - expect(output).toContain('ktx setup --project-dir /tmp/project'); - expect(output).toContain('to resume'); + const rendered = renderContextBuildView(state, { styled: false, showHint: true, projectDir: '/tmp/project' }); + + expect(rendered).toContain('Ctrl+C to stop'); + expect(rendered).not.toContain('d to detach'); + expect(rendered).not.toContain('resume'); }); it('omits detach hint when all targets are done', () => { @@ -357,7 +364,7 @@ describe('renderContextBuildView', () => { state.totalElapsedMs = 5000; const output = renderContextBuildView(state, { styled: false, showHint: true }); - expect(output).not.toContain('d to detach'); + expect(output).not.toContain('Ctrl+C to stop'); }); }); @@ -417,7 +424,7 @@ describe('runContextBuild', () => { { executeTarget, now: () => 1000 }, ); - expect(result).toEqual({ exitCode: 0, detached: false }); + expect(result).toEqual({ exitCode: 0 }); expect(callOrder).toEqual(['warehouse', 'dbt_main']); }); @@ -435,7 +442,7 @@ describe('runContextBuild', () => { { executeTarget, now: () => 1000 }, ); - expect(result).toEqual({ exitCode: 1, detached: false }); + expect(result).toEqual({ exitCode: 1 }); }); it('renders a friendly network failure when target output contains a network error code', async () => { @@ -455,7 +462,7 @@ describe('runContextBuild', () => { { executeTarget, now: () => 1000 }, ); - expect(result).toEqual({ exitCode: 1, detached: false }); + expect(result).toEqual({ exitCode: 1 }); expect(io.stdout()).toContain('KTX lost its connection to PostgreSQL while reading schema for warehouse.'); expect(io.stdout()).toContain('network address unavailable (EADDRNOTAVAIL)'); expect(io.stdout()).toContain('Retry: ktx setup --project-dir /tmp/project'); @@ -479,7 +486,7 @@ describe('runContextBuild', () => { { executeTarget, now: () => 1000 }, ); - expect(result).toEqual({ exitCode: 1, detached: false }); + expect(result).toEqual({ exitCode: 1 }); expect(io.stdout()).toContain('KTX lost its connection to PostgreSQL while reading schema for warehouse.'); expect(io.stdout()).toContain('connection reset (ECONNRESET)'); }); @@ -531,44 +538,6 @@ describe('runContextBuild', () => { ); }); - it('exits immediately with paused message when d is pressed', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit'); - }); - const io = makeIo(); - const project = projectWithConnections({ - warehouse: { driver: 'postgres' }, - dbt_main: { driver: 'dbt' }, - }); - let triggerDetach: (() => void) | null = null; - const executeTarget = vi.fn(async (target) => { - if (target.connectionId === 'warehouse') triggerDetach?.(); - return successResult(target.connectionId, target.driver, target.operation); - }); - - await expect( - runContextBuild( - project, - { projectDir: '/tmp/project', inputMode: 'disabled' }, - io.io, - { - executeTarget, - now: () => 1000, - setupKeystroke: (onDetach) => { - triggerDetach = onDetach; - return () => {}; - }, - }, - ), - ).rejects.toThrow('process.exit'); - - expect(mockExit).toHaveBeenCalledWith(0); - expect(io.stdout()).toContain('Context build continuing in the background.'); - expect(io.stdout()).toContain('Resume: ktx setup --project-dir /tmp/project'); - expect(io.stdout()).toContain('Status: ktx status --project-dir /tmp/project'); - mockExit.mockRestore(); - }); - it('calls onSourceProgress when sources start and finish', async () => { const io = makeIo(); const project = projectWithConnections({ @@ -673,7 +642,6 @@ describe('runContextBuild', () => { expect(result).toMatchObject({ exitCode: 0, - detached: false, reportIds: ['report-dbt-1'], artifactPaths: [ 'raw-sources/warehouse/live-database/sync-1/scan-report.json', @@ -707,7 +675,6 @@ describe('runContextBuild', () => { expect(result).toMatchObject({ exitCode: 1, - detached: false, reportIds: ['report-dbt-failed'], }); }); diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts index a6a0deb1..7a4d36e8 100644 --- a/packages/cli/src/context-build-view.ts +++ b/packages/cli/src/context-build-view.ts @@ -1,6 +1,4 @@ -import { spawn } from 'node:child_process'; -import { mkdirSync, openSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { resolve } from 'node:path'; import type { KtxProgressPort, KtxProgressUpdateOptions } from '@ktx/context/scan'; import type { KtxCliIo } from './index.js'; import type { KtxIngestProgressUpdate } from './ingest.js'; @@ -48,7 +46,6 @@ export interface ContextBuildArgs { export interface ContextBuildResult { exitCode: number; - detached: boolean; reportIds?: string[]; artifactPaths?: string[]; } @@ -68,8 +65,6 @@ export interface ContextBuildSourceProgressUpdate { export interface ContextBuildDeps { executeTarget?: typeof executePublicIngestTarget; now?: () => number; - setupKeystroke?: (onDetach: () => void, onCtrlC: () => void) => (() => void) | null; - onDetach?: () => void; onSourceProgress?: (sources: ContextBuildSourceProgressUpdate[]) => void; sourceProgressThrottleMs?: number; } @@ -243,7 +238,7 @@ export function renderContextBuildView( } if (options.showHint && hasActive) { - const hintContent = options.hintText ?? `d to detach ยท ${resumeCommand(options.projectDir)} to resume`; + const hintContent = options.hintText ?? 'Ctrl+C to stop'; const hint = ` ${hintContent}`; lines.push(styled ? dim(hint) : hint); lines.push(''); @@ -444,57 +439,6 @@ export function createRepainter(io: KtxCliIo) { }; } -// --- Background build --- - -function resolveKtxEntryScript(): string | null { - const argv1 = process.argv[1]; - if (argv1 && (argv1.endsWith('.js') || argv1.endsWith('.ts') || argv1.endsWith('.mjs'))) { - return argv1; - } - return null; -} - -function spawnBackgroundBuild(projectDir: string): { logPath: string } | null { - const entryScript = resolveKtxEntryScript(); - if (!entryScript) return null; - - const resolvedDir = resolve(projectDir); - const logDir = join(resolvedDir, '.ktx', 'setup'); - mkdirSync(logDir, { recursive: true }); - const logPath = join(logDir, 'context-build.log'); - const logFd = openSync(logPath, 'w'); - - const child = spawn( - process.execPath, - [entryScript, 'setup', '--project-dir', resolvedDir, '--no-input'], - { detached: true, stdio: ['ignore', logFd, logFd] }, - ); - child.unref(); - return { logPath }; -} - -// --- Keystroke handling --- - -export function defaultSetupKeystroke(onDetach: () => void, onCtrlC: () => void): (() => void) | null { - const stdin = process.stdin; - if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') { - return null; - } - stdin.setRawMode(true); - stdin.resume(); - const onData = (data: Buffer) => { - const char = data.toString(); - if (char === 'd' || char === 'D') onDetach(); - else if (char === '\x03') onCtrlC(); - }; - stdin.on('data', onData); - return () => { - stdin.off('data', onData); - if (typeof stdin.setRawMode === 'function') stdin.setRawMode(false); - stdin.pause(); - }; -} - // --- Orchestration --- function makeTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTargetState { @@ -668,37 +612,6 @@ export async function runContextBuild( return true; }; - let detached = false; - let exiting = false; - let cleanupKeystroke: (() => void) | null = null; - - if (isTTY || deps.setupKeystroke) { - const cleanup = () => { - if (spinnerInterval) clearInterval(spinnerInterval); - cleanupKeystroke?.(); - }; - cleanupKeystroke = (deps.setupKeystroke ?? defaultSetupKeystroke)( - () => { - detached = true; - cleanup(); - deps.onDetach?.(); - const bg = spawnBackgroundBuild(args.projectDir); - io.stdout.write('\n\nContext build continuing in the background.\n'); - if (bg) io.stdout.write(`Log: ${bg.logPath}\n`); - io.stdout.write(`Resume: ${resumeCommand(args.projectDir)}\n`); - io.stdout.write(`Status: ktx status --project-dir ${resolve(args.projectDir)}\n`); - exiting = true; - process.exit(0); - }, - () => { - cleanup(); - io.stdout.write('\n\nContext build stopped. Nothing is running in the background.\n'); - io.stdout.write(`Resume: ${resumeCommand(args.projectDir)}\n`); - exiting = true; - process.exit(130); - }, - ); - } const runArgs: Extract = { command: 'run', projectDir: args.projectDir, @@ -713,8 +626,6 @@ export async function runContextBuild( try { for (const targetState of orderedTargets) { - if (detached) break; - targetState.status = 'running'; targetState.startedAt = nowFn(); paint(true); @@ -747,9 +658,6 @@ export async function runContextBuild( try { result = await execTarget(targetState.target, runArgs, capture.io, progressDeps); } catch (error) { - if (exiting) { - throw error; - } thrownError = error; } @@ -786,17 +694,12 @@ export async function runContextBuild( } } finally { if (spinnerInterval) clearInterval(spinnerInterval); - cleanupKeystroke?.(); } if (state.startedAt !== null) { state.totalElapsedMs = nowFn() - state.startedAt; } - if (detached) { - return { exitCode: 0, detached: true }; - } - if (!repainter) { io.stdout.write(renderContextBuildView(state, { ...viewOpts, styled: false })); } else { @@ -805,7 +708,6 @@ export async function runContextBuild( return { exitCode: hasFailure ? 1 : 0, - detached: false, ...(reportIds.size > 0 ? { reportIds: [...reportIds] } : {}), ...(artifactPaths.size > 0 ? { artifactPaths: [...artifactPaths] } : {}), }; diff --git a/packages/cli/src/setup-context.test.ts b/packages/cli/src/setup-context.test.ts index 8b0e9e24..1fe34b28 100644 --- a/packages/cli/src/setup-context.test.ts +++ b/packages/cli/src/setup-context.test.ts @@ -212,14 +212,14 @@ describe('setup context build state', () => { const state = await readKtxSetupContextState(tempDir); expect(state).toMatchObject({ runId: 'setup-context-local-abc123', - status: 'running', + status: 'stale', primarySourceConnectionIds: ['warehouse'], contextSourceConnectionIds: ['docs'], commands: { - watch: `ktx setup --project-dir ${tempDir}`, + build: `ktx setup --project-dir ${tempDir}`, status: `ktx status --project-dir ${tempDir}`, - resume: `ktx setup --project-dir ${tempDir}`, }, + failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.', sourceProgress: [ { connectionId: 'warehouse', @@ -240,7 +240,6 @@ describe('setup context build state', () => { const io = makeIo(); const runContextBuildMock = vi.fn(async () => ({ exitCode: 0, - detached: false, reportIds: ['report-docs-1'], artifactPaths: ['raw-sources/warehouse/live-database/sync-1/scan-report.json'], })); @@ -271,7 +270,7 @@ describe('setup context build state', () => { inputMode: 'disabled', }), io.io, - expect.objectContaining({ onDetach: expect.any(Function) }), + expect.objectContaining({ onSourceProgress: expect.any(Function) }), ); expect(verifyContextReady).toHaveBeenCalledWith(tempDir); expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:'); @@ -296,7 +295,6 @@ describe('setup context build state', () => { ]); return { exitCode: 1, - detached: false, reportIds: ['report-docs-failed'], artifactPaths: ['raw-sources/docs/notion/sync-1/ingest-report.json'], }; @@ -335,7 +333,7 @@ describe('setup context build state', () => { await writeFile(join(tempDir, 'wiki', 'global', 'metrics.md'), '# Metrics\n'); await writeReadyEnrichedScanReport(tempDir); const io = makeIo(); - const runContextBuildMock = vi.fn(async () => ({ exitCode: 0, detached: false })); + const runContextBuildMock = vi.fn(async () => ({ exitCode: 0 })); await expect( runKtxSetupContextStep( @@ -380,7 +378,7 @@ describe('setup context build state', () => { const io = makeIo(); const runContextBuildMock = vi.fn(async () => { await writeReadyEnrichedScanReport(tempDir, '2026-05-09T10:00:00.000Z'); - return { exitCode: 0, detached: false }; + return { exitCode: 0 }; }); await expect( @@ -607,277 +605,60 @@ describe('setup context build state', () => { expect(io.stderr()).toContain('No primary or context sources are configured for a KTX context build.'); }); - it('watches an already-running setup context build from the resume prompt', async () => { + it('normalizes legacy detached and paused setup context states to stale', async () => { await writeReadyProject(tempDir); await writeKtxSetupContextState(tempDir, { - runId: 'setup-context-local-resume-watch', - status: 'detached', - startedAt: '2026-05-09T10:00:00.000Z', - updatedAt: '2026-05-09T10:00:00.000Z', - primarySourceConnectionIds: ['warehouse'], - contextSourceConnectionIds: ['docs'], - reportIds: [], - artifactPaths: [], - retryableFailedTargets: [], - commands: contextBuildCommands(tempDir, 'setup-context-local-resume-watch'), - }); - const io = makeIo(); - const completeRun = async () => { - await writeKtxSetupContextState(tempDir, { - runId: 'setup-context-local-resume-watch', - status: 'completed', - startedAt: '2026-05-09T10:00:00.000Z', - updatedAt: '2026-05-09T10:02:00.000Z', - completedAt: '2026-05-09T10:02:00.000Z', - primarySourceConnectionIds: ['warehouse'], - contextSourceConnectionIds: ['docs'], - reportIds: [], - artifactPaths: [], - retryableFailedTargets: [], - commands: contextBuildCommands(tempDir, 'setup-context-local-resume-watch'), - }); - }; - const select = vi.fn(async (options: { options: Array<{ value: string; label: string }> }) => { - expect(options.options.map((option) => option.label)).toContain('Watch progress'); - return 'watch'; - }); - - await expect( - runKtxSetupContextStep( - { projectDir: tempDir, inputMode: 'auto' }, - io.io, - { - prompts: { select, cancel: vi.fn() }, - sleep: completeRun, - watchIntervalMs: 1, - }, - ), - ).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-resume-watch' }); - expect(io.stdout()).toContain('KTX context built: detached'); - expect(io.stdout()).toContain('KTX context built: yes'); - }); - - it('auto-watches a running build without prompting when autoWatch is true', async () => { - await writeReadyProject(tempDir); - await writeKtxSetupContextState(tempDir, { - runId: 'setup-context-local-auto-watch', - status: 'detached', - startedAt: '2026-05-09T10:00:00.000Z', - updatedAt: '2026-05-09T10:00:00.000Z', + runId: 'setup-context-local-old', + status: 'detached' as never, + startedAt: '2026-05-09T09:00:00.000Z', + updatedAt: '2026-05-09T09:00:00.000Z', primarySourceConnectionIds: ['warehouse'], contextSourceConnectionIds: [], reportIds: [], artifactPaths: [], retryableFailedTargets: [], - commands: contextBuildCommands(tempDir, 'setup-context-local-auto-watch'), - }); - const io = makeIo(); - const completeRun = async () => { - await writeKtxSetupContextState(tempDir, { - runId: 'setup-context-local-auto-watch', - status: 'completed', - startedAt: '2026-05-09T10:00:00.000Z', - updatedAt: '2026-05-09T10:02:00.000Z', - completedAt: '2026-05-09T10:02:00.000Z', - primarySourceConnectionIds: ['warehouse'], - contextSourceConnectionIds: [], - reportIds: [], - artifactPaths: [], - retryableFailedTargets: [], - commands: contextBuildCommands(tempDir, 'setup-context-local-auto-watch'), - }); - }; - const select = vi.fn(async () => { - throw new Error('should not prompt when autoWatch is true'); + commands: contextBuildCommands(tempDir, 'setup-context-local-old'), }); - await expect( - runKtxSetupContextStep( - { projectDir: tempDir, inputMode: 'auto', autoWatch: true }, - io.io, - { - prompts: { select, cancel: vi.fn() }, - sleep: completeRun, - watchIntervalMs: 1, - }, - ), - ).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-auto-watch' }); - expect(select).not.toHaveBeenCalled(); - expect(io.stdout()).toContain('KTX context built: yes'); + await expect(readKtxSetupContextState(tempDir)).resolves.toMatchObject({ + status: 'stale', + failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.', + }); }); - it('renders the progress view when watching a build with sourceProgress', async () => { - await writeReadyProject(tempDir); - await writeKtxSetupContextState(tempDir, { - runId: 'setup-context-local-progress', - status: 'detached', - startedAt: '2026-05-09T10:00:00.000Z', - updatedAt: '2026-05-09T10:00:00.000Z', - primarySourceConnectionIds: ['warehouse'], - contextSourceConnectionIds: ['docs'], - reportIds: [], - artifactPaths: [], - retryableFailedTargets: [], - commands: contextBuildCommands(tempDir, 'setup-context-local-progress'), - sourceProgress: [ - { connectionId: 'warehouse', operation: 'database-ingest' as const, status: 'done' as const, elapsedMs: 30000 }, - { connectionId: 'docs', operation: 'source-ingest' as const, status: 'running' as const, startedAtMs: Date.now() - 5000 }, - ], + it('starts a fresh foreground build when a stale running state is found', async () => { + await writeReadyProject(tempDir, { + connections: { warehouse: { driver: 'postgres', readonly: true, context: { depth: 'fast' } } }, }); - const io = makeIo(); - const completeRun = async () => { - await writeKtxSetupContextState(tempDir, { - runId: 'setup-context-local-progress', - status: 'completed', - startedAt: '2026-05-09T10:00:00.000Z', - updatedAt: '2026-05-09T10:02:00.000Z', - completedAt: '2026-05-09T10:02:00.000Z', - primarySourceConnectionIds: ['warehouse'], - contextSourceConnectionIds: ['docs'], - reportIds: [], - artifactPaths: [], - retryableFailedTargets: [], - commands: contextBuildCommands(tempDir, 'setup-context-local-progress'), - sourceProgress: [ - { connectionId: 'warehouse', operation: 'database-ingest' as const, status: 'done' as const, elapsedMs: 30000 }, - { connectionId: 'docs', operation: 'source-ingest' as const, status: 'done' as const, elapsedMs: 60000 }, - ], - }); - }; - const select = vi.fn(async () => 'watch'); - - await expect( - runKtxSetupContextStep( - { projectDir: tempDir, inputMode: 'auto' }, - io.io, - { - prompts: { select, cancel: vi.fn() }, - sleep: completeRun, - watchIntervalMs: 1, - }, - ), - ).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-progress' }); - - const output = io.stdout(); - expect(output).toContain('Building KTX context'); - expect(output).toContain('Databases:'); - expect(output).toContain('warehouse'); - expect(output).toContain('Context sources:'); - expect(output).toContain('docs'); - expect(output).not.toContain('KTX context built: detached'); - }); - - it('re-renders the compact progress view when watched source messages change', async () => { - await writeReadyProject(tempDir); await writeKtxSetupContextState(tempDir, { - runId: 'setup-context-local-progress-message', - status: 'detached', - startedAt: '2026-05-09T10:00:00.000Z', - updatedAt: '2026-05-09T10:00:00.000Z', - primarySourceConnectionIds: ['warehouse'], - contextSourceConnectionIds: [], - reportIds: [], - artifactPaths: [], - retryableFailedTargets: [], - commands: contextBuildCommands(tempDir, 'setup-context-local-progress-message'), - sourceProgress: [ - { - connectionId: 'warehouse', - operation: 'database-ingest' as const, - status: 'running' as const, - startedAtMs: Date.now() - 5000, - percent: 35, - message: 'Inspecting database schema', - updatedAtMs: 1000, - }, - ], - }); - const io = makeIo(); - let polls = 0; - const updateRun = async () => { - polls++; - await writeKtxSetupContextState(tempDir, { - runId: 'setup-context-local-progress-message', - status: polls === 1 ? 'detached' : 'completed', - startedAt: '2026-05-09T10:00:00.000Z', - updatedAt: polls === 1 ? '2026-05-09T10:00:01.000Z' : '2026-05-09T10:00:02.000Z', - ...(polls === 1 ? {} : { completedAt: '2026-05-09T10:00:02.000Z' }), - primarySourceConnectionIds: ['warehouse'], - contextSourceConnectionIds: [], - reportIds: [], - artifactPaths: [], - retryableFailedTargets: [], - commands: contextBuildCommands(tempDir, 'setup-context-local-progress-message'), - sourceProgress: [ - { - connectionId: 'warehouse', - operation: 'database-ingest' as const, - status: polls === 1 ? ('running' as const) : ('done' as const), - startedAtMs: Date.now() - 5000, - elapsedMs: polls === 1 ? undefined : 6000, - percent: polls === 1 ? 76 : undefined, - message: polls === 1 ? 'Building embeddings 3/4 batches' : undefined, - updatedAtMs: polls === 1 ? 2000 : undefined, - summaryText: polls === 1 ? undefined : '42 tables', - }, - ], - }); - }; - - await expect( - runKtxSetupContextStep( - { projectDir: tempDir, inputMode: 'auto', autoWatch: true }, - io.io, - { - sleep: updateRun, - watchIntervalMs: 1, - }, - ), - ).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-progress-message' }); - - expect(io.stdout()).toContain('Inspecting database schema'); - expect(io.stdout()).toContain('Building embeddings 3/4 batches'); - expect(io.stdout()).toContain('warehouse'); - }); - - it('supports d to detach from the progress watch view', async () => { - await writeReadyProject(tempDir); - await writeKtxSetupContextState(tempDir, { - runId: 'setup-context-local-detach', + runId: 'setup-context-local-running', status: 'running', - startedAt: '2026-05-09T10:00:00.000Z', - updatedAt: '2026-05-09T10:00:00.000Z', + startedAt: '2026-05-09T09:00:00.000Z', + updatedAt: '2026-05-09T09:00:00.000Z', primarySourceConnectionIds: ['warehouse'], contextSourceConnectionIds: [], reportIds: [], artifactPaths: [], retryableFailedTargets: [], - commands: contextBuildCommands(tempDir, 'setup-context-local-detach'), - sourceProgress: [ - { connectionId: 'warehouse', operation: 'database-ingest' as const, status: 'running' as const, startedAtMs: Date.now() }, - ], + commands: contextBuildCommands(tempDir, 'setup-context-local-running'), }); const io = makeIo(); - let triggerDetach: (() => void) | null = null; + 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: 'auto', autoWatch: true }, + { projectDir: tempDir, inputMode: 'disabled' }, io.io, - { - sleep: async () => { triggerDetach?.(); }, - watchIntervalMs: 1, - setupKeystroke: (onDetach) => { - triggerDetach = onDetach; - return () => {}; - }, - }, + { runContextBuild: runContextBuildMock, verifyContextReady }, ), - ).resolves.toMatchObject({ status: 'detached' }); + ).resolves.toMatchObject({ status: 'ready' }); - const output = io.stdout(); - expect(output).toContain('Building KTX context'); - expect(output).toContain('Context build continuing in the background.'); - expect(output).toContain('Resume: ktx setup --project-dir'); + expect(runContextBuildMock).toHaveBeenCalledOnce(); }); }); diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index 835b42d7..f369110d 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -21,11 +21,7 @@ import { } from './ingest-depth.js'; import { type ContextBuildSourceProgressUpdate, - createRepainter, - defaultSetupKeystroke, - renderContextBuildView, runContextBuild, - viewStateFromSourceProgress, } from './context-build-view.js'; import { createKtxSetupPromptAdapter, @@ -35,8 +31,6 @@ import { export type KtxSetupContextBuildStatus = | 'not_started' | 'running' - | 'detached' - | 'paused' | 'completed' | 'failed' | 'interrupted' @@ -44,10 +38,7 @@ export type KtxSetupContextBuildStatus = export interface KtxSetupContextCommands { build: string; - watch: string; status: string; - stop: string; - resume: string; } export interface KtxSetupContextState { @@ -70,7 +61,6 @@ export interface KtxSetupContextStatusSummary { ready: boolean; status: KtxSetupContextBuildStatus; runId?: string; - watchCommand?: string; statusCommand?: string; retryCommand?: string; detail?: string; @@ -87,8 +77,6 @@ export interface KtxSetupContextReadiness { export type KtxSetupContextResult = | { status: 'ready'; projectDir: string; runId: string } | { status: 'skipped'; projectDir: string } - | { status: 'detached'; projectDir: string; runId: string } - | { status: 'paused'; projectDir: string; runId: string } | { status: 'back'; projectDir: string } | { status: 'missing-input'; projectDir: string } | { status: 'failed'; projectDir: string }; @@ -102,12 +90,6 @@ export interface KtxSetupContextStepArgs { autoWatch?: boolean; } -interface KtxSetupContextWatchArgs { - projectDir: string; - runId?: string; - inputMode: 'auto' | 'disabled'; -} - export interface KtxSetupContextPromptAdapter { select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; cancel(message: string): void; @@ -119,9 +101,6 @@ export interface KtxSetupContextDeps { now?: () => Date; runContextBuild?: typeof runContextBuild; verifyContextReady?: (projectDir: string) => Promise; - sleep?: (ms: number) => Promise; - watchIntervalMs?: number; - setupKeystroke?: (onDetach: () => void, onCtrlC: () => void) => (() => void) | null; } interface KtxSetupContextTargets { @@ -132,7 +111,6 @@ interface KtxSetupContextTargets { const SETUP_CONTEXT_STATE_PATH = ['.ktx', 'setup', 'context-build.json'] as const; const LIVE_DATABASE_ADAPTER = 'live-database'; const SCAN_REPORT_FILE = 'scan-report.json'; -const DEFAULT_WATCH_INTERVAL_MS = 2_000; function createPromptAdapter(): KtxSetupContextPromptAdapter { return createKtxSetupPromptAdapter({ selectCancelValue: 'back' }); @@ -155,10 +133,7 @@ export function contextBuildCommands(projectDir: string, runId?: string): KtxSet const resolvedProjectDir = resolve(projectDir); return { build: `ktx setup --project-dir ${resolvedProjectDir}`, - watch: `ktx setup --project-dir ${resolvedProjectDir}`, status: `ktx status --project-dir ${resolvedProjectDir}`, - stop: `ktx setup --project-dir ${resolvedProjectDir}`, - resume: `ktx setup --project-dir ${resolvedProjectDir}`, }; } @@ -179,7 +154,9 @@ function normalizeState(projectDir: string, value: unknown): KtxSetupContextStat return notStartedState(projectDir); } const record = value as Partial; - const status = record.status ?? 'not_started'; + const rawStatus = record.status ?? 'not_started'; + const legacyActive = rawStatus === 'detached' || rawStatus === 'paused' || rawStatus === 'running'; + const status: KtxSetupContextBuildStatus = legacyActive ? 'stale' : rawStatus; const runId = typeof record.runId === 'string' && record.runId.length > 0 ? record.runId : undefined; return { ...(runId ? { runId } : {}), @@ -203,7 +180,11 @@ function normalizeState(projectDir: string, value: unknown): KtxSetupContextStat ? record.retryableFailedTargets.filter((item): item is string => typeof item === 'string') : [], commands: contextBuildCommands(projectDir, runId), - ...(typeof record.failureReason === 'string' ? { failureReason: record.failureReason } : {}), + ...(typeof record.failureReason === 'string' + ? { failureReason: record.failureReason } + : legacyActive + ? { failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.' } + : {}), ...(normalizeSourceProgress(record.sourceProgress) ? { sourceProgress: normalizeSourceProgress(record.sourceProgress) } : {}), }; } @@ -281,7 +262,7 @@ export function setupContextStatusFromState( ready, status, ...(state.runId ? { runId: state.runId } : {}), - ...(state.runId ? { watchCommand: state.commands.watch, statusCommand: state.commands.status } : {}), + ...(state.runId ? { statusCommand: state.commands.status } : {}), retryCommand: state.commands.build, ...(state.failureReason ? { detail: state.failureReason } : {}), }; @@ -659,17 +640,6 @@ async function runBuild( }, io, { - onDetach: () => { - const resolvedDir = resolve(args.projectDir); - mkdirSync(join(resolvedDir, '.ktx', 'setup'), { recursive: true }); - const detachedState = normalizeState(resolvedDir, { - ...runningState, - status: 'detached', - updatedAt: new Date().toISOString(), - ...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}), - }); - writeFileSync(statePath(resolvedDir), `${JSON.stringify(detachedState, null, 2)}\n`); - }, onSourceProgress: (sources) => { lastSourceProgress = sources; try { @@ -689,18 +659,6 @@ async function runBuild( ); const completedReportIds = buildResult.reportIds ?? []; const completedArtifactPaths = buildResult.artifactPaths ?? []; - if (buildResult.detached) { - const updatedAt = now().toISOString(); - await writeKtxSetupContextState(args.projectDir, { - ...runningState, - status: 'detached', - updatedAt, - reportIds: completedReportIds, - artifactPaths: completedArtifactPaths, - ...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}), - }); - return { status: 'detached', projectDir: args.projectDir, runId }; - } if (buildResult.exitCode !== 0) { const updatedAt = now().toISOString(); await writeKtxSetupContextState(args.projectDir, { @@ -806,57 +764,15 @@ export async function runKtxSetupContextStep( if (completedSteps.includes('context') && existingState.status === 'completed') { return { status: 'ready', projectDir: args.projectDir, runId: existingState.runId ?? 'setup-context-completed' }; } - if ( - (existingState.status === 'running' || existingState.status === 'detached') && - args.inputMode !== 'disabled' + args.allowEmpty === true && + (!completedSteps.includes('databases') || !completedSteps.includes('sources')) ) { - if (args.autoWatch) { - const watched = await watchContextStatus( - { - projectDir: args.projectDir, - ...(existingState.runId ? { runId: existingState.runId } : {}), - inputMode: args.inputMode, - }, - existingState, - io, - deps, - ); - return setupResultFromWatchedState(args.projectDir, watched.state); - } - const choice = await prompts.select({ - message: - 'A context build is running in the background.\n\n' + - 'You can watch it until it finishes, check its status once, or start a fresh build.', - options: [ - { value: 'watch', label: 'Watch progress' }, - { value: 'status', label: 'Check status' }, - { value: 'rebuild', label: 'Start a fresh context build' }, - { value: 'back', label: 'Back' }, - ], - }); - if (choice === 'watch') { - const watched = await watchContextStatus( - { - projectDir: args.projectDir, - ...(existingState.runId ? { runId: existingState.runId } : {}), - inputMode: args.inputMode, - }, - existingState, - io, - deps, - ); - return setupResultFromWatchedState(args.projectDir, watched.state); - } - if (choice === 'status') { - const commands = contextBuildCommands(args.projectDir, existingState.runId); - io.stdout.write(`\nRun: ${commands.status}\n`); - io.stdout.write(`Log: ${join(resolve(args.projectDir), '.ktx', 'setup', 'context-build.log')}\n`); - return { status: 'detached', projectDir: args.projectDir, runId: existingState.runId ?? '' }; - } - if (choice === 'back') { - return { status: 'back', projectDir: args.projectDir }; - } + return { status: 'skipped', projectDir: args.projectDir }; + } + + if (existingState.status === 'stale') { + io.stdout.write('Previous context build state is stale; starting a fresh foreground build.\n'); } const targets = listContextTargets(project); @@ -904,183 +820,3 @@ export async function runKtxSetupContextStep( return { status: 'failed', projectDir: args.projectDir }; } } - -function stateMatchesRunId(state: KtxSetupContextState, runId: string | undefined): boolean { - return !runId || state.runId === runId; -} - -function isActiveStatus(status: KtxSetupContextBuildStatus): boolean { - return status === 'running' || status === 'detached'; -} - -function watchExitCode(status: KtxSetupContextBuildStatus): number { - return status === 'failed' || status === 'interrupted' || status === 'stale' ? 1 : 0; -} - -function defaultSleep(ms: number): Promise { - return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); -} - -function writeContextStatus(state: KtxSetupContextState, io: KtxCliIo): void { - io.stdout.write(`KTX context built: ${state.status === 'completed' ? 'yes' : state.status.replaceAll('_', ' ')}\n`); - if (state.runId) { - io.stdout.write(`Run: ${state.runId}\n`); - io.stdout.write(`Watch: ${state.commands.watch}\n`); - io.stdout.write(`Status: ${state.commands.status}\n`); - } - if (state.failureReason) { - io.stdout.write(`Detail: ${state.failureReason}\n`); - } -} - -async function watchContextStatus( - args: KtxSetupContextWatchArgs, - initialState: KtxSetupContextState, - io: KtxCliIo, - deps: KtxSetupContextDeps, -): Promise<{ exitCode: number; state: KtxSetupContextState }> { - if (initialState.sourceProgress && initialState.sourceProgress.length > 0) { - return watchContextStatusWithProgressView(args, initialState, io, deps); - } - return watchContextStatusText(args, initialState, io, deps); -} - -async function watchContextStatusText( - args: KtxSetupContextWatchArgs, - initialState: KtxSetupContextState, - io: KtxCliIo, - deps: KtxSetupContextDeps, -): Promise<{ exitCode: number; state: KtxSetupContextState }> { - const sleep = deps.sleep ?? defaultSleep; - const intervalMs = deps.watchIntervalMs ?? DEFAULT_WATCH_INTERVAL_MS; - let state = initialState; - let lastRenderedStatus = ''; - - io.stdout.write('KTX context build\n'); - while (true) { - const renderedStatus = `${state.status}:${state.updatedAt ?? ''}:${state.completedAt ?? ''}:${state.failureReason ?? ''}`; - if (renderedStatus !== lastRenderedStatus) { - writeContextStatus(state, io); - lastRenderedStatus = renderedStatus; - } - - if (!isActiveStatus(state.status)) { - return { exitCode: watchExitCode(state.status), state }; - } - - await sleep(intervalMs); - state = await readKtxSetupContextState(args.projectDir); - if (!stateMatchesRunId(state, args.runId)) { - io.stderr.write(`KTX setup context run "${args.runId}" was not found.\n`); - return { exitCode: 1, state }; - } - } -} - -async function watchContextStatusWithProgressView( - args: KtxSetupContextWatchArgs, - initialState: KtxSetupContextState, - io: KtxCliIo, - deps: KtxSetupContextDeps, -): Promise<{ exitCode: number; state: KtxSetupContextState }> { - const sleep = deps.sleep ?? defaultSleep; - const intervalMs = deps.watchIntervalMs ?? DEFAULT_WATCH_INTERVAL_MS; - const isTTY = io.stdout.isTTY === true; - const repainter = isTTY ? createRepainter(io) : null; - const projectDir = resolve(args.projectDir); - const viewOpts = { styled: isTTY, showHint: true, projectDir }; - let state = initialState; - let lastProgressKey = ''; - let detached = false; - - let viewState = viewStateFromSourceProgress(state.sourceProgress ?? [], Date.now(), - state.startedAt ? new Date(state.startedAt).getTime() : undefined); - - const cleanupKeystroke = (isTTY || deps.setupKeystroke) - ? (deps.setupKeystroke ?? defaultSetupKeystroke)( - () => { detached = true; }, - () => { detached = true; }, - ) - : null; - - let spinnerInterval: ReturnType | null = null; - if (repainter) { - repainter.paint(renderContextBuildView(viewState, viewOpts)); - spinnerInterval = setInterval(() => { - viewState.frame++; - const now = Date.now(); - viewState.totalElapsedMs = viewState.startedAt !== null ? now - viewState.startedAt : 0; - for (const t of [...viewState.primarySources, ...viewState.contextSources]) { - if (t.status === 'running' && t.startedAt !== null) { - t.elapsedMs = now - t.startedAt; - } - } - repainter.paint(renderContextBuildView(viewState, viewOpts)); - }, 140); - } - - try { - while (true) { - if (!repainter) { - const currentKey = JSON.stringify( - state.sourceProgress?.map((s) => ({ - id: s.connectionId, - status: s.status, - percent: s.percent, - message: s.message, - summaryText: s.summaryText, - updatedAtMs: s.updatedAtMs, - })), - ); - if (currentKey !== lastProgressKey || !isActiveStatus(state.status)) { - io.stdout.write(renderContextBuildView(viewState, viewOpts)); - lastProgressKey = currentKey; - } - } - - if (!isActiveStatus(state.status)) { - return { exitCode: watchExitCode(state.status), state }; - } - if (detached) break; - - await sleep(intervalMs); - if (detached) break; - - try { - state = await readKtxSetupContextState(args.projectDir); - } catch { - continue; - } - - if (!stateMatchesRunId(state, args.runId)) { - io.stderr.write(`KTX setup context run "${args.runId}" was not found.\n`); - return { exitCode: 1, state }; - } - - const now = Date.now(); - const startedAtMs = state.startedAt ? new Date(state.startedAt).getTime() : undefined; - viewState = viewStateFromSourceProgress(state.sourceProgress ?? [], now, startedAtMs); - } - } finally { - if (spinnerInterval) clearInterval(spinnerInterval); - cleanupKeystroke?.(); - } - - io.stdout.write('\n\nContext build continuing in the background.\n'); - io.stdout.write(`Resume: ktx setup --project-dir ${projectDir}\n`); - io.stdout.write(`Status: ktx status --project-dir ${projectDir}\n`); - return { exitCode: 0, state }; -} - -function setupResultFromWatchedState(projectDir: string, state: KtxSetupContextState): KtxSetupContextResult { - if (state.status === 'completed') { - return { status: 'ready', projectDir, runId: state.runId ?? 'setup-context-completed' }; - } - if (state.status === 'paused') { - return { status: 'paused', projectDir, runId: state.runId ?? '' }; - } - if (state.status === 'running' || state.status === 'detached') { - return { status: 'detached', projectDir, runId: state.runId ?? '' }; - } - return { status: 'failed', projectDir }; -} diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index dd134fce..c5dfd6ea 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -7,7 +7,7 @@ import { writeKtxSetupState } from '@ktx/context/project'; 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 { contextBuildCommands, readKtxSetupContextState, writeKtxSetupContextState } from './setup-context.js'; import { runDemoTour } from './setup-demo-tour.js'; import { formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js'; @@ -299,10 +299,10 @@ describe('setup status', () => { await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({ context: { ready: false, - status: 'running', + status: 'stale', runId: 'setup-context-local-abc123', - watchCommand: `ktx setup --project-dir ${tempDir}`, statusCommand: `ktx status --project-dir ${tempDir}`, + detail: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.', }, }); }); @@ -1624,51 +1624,7 @@ describe('setup status', () => { expect(io.stderr()).toContain('KTX context is not ready for agents.'); }); - it('does not install agents when full setup context build is detached', async () => { - const calls: string[] = []; - const io = makeIo(); - await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8'); - - await expect( - runKtxSetup( - { - command: 'run', - projectDir: tempDir, - mode: 'existing', - agents: false, - inputMode: 'disabled', - yes: true, - cliVersion: '0.2.0', - skipLlm: true, - skipEmbeddings: true, - skipDatabases: true, - skipSources: true, - skipAgents: false, - databaseSchemas: [], - }, - io.io, - { - context: async () => { - calls.push('context'); - return { status: 'detached', projectDir: tempDir, runId: 'setup-context-local-test' }; - }, - agents: async () => { - calls.push('agents'); - return { - status: 'ready', - projectDir: tempDir, - installs: [{ target: 'codex', scope: 'project', mode: 'cli' }], - }; - }, - }, - ), - ).resolves.toBe(0); - - expect(calls).toEqual(['context']); - }); - - it('resumes an active context build before prompting for earlier setup steps', async () => { - const io = makeIo(); + it('does not offer background watch choices from setup status', async () => { await writeFile( join(tempDir, 'ktx.yaml'), [ @@ -1685,122 +1641,23 @@ describe('setup status', () => { 'utf-8', ); await writeKtxSetupContextState(tempDir, { - runId: 'setup-context-local-active', + runId: 'setup-context-local-stale', status: 'running', - startedAt: '2026-05-09T10:00:00.000Z', - updatedAt: '2026-05-09T10:00:00.000Z', + startedAt: '2026-05-09T09:00:00.000Z', + updatedAt: '2026-05-09T09:00:00.000Z', primarySourceConnectionIds: ['warehouse'], contextSourceConnectionIds: [], reportIds: [], artifactPaths: [], retryableFailedTargets: [], - commands: contextBuildCommands(tempDir, 'setup-context-local-active'), - }); - const context = vi.fn(async () => ({ - status: 'detached' as const, - projectDir: tempDir, - runId: 'setup-context-local-active', - })); - const databases = vi.fn(async () => { - throw new Error('database setup should not run while context build is active'); + commands: contextBuildCommands(tempDir, 'setup-context-local-stale'), }); - await expect( - runKtxSetup( - { - command: 'run', - projectDir: tempDir, - mode: 'existing', - agents: false, - inputMode: 'auto', - yes: false, - cliVersion: '0.2.0', - skipLlm: false, - skipEmbeddings: false, - skipDatabases: false, - skipSources: false, - skipAgents: false, - databaseSchemas: [], - }, - io.io, - { context, databases }, - ), - ).resolves.toBe(0); - - expect(context).toHaveBeenCalledWith( - { projectDir: tempDir, inputMode: 'auto', allowEmpty: true }, - io.io, - ); - expect(databases).not.toHaveBeenCalled(); - }); - - it('skips entry menu and auto-watches when context build is active and showEntryMenu is true', async () => { - const io = makeIo(); - await writeFile( - join(tempDir, 'ktx.yaml'), - [ - 'project: revenue', - 'setup:', - ' database_connection_ids:', - ' - warehouse', - 'connections:', - ' warehouse:', - ' driver: postgres', - ' url: env:DATABASE_URL', - '', - ].join('\n'), - 'utf-8', - ); - await writeKtxSetupContextState(tempDir, { - runId: 'setup-context-local-active', - status: 'detached', - startedAt: '2026-05-09T10:00:00.000Z', - updatedAt: '2026-05-09T10:00:00.000Z', - primarySourceConnectionIds: ['warehouse'], - contextSourceConnectionIds: [], - reportIds: [], - artifactPaths: [], - retryableFailedTargets: [], - commands: contextBuildCommands(tempDir, 'setup-context-local-active'), - }); - const context = vi.fn(async () => ({ - status: 'detached' as const, - projectDir: tempDir, - runId: 'setup-context-local-active', - })); - const entryMenuSelect = vi.fn(async () => 'exit'); - - await expect( - runKtxSetup( - { - command: 'run', - projectDir: tempDir, - mode: 'existing', - agents: false, - inputMode: 'auto', - yes: false, - cliVersion: '0.2.0', - skipLlm: false, - skipEmbeddings: false, - skipDatabases: false, - skipSources: false, - skipAgents: false, - databaseSchemas: [], - showEntryMenu: true, - }, - io.io, - { - context, - entryMenuDeps: { prompts: { select: entryMenuSelect, cancel: vi.fn() } }, - }, - ), - ).resolves.toBe(0); - - expect(entryMenuSelect).not.toHaveBeenCalled(); - expect(context).toHaveBeenCalledWith( - { projectDir: tempDir, inputMode: 'auto', allowEmpty: true, autoWatch: true }, - io.io, - ); + const status = await readKtxSetupStatus(tempDir); + expect(status.context.status).toBe('stale'); + expect(status.context.watchCommand).toBeUndefined(); + const state = await readKtxSetupContextState(tempDir); + expect(state.status).toBe('stale'); }); it('routes a ready project menu selection to agent setup', async () => {