mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
Show progress when watching context builds
This commit is contained in:
parent
82848e5de9
commit
549fb35e75
6 changed files with 660 additions and 22 deletions
|
|
@ -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<Array<{ connectionId: string; status: string }>> = [];
|
||||
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...');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<KtxSetupContextState> {
|
||||
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<number> {
|
||||
): 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<KtxSetupContextCommandArgs, { command: 'watch' }>,
|
||||
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<KtxSetupContextCommandArgs, { command: 'watch' }>,
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue