fix(cli): preserve project artifacts when ktx setup steps fail (#229)

ktx setup wiped ktx.yaml, .ktx/setup/state.json, wiki/, semantic-layer/,
raw-sources/, and .git/ — or removed the entire project dir — whenever any
single source in the context-build step failed, destroying hours of ingest
work and the persisted resume state. The cleanup hint was designed for an
"early abort, leave no trace" semantic but was applied indiscriminately to
every later step failure, in direct conflict with the .ktx/setup/state.json
resume mechanism.

Drop the cleanup mechanism entirely (KtxSetupCreatedProjectCleanup,
cleanupForFolderState, createProjectWithCleanup, cleanupCreatedProjectScaffold,
and the createdProjectCleanup plumbing through KtxSetupProjectResult). Step
failures now return non-zero without touching the filesystem, so re-running
ktx setup continues from completed steps and only re-attempts failed sources.

Rewrites the two tests that documented the wipe behavior to assert
preservation, and adds a regression test that simulates partial context-build
artifacts (state.json, wiki/, semantic-layer/) and verifies all survive a
failed context step.

Refs KLO-719
This commit is contained in:
Andrey Avtomonov 2026-05-28 15:17:06 +02:00 committed by GitHub
parent b687167bc1
commit c1ed5eedce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 63 additions and 72 deletions

View file

@ -24,17 +24,12 @@ export interface KtxSetupProjectArgs {
allowBack?: boolean; allowBack?: boolean;
} }
export type KtxSetupCreatedProjectCleanup =
| { kind: 'remove-project-dir'; projectDir: string }
| { kind: 'remove-ktx-scaffold'; projectDir: string };
export type KtxSetupProjectResult = export type KtxSetupProjectResult =
| { | {
status: 'ready'; status: 'ready';
projectDir: string; projectDir: string;
project: KtxLocalProject; project: KtxLocalProject;
confirmedCreation?: boolean; confirmedCreation?: boolean;
createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
} }
| { status: 'back'; projectDir: string } | { status: 'back'; projectDir: string }
| { status: 'cancelled'; projectDir: string } | { status: 'cancelled'; projectDir: string }
@ -59,7 +54,6 @@ type PromptProjectDirResult =
status: 'selected'; status: 'selected';
projectDir: string; projectDir: string;
confirmedCreation: boolean; confirmedCreation: boolean;
createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
} }
| { status: 'cancelled'; projectDir: string } | { status: 'cancelled'; projectDir: string }
| { status: 'missing-input'; projectDir: string } | { status: 'missing-input'; projectDir: string }
@ -106,26 +100,12 @@ type ConfirmProjectDirResult =
| { | {
status: 'confirmed'; status: 'confirmed';
confirmedCreation: boolean; confirmedCreation: boolean;
createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
} }
| { status: 'choose-another' } | { status: 'choose-another' }
| { status: 'back' } | { status: 'back' }
| { status: 'cancelled' } | { status: 'cancelled' }
| { status: 'not-directory' }; | { status: 'not-directory' };
function cleanupForFolderState(
projectDir: string,
state: Awaited<ReturnType<typeof existingFolderState>>,
): KtxSetupCreatedProjectCleanup | undefined {
if (state === 'missing') {
return { kind: 'remove-project-dir', projectDir };
}
if (state === 'empty-directory') {
return { kind: 'remove-ktx-scaffold', projectDir };
}
return undefined;
}
async function confirmProjectDir( async function confirmProjectDir(
selectedDir: string, selectedDir: string,
io: KtxCliIo, io: KtxCliIo,
@ -165,7 +145,7 @@ async function confirmProjectDir(
if (action === 'choose-another') return { status: 'choose-another' }; if (action === 'choose-another') return { status: 'choose-another' };
if (action === 'back') return { status: 'back' }; if (action === 'back') return { status: 'back' };
if (action !== 'create') return { status: 'cancelled' }; if (action !== 'create') return { status: 'cancelled' };
return { status: 'confirmed', confirmedCreation: true, createdProjectCleanup: cleanupForFolderState(selectedDir, state) }; return { status: 'confirmed', confirmedCreation: true };
} }
async function normalizeSetupGitignore(projectDir: string): Promise<void> { async function normalizeSetupGitignore(projectDir: string): Promise<void> {
@ -252,24 +232,10 @@ async function promptForNewProjectDir(
status: 'selected', status: 'selected',
projectDir: selectedDir, projectDir: selectedDir,
confirmedCreation: confirmed.confirmedCreation, confirmedCreation: confirmed.confirmedCreation,
...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
}; };
} }
} }
async function createProjectWithCleanup(
projectDir: string,
deps: KtxSetupProjectDeps,
): Promise<{ project: KtxLocalProject; createdProjectCleanup?: KtxSetupCreatedProjectCleanup }> {
const state = await existingFolderState(projectDir);
const project = await createProject(projectDir, deps);
const createdProjectCleanup = cleanupForFolderState(projectDir, state);
return {
project,
...(createdProjectCleanup ? { createdProjectCleanup } : {}),
};
}
export async function runKtxSetupProjectStep( export async function runKtxSetupProjectStep(
args: KtxSetupProjectArgs, args: KtxSetupProjectArgs,
io: KtxCliIo, io: KtxCliIo,
@ -307,7 +273,6 @@ export async function runKtxSetupProjectStep(
projectDir: selected.projectDir, projectDir: selected.projectDir,
project, project,
confirmedCreation: selected.confirmedCreation, confirmedCreation: selected.confirmedCreation,
...(selected.createdProjectCleanup ? { createdProjectCleanup: selected.createdProjectCleanup } : {}),
}; };
} }
@ -322,13 +287,12 @@ export async function runKtxSetupProjectStep(
io.stderr.write('Missing setup choice: pass --yes to create a project in non-interactive setup.\n'); io.stderr.write('Missing setup choice: pass --yes to create a project in non-interactive setup.\n');
return { status: 'missing-input', projectDir }; return { status: 'missing-input', projectDir };
} }
const { project, createdProjectCleanup } = await createProjectWithCleanup(projectDir, deps); const project = await createProject(projectDir, deps);
printProjectSummary(io, projectDir); printProjectSummary(io, projectDir);
return { return {
status: 'ready', status: 'ready',
projectDir, projectDir,
project, project,
...(createdProjectCleanup ? { createdProjectCleanup } : {}),
}; };
} }
@ -368,13 +332,12 @@ export async function runKtxSetupProjectStep(
} }
if (choice === 'current') { if (choice === 'current') {
const { project, createdProjectCleanup } = await createProjectWithCleanup(projectDir, deps); const project = await createProject(projectDir, deps);
printProjectSummary(io, projectDir); printProjectSummary(io, projectDir);
return { return {
status: 'ready', status: 'ready',
projectDir, projectDir,
project, project,
...(createdProjectCleanup ? { createdProjectCleanup } : {}),
}; };
} }
@ -390,7 +353,6 @@ export async function runKtxSetupProjectStep(
projectDir: defaultProjectDir, projectDir: defaultProjectDir,
project, project,
confirmedCreation: confirmed.confirmedCreation, confirmedCreation: confirmed.confirmedCreation,
...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
}; };
} }
@ -419,7 +381,6 @@ export async function runKtxSetupProjectStep(
projectDir: customDir, projectDir: customDir,
project, project,
confirmedCreation: confirmed.confirmedCreation, confirmedCreation: confirmed.confirmedCreation,
...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
}; };
} }

View file

@ -1,5 +1,4 @@
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path'; import { basename, join, resolve } from 'node:path';
import { getLatestLocalIngestStatus } from './context/ingest/local-ingest.js'; import { getLatestLocalIngestStatus } from './context/ingest/local-ingest.js';
import { savedMemoryCountsForReport } from './context/ingest/reports.js'; import { savedMemoryCountsForReport } from './context/ingest/reports.js';
@ -32,11 +31,7 @@ import {
isKtxSetupLlmConfigReady, isKtxSetupLlmConfigReady,
runKtxSetupAnthropicModelStep, runKtxSetupAnthropicModelStep,
} from './setup-models.js'; } from './setup-models.js';
import { import { type KtxSetupProjectDeps, runKtxSetupProjectStep } from './setup-project.js';
type KtxSetupCreatedProjectCleanup,
type KtxSetupProjectDeps,
runKtxSetupProjectStep,
} from './setup-project.js';
import { import {
isKtxPreAgentSetupReady, isKtxPreAgentSetupReady,
isKtxSetupReady, isKtxSetupReady,
@ -556,23 +551,6 @@ async function commitSetupConfigChanges(projectDir: string): Promise<void> {
await project.git.commitFile('ktx.yaml', 'setup: update KTX project config', 'ktx setup', 'setup@ktx.local'); await project.git.commitFile('ktx.yaml', 'setup: update KTX project config', 'ktx setup', 'setup@ktx.local');
} }
const KTX_SETUP_SCAFFOLD_PATHS = ['ktx.yaml', '.ktx', 'wiki', 'semantic-layer', 'raw-sources', '.git'];
async function cleanupCreatedProjectScaffold(cleanup: KtxSetupCreatedProjectCleanup | undefined): Promise<void> {
if (!cleanup) {
return;
}
if (cleanup.kind === 'remove-project-dir') {
await rm(cleanup.projectDir, { recursive: true, force: true });
return;
}
await Promise.all(
KTX_SETUP_SCAFFOLD_PATHS.map((relativePath) =>
rm(join(cleanup.projectDir, relativePath), { recursive: true, force: true }),
),
);
}
export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise<number> { export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise<number> {
try { try {
return await runKtxSetupInner(args, io, deps); return await runKtxSetupInner(args, io, deps);
@ -869,7 +847,6 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
}); });
if (stepResult.status === 'failed') { if (stepResult.status === 'failed') {
await cleanupCreatedProjectScaffold(projectResult.createdProjectCleanup);
return 1; return 1;
} }
if (stepResult.status === 'missing-input') { if (stepResult.status === 'missing-input') {

View file

@ -1,5 +1,5 @@
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
@ -602,7 +602,7 @@ describe('setup status', () => {
expect(testIo.stderr()).toBe(''); expect(testIo.stderr()).toBe('');
}); });
it('removes a newly created missing project directory when a later runtime step fails', async () => { it('preserves a newly created missing project directory when a later setup step fails', async () => {
const projectDir = join(tempDir, 'missing-project'); const projectDir = join(tempDir, 'missing-project');
const testIo = makeIo(); const testIo = makeIo();
@ -634,10 +634,12 @@ describe('setup status', () => {
), ),
).resolves.toBe(1); ).resolves.toBe(1);
await expect(stat(projectDir)).rejects.toThrow(); await expect(stat(projectDir)).resolves.toBeDefined();
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
await expect(stat(join(projectDir, '.ktx'))).resolves.toBeDefined();
}); });
it('removes KTX scaffold files from an initially empty project directory when runtime setup fails', async () => { it('preserves KTX scaffold files in an initially empty project directory when setup fails', async () => {
const testIo = makeIo(); const testIo = makeIo();
await expect( await expect(
@ -668,8 +670,59 @@ describe('setup status', () => {
), ),
).resolves.toBe(1); ).resolves.toBe(1);
await expect(stat(tempDir)).resolves.toBeDefined(); await expect(stat(join(tempDir, 'ktx.yaml'))).resolves.toBeDefined();
expect(await readdir(tempDir)).toEqual([]); await expect(stat(join(tempDir, '.ktx'))).resolves.toBeDefined();
});
it('preserves partial context-build artifacts and resume state when the context step fails', async () => {
const projectDir = join(tempDir, 'partial-context');
const testIo = makeIo();
await expect(
runKtxSetup(
{
command: 'run',
projectDir,
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: true,
cliVersion: '0.2.0',
skipLlm: true,
skipEmbeddings: true,
databaseSchemas: [],
skipDatabases: true,
skipSources: true,
},
testIo.io,
{
model: async () => ({ status: 'skipped', projectDir }),
embeddings: async () => ({ status: 'skipped', projectDir }),
databases: async () => ({ status: 'skipped', projectDir }),
sources: async () => ({ status: 'skipped', projectDir }),
runtime: async () => runtimeReady(projectDir),
context: async () => {
await mkdir(join(projectDir, '.ktx', 'setup'), { recursive: true });
await writeFile(
join(projectDir, '.ktx', 'setup', 'state.json'),
JSON.stringify({ status: 'failed', retryableFailedTargets: [{ source: 'metabase' }] }),
'utf-8',
);
await mkdir(join(projectDir, 'wiki'), { recursive: true });
await writeFile(join(projectDir, 'wiki', 'postgres-warehouse.md'), '# warehouse\n', 'utf-8');
await mkdir(join(projectDir, 'semantic-layer'), { recursive: true });
await writeFile(join(projectDir, 'semantic-layer', 'orders.yaml'), 'name: orders\n', 'utf-8');
return { status: 'failed', projectDir };
},
},
),
).resolves.toBe(1);
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
await expect(readFile(join(projectDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toContain('"status":"failed"');
await expect(readFile(join(projectDir, 'wiki', 'postgres-warehouse.md'), 'utf-8')).resolves.toContain('warehouse');
await expect(readFile(join(projectDir, 'semantic-layer', 'orders.yaml'), 'utf-8')).resolves.toContain('orders');
}); });
it('preserves a pre-existing non-empty project directory when runtime setup fails', async () => { it('preserves a pre-existing non-empty project directory when runtime setup fails', async () => {