diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 7ce8959f6..f5595fed0 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -26,6 +26,7 @@ import { extractNoSolutionReason, extractRootCauses, extractSolution, + requireAutofixRunId, type SolutionArtifact, } from "../../types/seer.js"; import { @@ -54,7 +55,7 @@ type NoSolutionContext = { /** Return type for issue plan — includes state metadata and solution data */ type PlanData = { - run_id: number; + run_id: string | number; status: string; /** The solution data (without the artifact wrapper). Null when no solution is available. */ solution: SolutionArtifact["data"] | null; @@ -141,7 +142,7 @@ function buildNoSolutionContext( function buildPlanData(state: AutofixState): PlanData { const solution = extractSolution(state); const data: PlanData = { - run_id: state.run_id, + run_id: requireAutofixRunId(state), status: state.status, solution: solution?.data ?? null, }; @@ -235,7 +236,7 @@ export const planCommand = buildCommand({ } } - await triggerSolutionPlanning(org, numericId, state.run_id); + await triggerSolutionPlanning(org, numericId, requireAutofixRunId(state)); // Poll until solution is ready or terminal const finalState = await pollAutofixState({ diff --git a/src/lib/api/seer.ts b/src/lib/api/seer.ts index d0437f473..505ad6a4a 100644 --- a/src/lib/api/seer.ts +++ b/src/lib/api/seer.ts @@ -54,27 +54,26 @@ function normalizeAgentStatus(status: string): string { * * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID - * @returns The trigger response with run_id + * @returns The trigger response with `sentry_run_id` and the legacy `run_id` * @throws {ApiError} On API errors (402 = no budget, 403 = not enabled) */ export async function triggerRootCauseAnalysis( orgSlug: string, issueId: string -): Promise<{ run_id: number }> { +): Promise<{ run_id?: number; sentry_run_id: string | null }> { const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion<{ run_id: number }>( - regionUrl, - `/organizations/${orgSlug}/issues/${issueId}/autofix/`, - { - method: "POST", - params: EXPLORER_MODE_PARAMS, - body: { - step: "root_cause", - referrer: "api.cli", - }, - } - ); + const { data } = await apiRequestToRegion<{ + run_id?: number; + sentry_run_id: string | null; + }>(regionUrl, `/organizations/${orgSlug}/issues/${issueId}/autofix/`, { + method: "POST", + params: EXPLORER_MODE_PARAMS, + body: { + step: "root_cause", + referrer: "api.cli", + }, + }); return data; } @@ -113,21 +112,24 @@ export async function getAutofixState( * Trigger solution planning for an existing autofix run. * * Posts to the agent-based autofix endpoint with `step: "solution"` and - * the existing `run_id`. The agent continues from root cause analysis - * to generating a solution plan. + * the existing run ID (from {@link requireAutofixRunId}). * * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID - * @param runId - The autofix run ID + * @param runId - The autofix run ID (see {@link requireAutofixRunId}) * @returns The response from the API */ export async function triggerSolutionPlanning( orgSlug: string, issueId: string, - runId: number + runId: string | number ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); + // A UUID sent under run_id (an IntegerField) fails server-side validation. + const runIdBodyField = + typeof runId === "string" ? { sentry_run_id: runId } : { run_id: runId }; + const { data } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/issues/${issueId}/autofix/`, @@ -136,7 +138,7 @@ export async function triggerSolutionPlanning( params: EXPLORER_MODE_PARAMS, body: { step: "solution", - run_id: runId, + ...runIdBodyField, referrer: "api.cli", }, } diff --git a/src/types/seer.ts b/src/types/seer.ts index d7803714d..f83e6ca41 100644 --- a/src/types/seer.ts +++ b/src/types/seer.ts @@ -172,7 +172,10 @@ export type SolutionArtifact = z.infer; export const AutofixStateSchema = z .object({ - run_id: z.number(), + /** @deprecated Use sentry_run_id instead. */ + run_id: z.number().optional(), + /** Null for legacy runs predating this field. */ + sentry_run_id: z.string().nullable().optional(), status: z.string(), updated_at: z.string().optional(), request: z @@ -242,6 +245,18 @@ export function isTerminalStatus(status: string): boolean { return TERMINAL_STATUSES.includes(status as AutofixStatus); } +// At least one of sentry_run_id/run_id is always present on a non-null +// autofix state, so a missing run ID means a malformed response. +export function requireAutofixRunId(state: AutofixState): string | number { + const runId = state.sentry_run_id ?? state.run_id; + if (runId === undefined) { + throw new Error( + "Unexpected autofix response: missing both sentry_run_id and run_id." + ); + } + return runId; +} + /** Container that may hold root cause analysis data (legacy format) */ type WithCauses = { key: string; causes?: RootCause[] }; diff --git a/test/lib/api-client.seer.test.ts b/test/lib/api-client.seer.test.ts index afcbcfe3f..ba5ade22b 100644 --- a/test/lib/api-client.seer.test.ts +++ b/test/lib/api-client.seer.test.ts @@ -110,7 +110,7 @@ describe("getAutofixState", () => { return new Response( JSON.stringify({ autofix: { - run_id: 12_345, + sentry_run_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", status: "processing", blocks: [], updated_at: "2025-01-01T00:00:00Z", @@ -125,7 +125,7 @@ describe("getAutofixState", () => { const result = await getAutofixState("test-org", "123456789"); - expect(result?.run_id).toBe(12_345); + expect(result?.sentry_run_id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); expect(result?.status).toBe("PROCESSING"); expect(capturedRequest?.method).toBe("GET"); expect(capturedRequest?.url).toContain( @@ -134,6 +134,29 @@ describe("getAutofixState", () => { expect(capturedRequest?.url).toContain("mode=explorer"); }); + test("still parses legacy run_id when sentry_run_id is absent", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "processing", + blocks: [], + updated_at: "2025-01-01T00:00:00Z", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const result = await getAutofixState("test-org", "123456789"); + + expect(result?.run_id).toBe(12_345); + expect(result?.sentry_run_id).toBeUndefined(); + }); + test("normalizes agent status values to uppercase", async () => { globalThis.fetch = async () => new Response( @@ -274,4 +297,37 @@ describe("triggerSolutionPlanning", () => { referrer: "api.cli", }); }); + + test("sends a UUID run ID under the sentry_run_id body field, not run_id", async () => { + let capturedBody: unknown; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = await new Request(input, init).json(); + + return new Response( + JSON.stringify({ + run_id: 12_345, + sentry_run_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }), + { + status: 202, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + await triggerSolutionPlanning( + "test-org", + "123456789", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ); + + // sentry_run_id is a UUIDField server-side; run_id is an IntegerField. + // Sending the UUID under run_id would fail server-side validation. + expect(capturedBody).toEqual({ + step: "solution", + sentry_run_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + referrer: "api.cli", + }); + }); }); diff --git a/test/types/seer.test.ts b/test/types/seer.test.ts index f202b4145..96db67ec8 100644 --- a/test/types/seer.test.ts +++ b/test/types/seer.test.ts @@ -13,6 +13,7 @@ import { extractSolution, isTerminalStatus, type RootCause, + requireAutofixRunId, TERMINAL_STATUSES, } from "../../src/types/seer.js"; @@ -49,6 +50,29 @@ describe("isTerminalStatus", () => { }); }); +describe("requireAutofixRunId", () => { + test("prefers sentry_run_id over legacy run_id", () => { + const state: AutofixState = { + sentry_run_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + run_id: 123, + status: "COMPLETED", + }; + expect(requireAutofixRunId(state)).toBe( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ); + }); + + test("falls back to legacy run_id when sentry_run_id is absent", () => { + const state: AutofixState = { run_id: 123, status: "COMPLETED" }; + expect(requireAutofixRunId(state)).toBe(123); + }); + + test("throws when neither field is present", () => { + const state: AutofixState = { status: "COMPLETED" }; + expect(() => requireAutofixRunId(state)).toThrow(/missing both/); + }); +}); + describe("extractRootCauses", () => { test("extracts causes from root_cause_analysis step", () => { const state: AutofixState = {