Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/commands/issue/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
extractNoSolutionReason,
extractRootCauses,
extractSolution,
requireAutofixRunId,
type SolutionArtifact,
} from "../../types/seer.js";
import {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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({
Expand Down
40 changes: 21 additions & 19 deletions src/lib/api/seer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<unknown> {
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/`,
Expand All @@ -136,7 +138,7 @@ export async function triggerSolutionPlanning(
params: EXPLORER_MODE_PARAMS,
body: {
step: "solution",
run_id: runId,
...runIdBodyField,
referrer: "api.cli",
},
}
Expand Down
17 changes: 16 additions & 1 deletion src/types/seer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ export type SolutionArtifact = z.infer<typeof SolutionArtifactSchema>;

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
Expand Down Expand Up @@ -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[] };

Expand Down
60 changes: 58 additions & 2 deletions test/lib/api-client.seer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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",
});
});
});
24 changes: 24 additions & 0 deletions test/types/seer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
extractSolution,
isTerminalStatus,
type RootCause,
requireAutofixRunId,
TERMINAL_STATUSES,
} from "../../src/types/seer.js";

Expand Down Expand Up @@ -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 = {
Expand Down
Loading