From 549fb35e7512cf2b565bd913b0bcf9034bab8232 Mon Sep 17 00:00:00 2001 From: Luca Martial Date: Sun, 10 May 2026 17:08:55 -0700 Subject: [PATCH] Show progress when watching context builds --- packages/cli/src/context-build-view.test.ts | 94 ++++++++++ packages/cli/src/context-build-view.ts | 56 +++++- packages/cli/src/setup-context.test.ts | 160 +++++++++++++++++ packages/cli/src/setup-context.ts | 182 +++++++++++++++++++- packages/cli/src/setup.test.ts | 134 ++++++++++++++ packages/cli/src/setup.ts | 56 ++++-- 6 files changed, 660 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/context-build-view.test.ts b/packages/cli/src/context-build-view.test.ts index 1c6965d8..a88b42cc 100644 --- a/packages/cli/src/context-build-view.test.ts +++ b/packages/cli/src/context-build-view.test.ts @@ -8,6 +8,7 @@ import { parseScanSummary, renderContextBuildView, runContextBuild, + viewStateFromSourceProgress, } from './context-build-view.js'; function makeIo(options: { isTTY?: boolean } = {}) { @@ -424,4 +425,97 @@ describe('runContextBuild', () => { expect(io.stdout()).toContain('Resume: ktx setup --project-dir /tmp/project'); mockExit.mockRestore(); }); + + it('calls onSourceProgress when sources start and finish', async () => { + const io = makeIo(); + const project = projectWithConnections({ + warehouse: { driver: 'postgres' }, + dbt_main: { driver: 'dbt' }, + }); + const progressUpdates: Array> = []; + const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation)); + + await runContextBuild( + project, + { projectDir: '/tmp/project', inputMode: 'disabled' }, + io.io, + { + executeTarget, + now: () => 1000, + onSourceProgress: (sources) => { + progressUpdates.push(sources.map((s) => ({ connectionId: s.connectionId, status: s.status }))); + }, + }, + ); + + expect(progressUpdates).toHaveLength(4); + expect(progressUpdates[0]).toEqual([ + { connectionId: 'warehouse', status: 'running' }, + { connectionId: 'dbt_main', status: 'queued' }, + ]); + expect(progressUpdates[1]).toEqual([ + { connectionId: 'warehouse', status: 'done' }, + { connectionId: 'dbt_main', status: 'queued' }, + ]); + expect(progressUpdates[2]).toEqual([ + { connectionId: 'warehouse', status: 'done' }, + { connectionId: 'dbt_main', status: 'running' }, + ]); + expect(progressUpdates[3]).toEqual([ + { connectionId: 'warehouse', status: 'done' }, + { connectionId: 'dbt_main', status: 'done' }, + ]); + }); +}); + +describe('viewStateFromSourceProgress', () => { + it('partitions sources into primary and context groups', () => { + const state = viewStateFromSourceProgress( + [ + { connectionId: 'warehouse', operation: 'scan', status: 'running', startedAtMs: 900 }, + { connectionId: 'dbt-main', operation: 'source-ingest', status: 'queued' }, + ], + 1000, + 500, + ); + + expect(state.primarySources).toHaveLength(1); + expect(state.primarySources[0].target.connectionId).toBe('warehouse'); + expect(state.primarySources[0].status).toBe('running'); + expect(state.primarySources[0].elapsedMs).toBe(100); + expect(state.contextSources).toHaveLength(1); + expect(state.contextSources[0].target.connectionId).toBe('dbt-main'); + expect(state.contextSources[0].status).toBe('queued'); + expect(state.totalElapsedMs).toBe(500); + }); + + it('uses stored elapsedMs for completed sources', () => { + const state = viewStateFromSourceProgress( + [{ connectionId: 'warehouse', operation: 'scan', status: 'done', elapsedMs: 72000, summaryText: '42 tables' }], + 99999, + ); + + expect(state.primarySources[0].elapsedMs).toBe(72000); + expect(state.primarySources[0].summaryText).toBe('42 tables'); + }); + + it('renders the same view format as the foreground build', () => { + const state = viewStateFromSourceProgress( + [ + { connectionId: 'warehouse', operation: 'scan', status: 'done', elapsedMs: 72000, summaryText: '42 tables' }, + { connectionId: 'dbt-main', operation: 'source-ingest', status: 'running', startedAtMs: 900 }, + ], + 1000, + 500, + ); + + const output = renderContextBuildView(state, { styled: false }); + expect(output).toContain('Building KTX context'); + expect(output).toContain('Primary sources:'); + expect(output).toContain('warehouse'); + expect(output).toContain('42 tables'); + expect(output).toContain('Context sources:'); + expect(output).toContain('dbt-main'); + expect(output).toContain('ingesting...'); + }); }); diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts index 96a8aa57..7edc7d13 100644 --- a/packages/cli/src/context-build-view.ts +++ b/packages/cli/src/context-build-view.ts @@ -46,11 +46,21 @@ export interface ContextBuildResult { detached: boolean; } +export interface ContextBuildSourceProgressUpdate { + connectionId: string; + operation: 'scan' | 'source-ingest'; + status: 'queued' | 'running' | 'done' | 'failed'; + startedAtMs?: number; + elapsedMs?: number; + summaryText?: string; +} + export interface ContextBuildDeps { executeTarget?: typeof executePublicIngestTarget; now?: () => number; setupKeystroke?: (onDetach: () => void, onCtrlC: () => void) => (() => void) | null; onDetach?: () => void; + onSourceProgress?: (sources: ContextBuildSourceProgressUpdate[]) => void; } // --- Rendering --- @@ -165,7 +175,7 @@ function resumeCommand(projectDir?: string): string { export function renderContextBuildView( state: ContextBuildViewState, - options: { styled?: boolean; showHint?: boolean; projectDir?: string } = {}, + options: { styled?: boolean; showHint?: boolean; hintText?: string; projectDir?: string } = {}, ): string { const styled = options.styled ?? true; const width = columnWidth(state); @@ -203,7 +213,8 @@ export function renderContextBuildView( } if (options.showHint && hasActive) { - const hint = ` d to detach · ${resumeCommand(options.projectDir)} to resume`; + const hintContent = options.hintText ?? `d to detach · ${resumeCommand(options.projectDir)} to resume`; + const hint = ` ${hintContent}`; lines.push(styled ? dim(hint) : hint); lines.push(''); } @@ -261,9 +272,45 @@ function createCaptureIo(onProgress: (message: string) => void, isTTY: boolean): }; } +// --- Source progress helpers --- + +function collectSourceProgress(targets: ContextBuildTargetState[]): ContextBuildSourceProgressUpdate[] { + return targets.map((t) => ({ + connectionId: t.target.connectionId, + operation: t.target.operation, + status: t.status, + ...(t.startedAt !== null ? { startedAtMs: t.startedAt } : {}), + ...(t.elapsedMs > 0 ? { elapsedMs: t.elapsedMs } : {}), + ...(t.summaryText ? { summaryText: t.summaryText } : {}), + })); +} + +export function viewStateFromSourceProgress( + sources: ContextBuildSourceProgressUpdate[], + now: number, + startedAtMs?: number, +): ContextBuildViewState { + const makeTarget = (s: ContextBuildSourceProgressUpdate): ContextBuildTargetState => ({ + target: { connectionId: s.connectionId, driver: '', operation: s.operation, debugCommand: '', steps: [] }, + status: s.status, + detailLine: null, + summaryText: s.summaryText ?? null, + startedAt: s.startedAtMs ?? null, + elapsedMs: s.status === 'running' && s.startedAtMs ? now - s.startedAtMs : (s.elapsedMs ?? 0), + }); + + return { + primarySources: sources.filter((s) => s.operation === 'scan').map(makeTarget), + contextSources: sources.filter((s) => s.operation === 'source-ingest').map(makeTarget), + frame: 0, + startedAt: startedAtMs ?? null, + totalElapsedMs: startedAtMs ? now - startedAtMs : 0, + }; +} + // --- Repaint --- -function createRepainter(io: KtxCliIo) { +export function createRepainter(io: KtxCliIo) { let lastLineCount = 0; return { @@ -397,7 +444,6 @@ export async function runContextBuild( 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(`Status: ktx setup context status --project-dir ${resolve(args.projectDir)}\n`); io.stdout.write(`Resume: ${resumeCommand(args.projectDir)}\n`); process.exit(0); }, @@ -428,6 +474,7 @@ export async function runContextBuild( targetState.status = 'running'; targetState.startedAt = nowFn(); paint(true); + deps.onSourceProgress?.(collectSourceProgress(orderedTargets)); const capture = createCaptureIo( (message) => { @@ -452,6 +499,7 @@ export async function runContextBuild( if (failed) hasFailure = true; paint(true); + deps.onSourceProgress?.(collectSourceProgress(orderedTargets)); } } finally { if (spinnerInterval) clearInterval(spinnerInterval); diff --git a/packages/cli/src/setup-context.test.ts b/packages/cli/src/setup-context.test.ts index 0f20ee81..90694ada 100644 --- a/packages/cli/src/setup-context.test.ts +++ b/packages/cli/src/setup-context.test.ts @@ -340,6 +340,166 @@ 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 () => { + 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', + 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'); + }); + + 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'); + }); + + 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: 'scan' as const, status: 'done' as const, elapsedMs: 30000 }, + { connectionId: 'docs', operation: 'source-ingest' as const, status: 'running' as const, startedAtMs: Date.now() - 5000 }, + ], + }); + 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: 'scan' 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('Primary sources:'); + expect(output).toContain('warehouse'); + expect(output).toContain('Context sources:'); + expect(output).toContain('docs'); + expect(output).not.toContain('KTX context built: detached'); + }); + it('prints JSON setup context command status with watch and resume commands', async () => { await mkdir(join(tempDir, '.ktx', 'setup'), { recursive: true }); await writeKtxSetupContextState(tempDir, { diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index 1555f264..cd79d9bd 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -10,7 +10,13 @@ import { } from '@ktx/context/project'; import type { KtxCliIo } from './cli-runtime.js'; import { buildPublicIngestPlan } from './public-ingest.js'; -import { runContextBuild } from './context-build-view.js'; +import { + type ContextBuildSourceProgressUpdate, + createRepainter, + renderContextBuildView, + runContextBuild, + viewStateFromSourceProgress, +} from './context-build-view.js'; import { withMenuOptionsSpacing } from './prompt-navigation.js'; import { withSetupInterruptConfirmation } from './setup-interrupt.js'; @@ -45,6 +51,7 @@ export interface KtxSetupContextState { retryableFailedTargets: string[]; commands: KtxSetupContextCommands; failureReason?: string; + sourceProgress?: ContextBuildSourceProgressUpdate[]; } export interface KtxSetupContextStatusSummary { @@ -80,6 +87,7 @@ export interface KtxSetupContextStepArgs { forcePrompt?: boolean; allowEmpty?: boolean; prompt?: boolean; + autoWatch?: boolean; } export type KtxSetupContextCommandArgs = @@ -196,9 +204,34 @@ function normalizeState(projectDir: string, value: unknown): KtxSetupContextStat : [], commands: contextBuildCommands(projectDir, runId), ...(typeof record.failureReason === 'string' ? { failureReason: record.failureReason } : {}), + ...(normalizeSourceProgress(record.sourceProgress) ? { sourceProgress: normalizeSourceProgress(record.sourceProgress) } : {}), }; } +const VALID_SOURCE_OPERATIONS = new Set(['scan', 'source-ingest']); +const VALID_SOURCE_STATUSES = new Set(['queued', 'running', 'done', 'failed']); + +function normalizeSourceProgress(value: unknown): ContextBuildSourceProgressUpdate[] | undefined { + if (!Array.isArray(value)) return undefined; + const entries: ContextBuildSourceProgressUpdate[] = []; + for (const item of value) { + if (typeof item !== 'object' || item === null || Array.isArray(item)) continue; + const rec = item as Record; + if (typeof rec.connectionId !== 'string') continue; + if (!VALID_SOURCE_OPERATIONS.has(String(rec.operation))) continue; + if (!VALID_SOURCE_STATUSES.has(String(rec.status))) continue; + entries.push({ + connectionId: rec.connectionId, + operation: rec.operation as 'scan' | 'source-ingest', + status: rec.status as 'queued' | 'running' | 'done' | 'failed', + ...(typeof rec.startedAtMs === 'number' ? { startedAtMs: rec.startedAtMs } : {}), + ...(typeof rec.elapsedMs === 'number' ? { elapsedMs: rec.elapsedMs } : {}), + ...(typeof rec.summaryText === 'string' ? { summaryText: rec.summaryText } : {}), + }); + } + return entries.length > 0 ? entries : undefined; +} + export async function readKtxSetupContextState(projectDir: string): Promise { const filePath = statePath(projectDir); if (!(await pathExists(filePath))) { @@ -517,6 +550,7 @@ async function runBuild( }; await writeKtxSetupContextState(args.projectDir, runningState); + let lastSourceProgress: ContextBuildSourceProgressUpdate[] | undefined; const contextBuild = deps.runContextBuild ?? runContextBuild; const buildResult = await contextBuild( project, @@ -535,14 +569,35 @@ async function runBuild( ...runningState, status: 'detached', updatedAt: new Date().toISOString(), + ...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}), }); writeFileSync(statePath(resolvedDir), `${JSON.stringify(detachedState, null, 2)}\n`); }, + onSourceProgress: (sources) => { + lastSourceProgress = sources; + try { + const resolvedDir = resolve(args.projectDir); + mkdirSync(join(resolvedDir, '.ktx', 'setup'), { recursive: true }); + const progressState = normalizeState(resolvedDir, { + ...runningState, + sourceProgress: sources, + updatedAt: new Date().toISOString(), + }); + writeFileSync(statePath(resolvedDir), `${JSON.stringify(progressState, null, 2)}\n`); + } catch { + // Progress reporting is supplementary — don't crash the build + } + }, }, ); if (buildResult.detached) { const updatedAt = now().toISOString(); - await writeKtxSetupContextState(args.projectDir, { ...runningState, status: 'detached', updatedAt }); + await writeKtxSetupContextState(args.projectDir, { + ...runningState, + status: 'detached', + updatedAt, + ...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}), + }); return { status: 'detached', projectDir: args.projectDir, runId }; } if (buildResult.exitCode !== 0) { @@ -553,6 +608,7 @@ async function runBuild( updatedAt, retryableFailedTargets: [...targets.primarySourceConnectionIds, ...targets.contextSourceConnectionIds], failureReason: 'Context build failed.', + ...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}), }); return { status: 'failed', projectDir: args.projectDir }; } @@ -566,6 +622,7 @@ async function runBuild( updatedAt, retryableFailedTargets: readiness.failedTargets ?? [], failureReason: readiness.details.join(' '), + ...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}), }); io.stderr.write('KTX context build did not pass agent-readiness verification.\n'); for (const detail of readiness.details) { @@ -582,6 +639,7 @@ async function runBuild( updatedAt: completedAt, completedAt, retryableFailedTargets: [], + ...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}), }); writeSuccess(readiness, targets, io); return { status: 'ready', projectDir: args.projectDir, runId }; @@ -635,17 +693,46 @@ export async function runKtxSetupContextStep( (existingState.status === 'running' || existingState.status === 'detached') && args.inputMode !== 'disabled' ) { + if (args.autoWatch) { + const watched = await watchContextStatus( + { + command: 'watch', + projectDir: args.projectDir, + ...(existingState.runId ? { runId: existingState.runId } : {}), + inputMode: args.inputMode, + }, + existingState, + io, + deps, + ); + return setupResultFromWatchedState(args.projectDir, watched.state); + } const prompts = deps.prompts ?? createPromptAdapter(); const choice = await prompts.select({ message: 'A context build is running in the background.\n\n' + - 'You can wait for it to finish, check its status, or start a fresh build.', + '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( + { + command: 'watch', + 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`); @@ -734,7 +821,19 @@ async function watchContextStatus( initialState: KtxSetupContextState, io: KtxCliIo, deps: KtxSetupContextDeps, -): Promise { +): 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: Extract, + 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; @@ -749,18 +848,87 @@ async function watchContextStatus( } if (!isActiveStatus(state.status)) { - return watchExitCode(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 1; + return { exitCode: 1, state }; } } } +async function watchContextStatusWithProgressView( + args: Extract, + 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; + let state = initialState; + let frame = 0; + let lastProgressKey = ''; + + while (true) { + const now = Date.now(); + const startedAtMs = state.startedAt ? new Date(state.startedAt).getTime() : undefined; + const viewState = viewStateFromSourceProgress(state.sourceProgress ?? [], now, startedAtMs); + viewState.frame = frame; + + const viewOpts = { + styled: isTTY, + showHint: true, + hintText: 'ctrl+c to stop watching · build continues in background', + }; + + if (repainter) { + repainter.paint(renderContextBuildView(viewState, viewOpts)); + } else { + const currentKey = JSON.stringify(state.sourceProgress?.map((s) => s.status)); + if (currentKey !== lastProgressKey || !isActiveStatus(state.status)) { + io.stdout.write(renderContextBuildView(viewState, viewOpts)); + lastProgressKey = currentKey; + } + } + + if (!isActiveStatus(state.status)) { + return { exitCode: watchExitCode(state.status), state }; + } + + frame++; + await sleep(intervalMs); + + 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 }; + } + } +} + +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 }; +} + export async function runKtxSetupContextCommand( args: KtxSetupContextCommandArgs, io: KtxCliIo, @@ -791,7 +959,7 @@ export async function runKtxSetupContextCommand( } if (args.command === 'watch') { - return await watchContextStatus(args, state, io, deps); + return (await watchContextStatus(args, state, io, deps)).exitCode; } const updatedAt = new Date().toISOString(); diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index 3e772d92..cf9d22a8 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -1305,6 +1305,140 @@ describe('setup status', () => { expect(calls).toEqual(['context']); }); + it('resumes an active context build before prompting for earlier setup steps', 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: 'running', + 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 databases = vi.fn(async () => { + throw new Error('database setup should not run while context build is active'); + }); + + await expect( + runKtxSetup( + { + command: 'run', + projectDir: tempDir, + mode: 'existing', + agents: false, + inputMode: 'auto', + yes: false, + 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, + 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, + ); + }); + it('routes a ready project menu selection to agent setup', async () => { const calls: string[] = []; const io = makeIo(); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 09deff37..2aae882e 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -391,6 +391,10 @@ function setupContextReady(status: KtxSetupStatus): boolean { return status.context.ready; } +function setupContextActive(status: KtxSetupStatus): boolean { + return status.context.status === 'running' || status.context.status === 'detached'; +} + function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void { io.stderr.write('KTX context is not ready for agents.\n\n'); io.stderr.write(`Build context first:\n ktx setup context build --project-dir ${resolve(projectDir)}\n\n`); @@ -454,22 +458,27 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup args.inputMode !== 'disabled' && !args.agents && (io.stdout.isTTY === true || deps.entryMenuDeps?.prompts !== undefined); + let autoWatchActiveBuild = false; setupLoop: while (true) { entryAction = undefined; if (canShowEntryMenu) { const status = await readKtxSetupStatus(args.projectDir); - entryAction = (await runKtxSetupEntryMenu(status, deps.entryMenuDeps)).action; - if (entryAction === 'exit') { - (deps.entryMenuDeps?.prompts ?? createEntryMenuPromptAdapter()).cancel('Setup cancelled.'); - return 0; - } - if (entryAction === 'status') { - io.stdout.write(formatKtxSetupStatus(status)); - return 0; - } - if (entryAction === 'demo') { - return await runKtxSetupDemoFromEntryMenu(args, io, deps); + if (setupContextActive(status)) { + autoWatchActiveBuild = true; + } else { + entryAction = (await runKtxSetupEntryMenu(status, deps.entryMenuDeps)).action; + if (entryAction === 'exit') { + (deps.entryMenuDeps?.prompts ?? createEntryMenuPromptAdapter()).cancel('Setup cancelled.'); + return 0; + } + if (entryAction === 'status') { + io.stdout.write(formatKtxSetupStatus(status)); + return 0; + } + if (entryAction === 'demo') { + return await runKtxSetupDemoFromEntryMenu(args, io, deps); + } } } @@ -497,6 +506,31 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup const agentsRequested = args.agents || entryAction === 'agents'; const currentStatus = await readKtxSetupStatus(projectResult.projectDir); let readyAction: string | undefined; + + if (args.inputMode !== 'disabled' && !agentsRequested && setupContextActive(currentStatus)) { + const contextRunner = + deps.context ?? ((contextArgs, contextIo) => runKtxSetupContextStep(contextArgs, contextIo, deps.contextDeps)); + const contextResult = await contextRunner( + { + projectDir: projectResult.projectDir, + inputMode: args.inputMode, + allowEmpty: true, + ...(autoWatchActiveBuild ? { autoWatch: true } : {}), + }, + io, + ); + autoWatchActiveBuild = false; + if (contextResult.status === 'back') { + continue; + } + if (contextResult.status === 'failed' || contextResult.status === 'missing-input') { + return 1; + } + if (contextResult.status !== 'ready') { + return 0; + } + } + if (args.inputMode !== 'disabled' && !agentsRequested && isKtxSetupReady(currentStatus)) { readyAction = (await runKtxSetupReadyChangeMenu(currentStatus, deps.readyMenuDeps)).action; if (readyAction === 'exit') return 0;