mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-19 08:28:06 +02:00
* feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
132 lines
3.6 KiB
TypeScript
132 lines
3.6 KiB
TypeScript
import type { KtxCliIo } from './cli-runtime.js';
|
|
import type { KtxSetupPromptOption } from './setup-prompts.js';
|
|
|
|
export type RecoveryOutcome = 'ready' | 'skip' | 'back' | 'failed';
|
|
|
|
/** @internal */
|
|
export interface RecoveryAction {
|
|
value: string;
|
|
label: string;
|
|
run: () => Promise<void>;
|
|
}
|
|
|
|
export type ConfigureResult = 'configured' | 'back' | 'cancelled';
|
|
|
|
export type ValidateResult =
|
|
| { status: 'ok' }
|
|
| { status: 'back' }
|
|
| { status: 'failed'; extraActions?: RecoveryAction[] };
|
|
|
|
export interface ConnectionRecoveryInput {
|
|
label: string;
|
|
interactive: boolean;
|
|
allowSkip: boolean;
|
|
io: KtxCliIo;
|
|
prompts: {
|
|
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
|
};
|
|
snapshot: () => Promise<() => Promise<void>>;
|
|
configure: () => Promise<ConfigureResult>;
|
|
validate: () => Promise<ValidateResult>;
|
|
}
|
|
|
|
async function runRollbackOnce(input: {
|
|
rollback: () => Promise<void>;
|
|
state: { rolledBack: boolean };
|
|
}): Promise<void> {
|
|
if (input.state.rolledBack) {
|
|
return;
|
|
}
|
|
input.state.rolledBack = true;
|
|
await input.rollback();
|
|
}
|
|
|
|
function recoveryOptions(input: {
|
|
allowSkip: boolean;
|
|
extraActions?: RecoveryAction[];
|
|
}): KtxSetupPromptOption[] {
|
|
return [
|
|
{ value: 'retry', label: 'Retry connection test' },
|
|
{ value: 're-enter', label: 'Re-enter connection details' },
|
|
...(input.extraActions ?? []).map((action) => ({
|
|
value: action.value,
|
|
label: action.label,
|
|
})),
|
|
...(input.allowSkip ? [{ value: 'skip', label: 'Skip this connection' }] : []),
|
|
{ value: 'back', label: 'Back' },
|
|
];
|
|
}
|
|
|
|
export async function runConnectionSetupWithRecovery(
|
|
input: ConnectionRecoveryInput,
|
|
): Promise<RecoveryOutcome> {
|
|
const rollback = await input.snapshot();
|
|
const rollbackState = { rolledBack: false };
|
|
|
|
const firstConfig = await input.configure();
|
|
if (firstConfig === 'back') {
|
|
await runRollbackOnce({ rollback, state: rollbackState });
|
|
return 'back';
|
|
}
|
|
if (firstConfig === 'cancelled') {
|
|
await runRollbackOnce({ rollback, state: rollbackState });
|
|
return 'failed';
|
|
}
|
|
|
|
let validation = await input.validate();
|
|
while (validation.status !== 'ok') {
|
|
if (validation.status === 'back') {
|
|
await runRollbackOnce({ rollback, state: rollbackState });
|
|
return 'back';
|
|
}
|
|
|
|
if (!input.interactive) {
|
|
return 'failed';
|
|
}
|
|
|
|
const action = await input.prompts.select({
|
|
message: `Connection setup failed for ${input.label}`,
|
|
options: recoveryOptions({
|
|
allowSkip: input.allowSkip,
|
|
extraActions: validation.extraActions,
|
|
}),
|
|
});
|
|
|
|
if (action === 'back') {
|
|
await runRollbackOnce({ rollback, state: rollbackState });
|
|
return 'back';
|
|
}
|
|
if (action === 'skip' && input.allowSkip) {
|
|
await runRollbackOnce({ rollback, state: rollbackState });
|
|
return 'skip';
|
|
}
|
|
if (action === 're-enter') {
|
|
const nextConfig = await input.configure();
|
|
if (nextConfig === 'back') {
|
|
await runRollbackOnce({ rollback, state: rollbackState });
|
|
return 'back';
|
|
}
|
|
if (nextConfig === 'cancelled') {
|
|
await runRollbackOnce({ rollback, state: rollbackState });
|
|
return 'failed';
|
|
}
|
|
validation = await input.validate();
|
|
continue;
|
|
}
|
|
if (action === 'retry') {
|
|
validation = await input.validate();
|
|
continue;
|
|
}
|
|
|
|
const extraAction = validation.extraActions?.find((candidate) => candidate.value === action);
|
|
if (extraAction) {
|
|
await extraAction.run();
|
|
validation = await input.validate();
|
|
continue;
|
|
}
|
|
|
|
validation = await input.validate();
|
|
}
|
|
|
|
return 'ready';
|
|
}
|