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
32 changes: 32 additions & 0 deletions frontend/src/components/Scenarios/ScenarioRunPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,38 @@ describe('ScenarioRunPage', () => {
expect(screen.getByText(/showing the last successfully loaded progress/i)).toBeInTheDocument()
})

it('shows the persisted failure reason and type for failed runs', () => {
mockHookState(makeState({
run: {
...makeState().run!,
status: 'FAILED',
error: 'Scenario initialization failed.',
error_type: 'ValueError',
},
}))

renderPage()

expect(screen.getByText(
/Run failed \(ValueError\): Scenario initialization failed\. Finished executions remain available below\./,
)).toBeInTheDocument()
})

it('shows a generic failure message for legacy runs without error details', () => {
mockHookState(makeState({
run: {
...makeState().run!,
status: 'FAILED',
},
}))

renderPage()

expect(screen.getByText(
/This run ended before all planned executable units completed\. Finished executions remain available below\./,
)).toBeInTheDocument()
})

it('cancels after confirmation and immediately applies the returned terminal state', async () => {
const user = userEvent.setup()
const cancelledRun = {
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/components/Scenarios/ScenarioRunPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ function ScenarioRunPageContent({ scenarioResultId, attackResultId }: ScenarioRu
{run.status === 'FAILED' && (
<MessageBar intent="error">
<MessageBarBody>
This run ended before all planned executable units completed. Finished executions remain available below.
{formatRunFailure(run)}
</MessageBarBody>
</MessageBar>
)}
Expand Down Expand Up @@ -789,6 +789,20 @@ function formatRunState(status: string): string {
return status.toLowerCase().replace('_', ' ').replace(/^\w/, (letter) => letter.toUpperCase())
}

function formatRunFailure(run: ScenarioProgressHeader): string {
const completedResultsMessage = 'Finished executions remain available below.'
if (run.error_type && run.error) {
return `Run failed (${run.error_type}): ${run.error} ${completedResultsMessage}`
}
if (run.error) {
return `Run failed: ${run.error} ${completedResultsMessage}`
}
if (run.error_type) {
return `Run failed (${run.error_type}). ${completedResultsMessage}`
}
return `This run ended before all planned executable units completed. ${completedResultsMessage}`
}

function statusIcon(status: ScenarioRunState): React.ReactElement {
if (status === 'COMPLETED') {
return <CheckmarkCircleRegular />
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@ export interface ScenarioProgressHeader {
status: ScenarioRunState
created_at: string
completed_at?: string | null
error?: string | null
error_type?: string | null
pyrit_version?: string | null
target?: ScenarioTargetSummary | null
techniques_used?: string[]
Expand Down
2 changes: 2 additions & 0 deletions pyrit/backend/services/scenario_run_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,8 @@ def get_run_progress_from_storage(
status=header_result.scenario_run_state,
created_at=header_result.creation_time,
completed_at=header_result.completion_time if terminal else None,
error=header_result.error_message,
error_type=header_result.error_type,
pyrit_version=header_result.pyrit_version,
target=target,
techniques_used=techniques_used,
Expand Down
2 changes: 2 additions & 0 deletions pyrit/models/scenario_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ class ScenarioProgressHeader(BaseModel):
status: ScenarioRunState
created_at: datetime
completed_at: datetime | None = None
error: str | None = None
error_type: str | None = None
pyrit_version: str | None = None
target: "ScenarioTargetSummary | None" = None
techniques_used: list[str] = Field(default_factory=list)
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/backend/test_scenario_run_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2309,6 +2309,29 @@ def test_get_progress_uses_lightweight_queries_without_full_hydration(mock_memor
assert str(header.id) not in service._active_tasks


def test_get_progress_maps_persisted_failure_details(mock_memory) -> None:
plan = ScenarioRunPlan(atomic_groups=[], seed_groups=[], scenario_registry_name="test.scenario")
header = make_scenario_result(
attack_results={},
scenario_run_state=ScenarioRunState.FAILED,
error_message="Scenario initialization failed.",
error_type="ValueError",
metadata={SCENARIO_RUN_PLAN_METADATA_KEY: plan.model_dump(mode="json")},
)
mock_memory.get_scenario_result_header.return_value = header
mock_memory.get_scenario_attack_result_deltas.return_value = ([], False)

progress = ScenarioRunService().get_run_progress(
scenario_result_id=str(header.id),
since=None,
limit=25,
)

assert progress is not None
assert progress.run.error == "Scenario initialization failed."
assert progress.run.error_type == "ValueError"


def test_get_progress_cache_only_maps_new_storage_rows(mock_memory) -> None:
seed_group_ids = ["seed-1", "seed-2"]
plan = ScenarioRunPlan(
Expand Down