Skip to content
Merged
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
21 changes: 15 additions & 6 deletions static/app/views/investigations/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,14 @@ function useInvestigationMutation<TData, TVariables>(
});
}

/**
* Start an empty investigation.
*
* A `source` with no `templateKey` is what makes the server build an agentic
* run rather than a bare notebook, so this is the field that decides whether
* the investigation ever has hypotheses. A manual source carries no prompt yet,
* so the run opens `awaiting_input` and waits for one.
*/
export function useCreateInvestigationMutation(
organizationSlug: string,
options?: MutationOptions<InvestigationListItem, void>
Expand All @@ -280,7 +288,7 @@ export function useCreateInvestigationMutation(
path: {organizationIdOrSlug: organizationSlug},
}),
method: 'POST',
data: {title: 'Untitled investigation'},
data: {title: 'Untitled investigation', source: {type: 'manual'}},
}),
options
);
Expand All @@ -298,11 +306,12 @@ export function useLaunchInvestigationMutation(
path: {organizationIdOrSlug: organizationSlug},
}),
method: 'POST',
data: {
templateKey: 'breached_metric',
templateVersion: 1,
source,
},
// No `templateKey`: the metric snapshot is enough for the server to
// build an agentic run, which is what gives this investigation
// hypotheses instead of a fixed sequence of notebook cells. The
// candidates endpoint matches agentic and template lineage keys alike,
// so an already-investigated breach still resolves to "View".
data: {source},
}),
options,
{invalidateCandidates: true}
Expand Down
4 changes: 2 additions & 2 deletions static/app/views/investigations/detail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
} from 'sentry/views/investigations/detail/cell';
import {
InvestigationHypotheses,
isInvestigationRunSettled,
shouldPollInvestigationRun,
} from 'sentry/views/investigations/hypotheses/investigationHypotheses';
import {updateInvestigationCache} from 'sentry/views/investigations/investigationCache';
import {InvestigationSummaryCard} from 'sentry/views/investigations/investigationSummaryCard';
Expand Down Expand Up @@ -94,7 +94,7 @@ export function InvestigationBootstrapPage({investigationId}: {investigationId:
// row, so a stale copy would leave the row hidden or showing a run that
// has since finished.
const orchestrationActive =
data?.orchestration && !isInvestigationRunSettled(data.orchestration.status);
data?.orchestration && shouldPollInvestigationRun(data.orchestration.status);
return orchestrationActive ||
shouldPollInvestigationBlocks(data?.blocks ?? []) ||
isTitleGenerationActive(data?.titleGeneration?.status)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar
import {InvestigationOrchestrationFixture} from 'sentry/views/investigations/fixtures';
import {
InvestigationHypotheses,
isInvestigationRunSettled,
shouldPollInvestigationRun,
} from 'sentry/views/investigations/hypotheses/investigationHypotheses';
import type {InvestigationOrchestration} from 'sentry/views/investigations/types';

Expand All @@ -25,6 +25,36 @@ function renderHypotheses() {
});
}

describe('shouldPollInvestigationRun', () => {
it.each([
['pending', true],
['processing', true],
[undefined, true],
// Blocked on a person, not on the agent. Every investigation created
// without a prompt starts here, so polling would never stop.
['awaiting_input', false],
['completed', false],
['failed', false],
['cancelled', false],
] as const)('%s polls: %s', (status, expected) => {
expect(shouldPollInvestigationRun(status)).toBe(expected);
});

it.each([
// A run parked at `awaiting_input` with no Seer id yet has not been created
// in Seer: the dispatch runs after the commit, and it can still rewrite the
// projection or fail the run, so the placeholder status must keep polling.
['awaiting_input', false, true],
// Once the run exists, the same status really does mean blocked on a person.
['awaiting_input', true, false],
// A create that failed leaves no Seer id behind, but the run has stopped for
// good — the missing id must not restart polling.
['failed', false, false],
] as const)('%s with hasSeerRun %s polls: %s', (status, hasSeerRun, expected) => {
expect(shouldPollInvestigationRun(status, hasSeerRun)).toBe(expected);
});
});

describe('InvestigationHypotheses', () => {
it('renders the hypotheses carried on the projection', async () => {
MockApiClient.addMockResponse({
Expand Down Expand Up @@ -261,18 +291,3 @@ describe('InvestigationHypotheses', () => {
expect(screen.getAllByText('Formed')).toHaveLength(3);
});
});

describe('isInvestigationRunSettled', () => {
it.each([
['completed', true],
['failed', true],
['cancelled', true],
// Not terminal: the run resumes as soon as input arrives, possibly from
// another surface, so the projection has to keep being read.
['awaiting_input', false],
['processing', false],
['pending', false],
] as const)('reads %s as %s', (status, expected) => {
expect(isInvestigationRunSettled(status)).toBe(expected);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,40 @@ const POLL_INTERVAL_MS = 2000;
*/
const COMMAND_SETTLE_MS = 30_000;

/** Statuses the agent will never move out of on its own. */
const TERMINAL_STATUSES = new Set<string>(['completed', 'failed', 'cancelled']);

/**
* Whether a workflow has stopped moving on its own.
* Whether the projection is still worth re-reading.
*
* `awaiting_input` is the subtle one. A run blocked on a person does not move
* until someone supplies a prompt, and an investigation created without one
* starts there and stays there, so polling it would be a permanent two-second
* request loop on a run nobody is driving. Supplying input from this client
* writes the new projection straight into the cache, which starts it again.
*
* But Sentry parks a brand-new run at `awaiting_input` *before* it has finished
* creating it in Seer: the create is dispatched after the transaction commits,
* and until it lands the run carries no Seer id. In that window the status is a
* placeholder rather than a decision to wait for a person, and the create can
* still fail the run or rewrite the projection underneath it — so it has to
* keep being read. `hasSeerRun` is how a caller says which of the two it is.
*
* `awaiting_input` is deliberately not terminal: the run resumes as soon as
* input arrives, which may happen from another surface, so polling has to
* continue. Exported because the detail view decides from the summary served
* alongside the investigation, and this component from the full projection —
* the same three statuses either way.
* Callers that cannot tell leave it alone and get the blocked-on-a-person
* reading: the detail view decides from the summary served alongside the
* investigation, and that summary carries no run id.
*/
export function isInvestigationRunSettled(
status: InvestigationOrchestrationStatus | undefined
export function shouldPollInvestigationRun(
status: InvestigationOrchestrationStatus | undefined,
hasSeerRun = true
): boolean {
return status === 'completed' || status === 'failed' || status === 'cancelled';
if (status === undefined) {
return true;
}
if (TERMINAL_STATUSES.has(status)) {
return false;
}
return status === 'awaiting_input' ? !hasSeerRun : true;
}

type InvestigationHypothesesProps = {
Expand Down Expand Up @@ -89,7 +110,8 @@ export function InvestigationHypotheses({
...investigationOrchestrationQueryOptions(organization.slug, investigationId),
enabled,
refetchInterval: query => {
if (!isInvestigationRunSettled(query.state.data?.json.status)) {
const run = query.state.data?.json;
if (shouldPollInvestigationRun(run?.status, run?.runId !== null)) {
return POLL_INTERVAL_MS;
}
const waitingOnCommand =
Expand Down
5 changes: 4 additions & 1 deletion static/app/views/investigations/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,10 @@ describe('Explore Investigations', () => {
await waitFor(() =>
expect(createRequest).toHaveBeenCalledWith(
listUrl,
expect.objectContaining({data: {title: 'Untitled investigation'}})
expect.objectContaining({
// A source with no templateKey is what makes this agentic.
data: {title: 'Untitled investigation', source: {type: 'manual'}},
})
)
);
expect(await screen.findByText('Untitled investigation')).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,9 @@ describe('MetricDetectorTriggeredSection', () => {
expect(launchMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
// No templateKey: the server builds an agentic run from the metric
// snapshot rather than a fixed notebook.
data: {
templateKey: 'breached_metric',
templateVersion: 1,
source: {
type: 'metric_open_period',
ref: {groupId: defaultGroup.id, openPeriodId: '101'},
Expand Down
Loading