diff --git a/packages/cli/src/setup-context.test.ts b/packages/cli/src/setup-context.test.ts index 2cebff8d..0f20ee81 100644 --- a/packages/cli/src/setup-context.test.ts +++ b/packages/cli/src/setup-context.test.ts @@ -372,6 +372,48 @@ describe('setup context build state', () => { }); }); + it('watches setup context command status until the run reaches a terminal state', async () => { + await mkdir(join(tempDir, '.ktx', 'setup'), { recursive: true }); + await writeKtxSetupContextState(tempDir, { + runId: 'setup-context-local-watch', + status: 'running', + 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-watch'), + }); + const io = makeIo(); + const completeRun = async () => { + await writeKtxSetupContextState(tempDir, { + runId: 'setup-context-local-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-watch'), + }); + }; + + await expect( + runKtxSetupContextCommand( + { command: 'watch', projectDir: tempDir, runId: 'setup-context-local-watch', inputMode: 'disabled' }, + io.io, + { sleep: completeRun, watchIntervalMs: 1 }, + ), + ).resolves.toBe(0); + expect(io.stdout()).toContain('KTX context built: running'); + expect(io.stdout()).toContain('KTX context built: yes'); + }); + it('runs direct build commands without asking for setup confirmation first', async () => { await writeReadyProject(tempDir); const io = makeIo(); diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index 042c5b1e..1555f264 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -99,6 +99,8 @@ export interface KtxSetupContextDeps { now?: () => Date; runContextBuild?: typeof runContextBuild; verifyContextReady?: (projectDir: string) => Promise; + sleep?: (ms: number) => Promise; + watchIntervalMs?: number; } interface KtxSetupContextTargets { @@ -109,6 +111,7 @@ 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 { @@ -698,6 +701,18 @@ function stateMatchesRunId(state: KtxSetupContextState, runId: string | undefine 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 statusPayload(state: KtxSetupContextState): KtxSetupContextStatusSummary { return setupContextStatusFromState(state, { completedStep: state.status === 'completed' }); } @@ -714,6 +729,38 @@ function writeContextStatus(state: KtxSetupContextState, io: KtxCliIo): void { } } +async function watchContextStatus( + args: Extract, + initialState: KtxSetupContextState, + io: KtxCliIo, + deps: KtxSetupContextDeps, +): Promise { + 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 watchExitCode(state.status); + } + + 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; + } + } +} + export async function runKtxSetupContextCommand( args: KtxSetupContextCommandArgs, io: KtxCliIo, @@ -744,9 +791,7 @@ export async function runKtxSetupContextCommand( } if (args.command === 'watch') { - io.stdout.write('KTX context build\n'); - writeContextStatus(state, io); - return 0; + return await watchContextStatus(args, state, io, deps); } const updatedAt = new Date().toISOString();