diff --git a/packages/cli/src/lib/init/types.ts b/packages/cli/src/lib/init/types.ts index 7ea6a8721..e2a9f6c96 100644 --- a/packages/cli/src/lib/init/types.ts +++ b/packages/cli/src/lib/init/types.ts @@ -226,6 +226,9 @@ export type ToolResult = { // Wizard output export type WizardOutput = { + bailCategory?: "expected" | "unexpected"; + bailReason?: string; + bailStep?: string; platform?: string; projectDir?: string; features?: string[]; diff --git a/packages/cli/src/lib/init/wizard-runner.ts b/packages/cli/src/lib/init/wizard-runner.ts index 5ef492164..00e035e9b 100644 --- a/packages/cli/src/lib/init/wizard-runner.ts +++ b/packages/cli/src/lib/init/wizard-runner.ts @@ -68,6 +68,7 @@ import type { ToolPayload, ToolResult, WizardOptions, + WizardOutput, WorkflowRunResult, } from "./types.js"; import { getUIAsync } from "./ui/factory.js"; @@ -1191,6 +1192,23 @@ function syncWorkflowStepStatuses( } } +function tagWorkflowFailure(output: WizardOutput | undefined): void { + const bailCategory = output?.bailCategory; + setTag("wizard.outcome", bailCategory === "expected" ? "bailed" : "errored"); + if (bailCategory !== undefined) { + setTag("wizard.bail_category", bailCategory); + } + if (output?.bailReason !== undefined) { + setTag("wizard.bail_reason", output.bailReason); + } + if (output?.bailStep !== undefined) { + setTag("wizard.step", output.bailStep); + } + if (output?.exitCode !== undefined) { + setTag("wizard.exit_code", output.exitCode); + } +} + // biome-ignore lint/nursery/useMaxParams: existing 4-param shape; cwd is a defaulted extension export async function handleFinalResult( result: WorkflowRunResult, @@ -1211,10 +1229,7 @@ export async function handleFinalResult( // Map workflow-internal exit codes to semantic EXIT.* constants const workflowCode = result.result?.exitCode; const exitCode = mapWorkflowExitCode(workflowCode); - setTag("wizard.outcome", "errored"); - if (workflowCode !== undefined) { - setTag("wizard.exit_code", workflowCode); - } + tagWorkflowFailure(result.result); throw new WizardError( result.error ?? result.result?.message ?? "Workflow returned an error", { exitCode } diff --git a/packages/cli/test/lib/wizard-runner-handle-final-result.mocked.test.ts b/packages/cli/test/lib/wizard-runner-handle-final-result.mocked.test.ts index d433f5e5e..105fb329d 100644 --- a/packages/cli/test/lib/wizard-runner-handle-final-result.mocked.test.ts +++ b/packages/cli/test/lib/wizard-runner-handle-final-result.mocked.test.ts @@ -148,6 +148,67 @@ describe("handleFinalResult", () => { }); }); + describe("workflow bail telemetry", () => { + test("marks explicitly expected workflow exits as bailed", async () => { + const result = makeBailResult({ + bailCategory: "expected", + bailReason: "unknown_platform", + bailStep: "detect-platform", + exitCode: 20, + }); + + await expect( + handleFinalResult( + result, + makeSpinnerHandle(), + makeSpinState(), + makeUI() + ) + ).rejects.toThrow(WizardError); + + expect(tags["wizard.outcome"]).toBe("bailed"); + expect(tags["wizard.bail_category"]).toBe("expected"); + expect(tags["wizard.bail_reason"]).toBe("unknown_platform"); + expect(tags["wizard.step"]).toBe("detect-platform"); + }); + + test("keeps unexpected and unclassified workflow exits as errors", async () => { + const unexpected = makeBailResult({ + bailCategory: "unexpected", + bailReason: "agent_failure", + bailStep: "plan-codemods", + }); + + await expect( + handleFinalResult( + unexpected, + makeSpinnerHandle(), + makeSpinState(), + makeUI() + ) + ).rejects.toThrow(WizardError); + + expect(tags["wizard.outcome"]).toBe("errored"); + expect(tags["wizard.bail_category"]).toBe("unexpected"); + + for (const key of Object.keys(tags)) { + delete tags[key]; + } + + await expect( + handleFinalResult( + makeBailResult(), + makeSpinnerHandle(), + makeSpinState(), + makeUI() + ) + ).rejects.toThrow(WizardError); + + expect(tags["wizard.outcome"]).toBe("errored"); + expect(tags["wizard.bail_category"]).toBeUndefined(); + }); + }); + describe("WizardError message — result.error fallback", () => { test("uses result.error when result.result is absent (plain workflow failure)", async () => { const result: WorkflowRunResult = {