diff --git a/.ai/spec/how/console-plugin-system.md b/.ai/spec/how/console-plugin-system.md
index a3dc86b5..2f81dbac 100644
--- a/.ai/spec/how/console-plugin-system.md
+++ b/.ai/spec/how/console-plugin-system.md
@@ -27,7 +27,7 @@
- `console.page/route` → maps a URL path to a React component via `$codeRef`
- `console.navigation/href` → adds a nav link in the admin perspective
-The `$codeRef` value (e.g., `"ProposalListPage"`) MUST have a matching key in `package.json` → `consolePlugin.exposedModules` (e.g., `"ProposalListPage": "./components/proposals/ProposalListPage"`). The value is a path relative to `src/`.
+The `$codeRef` value (e.g., `"RunListPage"`) MUST have a matching key in `package.json` → `consolePlugin.exposedModules` (e.g., `"RunListPage": "./components/runs/RunListPage"`). The value is a path relative to `src/`.
## Key Abstractions
diff --git a/.ai/spec/how/k8s-data-layer.md b/.ai/spec/how/k8s-data-layer.md
index 4e426385..02bd2b36 100644
--- a/.ai/spec/how/k8s-data-layer.md
+++ b/.ai/spec/how/k8s-data-layer.md
@@ -4,11 +4,11 @@
| File | Key Symbols | Responsibility |
|---|---|---|
-| `src/models/proposal.ts` | `LightspeedProposalModel`, `LightspeedProposalGVK`, all `*Model`/`*GVK` constants | K8sModel definitions for the Console SDK's watch/patch/create/delete functions |
-| `src/models/proposal.ts` | `LightspeedProposal`, `LightspeedProposalApproval`, `*ResultCR` types | TypeScript types for each CRD |
-| `src/models/proposal.ts` | `ProposalK8s`, `AnalysisResultK8s`, `ExecutionResultK8s`, `VerificationResultK8s`, `ProposalApprovalK8s` | K8s intersection types (`CRDType & K8sResourceCommon`) for `useK8sWatchResource` generics |
-| `src/models/proposal-views.ts` | `ProposalView`, `RemediationOptionView`, `ExecutionView`, `VerificationView` | View-model types — output of the API→view mapping layer |
-| `src/hooks/useProposal.ts` | `useProposal`, `mapToProposalView` | Fetches all proposal-related CRs and maps to a single `ProposalView` |
+| `src/models/agenticrun.ts` | `LightspeedAgenticRunModel`, `LightspeedAgenticRunGVK`, all `*Model`/`*GVK` constants | K8sModel definitions for the Console SDK's watch/patch/create/delete functions |
+| `src/models/agenticrun.ts` | `LightspeedAgenticRun`, `LightspeedAgenticRunApproval`, `*ResultCR` types | TypeScript types for each CRD |
+| `src/models/agenticrun.ts` | `AgenticRunK8s`, `AgenticRunApprovalK8s`, `AnalysisResultK8s`, `ExecutionResultK8s`, `VerificationResultK8s`, `EscalationResultK8s` | K8s intersection types (`CRDType & K8sResourceCommon`) for `useK8sWatchResource` generics |
+| `src/models/agenticrun-views.ts` | `AgenticRunView`, `RemediationOptionView`, `ExecutionView`, `VerificationView`, `EscalationView` | View-model types — output of the API→view mapping layer |
+| `src/hooks/useAgenticRun.ts` | `useAgenticRun`, `mapToAgenticRunView` | Fetches all run-related CRs and maps to a single `AgenticRunView` |
| `src/utils/approval.ts` | `buildApprovalPatch` | Generates JSON Patch arrays for `AgenticRunApproval` mutations |
## Data Flow
@@ -16,7 +16,7 @@
### AgenticRun Watching
```
-useK8sWatchResource(ProposalGVK, {name, namespace})
+useK8sWatchResource(LightspeedAgenticRunGVK, {name, namespace})
→ WebSocket watch on /apis/agentic.openshift.io/v1alpha1/namespaces/{ns}/agenticruns/{name}
→ Console SDK manages cache invalidation and re-renders
```
@@ -28,13 +28,13 @@ Result CRs are not watched by name. Instead:
```
useK8sWatchResource(AnalysisResultGVK, {namespace, selector: {matchLabels: {agentic.openshift.io/run: name}}, isList: true})
→ Returns all AnalysisResults for this run
- → getLatestResult(results, proposal.status.steps.analysis.results)
+ → filterLatest(results, run.status.steps.analysis.results)
→ Finds the result CR referenced by the last entry in the step's results array
```
This pattern repeats for ExecutionResult, VerificationResult, and EscalationResult. The `results[]` array on each step status contains `{name, outcome}` refs — the name matches the result CR's `metadata.name`.
-The `useProposal` hook wraps all five watches (Proposal, AnalysisResult, ExecutionResult, VerificationResult, ProposalApproval) and uses `filterLatest` to select the most recent result CR by `creationTimestamp`. The mapped `ProposalView` is recomputed via `useMemo` whenever any watched resource changes.
+The `useAgenticRun` hook wraps watches for AgenticRun, AnalysisResult, ExecutionResult, VerificationResult, EscalationResult, and AgenticRunApproval, and uses `filterLatest` to select the most recent result CR by `creationTimestamp`. The mapped `AgenticRunView` is recomputed via `useMemo` whenever any watched resource changes.
### Approval Patch Generation
@@ -57,19 +57,19 @@ consoleFetch(/api/kubernetes/.../pods/{pod}/log?container=agent&follow=true&time
### K8sModel Pattern
-Every CRD has a paired `K8sModel` (used by Console SDK functions) and a `GVK` object (used by `useK8sWatchResource`). The `K8sModel` includes `apiGroup`, `apiVersion`, `kind`, `plural`, `namespaced`, and display labels. These are defined once in `proposal.ts` and imported everywhere.
+Every CRD has a paired `K8sModel` (used by Console SDK functions) and a `GVK` object (used by `useK8sWatchResource`). The `K8sModel` includes `apiGroup`, `apiVersion`, `kind`, `plural`, `namespaced`, and display labels. These are defined once in `agenticrun.ts` and imported everywhere.
### Type Union Strategy
CRD types are hand-written, not generated. A TODO exists to auto-generate from OpenAPI. The types closely mirror the CRD status structure — changes in the operator's CRD require manual synchronization here.
-K8s intersection types (e.g., `ProposalK8s = LightspeedProposal & K8sResourceCommon`) are defined at the bottom of `proposal.ts` for use with `useK8sWatchResource` generics. A separate view-model layer in `proposal-views.ts` defines UI-optimized types (`ProposalView`, `RemediationOptionView`, etc.) with `*View` suffix. The `useProposal` hook in `src/hooks/useProposal.ts` contains pure mapping functions (`mapRootCause`, `mapOption`, `mapExecution`, `mapVerification`, `mapTimeline`) that transform API types into view types. Phase derivation is centralized in `derivePhaseFromConditions` (defined in `proposal.ts`, used by both list and detail pages).
+K8s intersection types (e.g., `AgenticRunK8s = LightspeedAgenticRun & K8sResourceCommon`) are defined at the bottom of `agenticrun.ts` for use with `useK8sWatchResource` generics. A separate view-model layer in `agenticrun-views.ts` defines UI-optimized types (`AgenticRunView`, `RemediationOptionView`, etc.) with `*View` suffix. The `useAgenticRun` hook in `src/hooks/useAgenticRun.ts` contains pure mapping functions (`mapRootCause`, `mapOption`, `mapExecution`, `mapVerification`, `mapEscalation`, `mapTimeline`) that transform API types into view types. Phase derivation is centralized in `derivePhaseFromConditions` (defined in `agenticrun.ts`, used by both list and detail pages).
### Approval Logic
-Approval logic is embedded in the `useProposal` hook — a single hook instance per detail page. It exposes:
+Approval logic is embedded in the `useAgenticRun` hook — a single hook instance per detail page. It exposes:
- Read: `canApprove` / `canApproveLoading` → derived from `useAccessReview` on `agenticrunapprovals`
-- Write: `approveExecution(optionIndex, maxRetries)` / `denyExecution()` → `k8sPatch` with patches from `buildApprovalPatch`
+- Write: `approveStage(stageType)` / `denyExecution()` → `k8sPatch` with patches from `buildApprovalPatch`
- State helpers: `stageNeedsApproval()` and `getStageStatus()` from `src/utils/approval.ts` are used internally
There is no per-tab instantiation — the detail page uses a single-page sectioned layout.
diff --git a/.ai/spec/how/project-structure.md b/.ai/spec/how/project-structure.md
index b82bd6ce..bd1e3478 100644
--- a/.ai/spec/how/project-structure.md
+++ b/.ai/spec/how/project-structure.md
@@ -8,13 +8,14 @@
| `src/config.ts` | `getApiUrl` | API proxy URL construction |
| `src/utils/approval.ts` | `findStage`, `getStageStatus`, `stageNeedsApproval`, `buildApprovalPatch` | Pure functions for approval logic |
| `src/utils/markdown.ts` | `renderMarkdown`, `renderMarkdownInline` | Low-level sanitized markdown rendering (marked + DOMPurify). `renderMarkdown` emits block HTML via `marked.parse`; `renderMarkdownInline` emits inline HTML via `marked.parseInline`. All links are hardened with `target="_blank" rel="noopener noreferrer"`. Prefer `MarkdownContent` component over direct calls. |
-| `src/utils/proposal-utils.ts` | `buildPodLogUrl`, `getOutcomeStatus`, `getReversibilityColor` | Helpers for pod log URLs, outcome status mapping, reversibility colors |
+| `src/utils/agenticrun-utils.ts` | `buildPodLogUrl`, `getOutcomeStatus`, `getReversibilityColor` | Helpers for pod log URLs, outcome status mapping, reversibility colors |
| `src/components/runs/RunListPage.tsx` | `RunListPage` | Run list with virtualized table and phase filters |
| `src/components/runs/RunDetailPage.tsx` | `RunDetailPage` | Section-based run detail page, delegates to `detail/` subcomponents |
| `src/components/runs/detail/AnalysisSummary.tsx` | `AnalysisSummary` | Analysis request display, analysis loading/streaming state |
| `src/components/runs/detail/RemediationOptionCard.tsx` | `RemediationOptionCard` | Expandable remediation option card with embedded root cause analysis |
| `src/components/runs/detail/ExecutionSummary.tsx` | `ExecutionSummary` | Post-execution actions and outcome display |
| `src/components/runs/detail/VerificationSummary.tsx` | `VerificationSummary` | Verification checks and summary |
+| `src/components/runs/detail/EscalationSummary.tsx` | `EscalationSummary` | Freeform escalation summary/content card (returns null when empty) |
| `src/components/runs/detail/RunPhaseLabel.tsx` | `RunPhaseLabel` | Phase label with status color |
| `src/components/runs/detail/RunTimeline.tsx` | `RunTimeline` | Chronological event timeline |
| `src/components/runs/detail/StageInProgress.tsx` | `StageInProgress` | In-progress stage card with embedded log viewer |
@@ -24,9 +25,9 @@
| `src/components/CodeBlockWithClipboard.tsx` | `CodeBlockWithClipboard` | Reusable code block with clipboard copy button and expandable truncation for long content |
| `src/components/ConfirmationModal.tsx` | `ConfirmationModal` | Reusable confirmation modal with confirm/cancel actions, loading state, and inline error display |
| `src/components/StatusGuard.tsx` | `StatusGuard` | Loading/error/empty gate using PatternFly `ErrorState`; replaces internal console `StatusBox` |
-| `src/models/agenticrun-views.ts` | `AgenticRunView`, `RemediationOptionView`, `ExecutionView`, `VerificationView`, `SandboxView`, `TimelineEvent`, `TERMINAL_PHASES` | View-model types for the detail page (output of `useAgenticRun` mapping layer) |
-| `src/constants.ts` | `PROPOSAL_NAMESPACE`, `PROPOSAL_LABEL_SOURCE`, `RESULT_LABEL_PROPOSAL` | Shared constants for K8s label keys and namespace |
-| `src/hooks/useAgenticRun.ts` | `useAgenticRun`, `mapRootCause`, `mapOption`, `mapExecution`, `mapVerification`, `mapTimeline`, `filterLatest` | Fetches run + result CRs, maps API types → view types |
+| `src/models/agenticrun-views.ts` | `AgenticRunView`, `RemediationOptionView`, `ExecutionView`, `VerificationView`, `EscalationView`, `SandboxView`, `TimelineEvent`, `TERMINAL_PHASES` | View-model types for the detail page (output of `useAgenticRun` mapping layer) |
+| `src/constants.ts` | `RUN_NAMESPACE`, `RUN_LABEL_SOURCE`, `RESULT_LABEL_RUN` | Shared constants for K8s label keys and namespace |
+| `src/hooks/useAgenticRun.ts` | `useAgenticRun`, `mapRootCause`, `mapOption`, `mapExecution`, `mapVerification`, `mapEscalation`, `mapTimeline`, `filterLatest` | Fetches run + result CRs (including EscalationResult), maps API types → view types |
| `src/hooks/useExecutionLogActions.ts` | `useExecutionLogActions` | Parses execution actions from sandbox pod logs |
| `src/hooks/useSandboxLogStream.ts` | `useSandboxLogStream` | Streams audit lines from sandbox pod logs |
| `src/components/configuration/ConfigurationPage.tsx` | `ConfigurationPage` | Configuration page with tabbed layout |
diff --git a/.ai/spec/what/run-lifecycle.md b/.ai/spec/what/run-lifecycle.md
index 762e5c28..ef4b19b4 100644
--- a/.ai/spec/what/run-lifecycle.md
+++ b/.ai/spec/what/run-lifecycle.md
@@ -31,16 +31,16 @@ The core domain of the plugin: displaying and managing runs through a multi-stag
### Run Detail — Layout
10. The detail page MUST display content progressively as data becomes available. The run header (breadcrumb, title, phase label, creation timestamp, failure/results alerts) MUST render immediately without waiting for result CRs. The detail section (RCA, remediation hub, timeline) MUST be gated behind a loading/error guard (`StatusGuard`): show a spinner while loading, an error state on failure (403 → restricted access, 404 → not found, other → error message with detail), and the section content when data is ready. `AnalysisSummary` MUST be gated on `view` (available once the run CR loads), not on `resultsLoaded`, so the analysis request prompt and analysis phase state display without waiting for all result CRs. Remediation hub and timeline remain gated on `resultsLoaded`.
-11. The detail page uses a single-page section layout (not tabs). Sections are rendered conditionally based on the current phase: Analysis request, Remediation options, Execution summary, Verification summary, and Timeline.
+11. The detail page uses a single-page section layout (not tabs). Sections are rendered conditionally based on the current phase: Analysis request, Remediation options, Execution summary, Verification summary, Escalation summary, and Timeline.
11a. Legal disclaimer banner — persistent info alert below the detail page title/status: "OpenShift Lightspeed uses AI technology to help generate remediation plans. Always review AI-generated content prior to use."
-11b. AI-generated content labeling — section headings for AI-generated content (Root cause analysis, Remediation hub, Verification summary) MUST display a compact "AI-generated" label inline next to the heading text.
-12. During in-progress stages (Analyzing, Executing, Verifying), the page MUST show a `StageInProgress` card with embedded live log streaming from the sandbox pod.
+11b. AI-generated content labeling — section headings for AI-generated content (Root cause analysis, Remediation hub, Verification summary, Escalation summary) MUST display a compact "AI-generated" label inline next to the heading text.
+12. During in-progress stages (Analyzing, Executing, Verifying, Escalating), the page MUST show a `StageInProgress` card with embedded live log streaming from the sandbox pod, unless a manual approval gate for that stage is pending (`StageApprovalBanner` is shown instead).
13. The page MUST be wrapped in `AgenticLayout` to display the system-suspended banner when the agentic config has `suspended: true`.
### Approval Flow
14. Each stage (Analysis, Execution, Verification, Escalation) can independently require approval based on the `AgenticRunApproval` CR.
-14a. **Authorization gate.** Before rendering Approve/Deny buttons, the plugin MUST perform a `useAccessReview` check for `patch` verb on `agenticrunapprovals` resource in API group `agentic.openshift.io`. The namespace MUST fall back from `approval.metadata.namespace` to the run's `metadata.namespace` when the approval CR has not loaded yet. If the user lacks the permission, the buttons MUST be disabled (using `isAriaDisabled` so hover/focus events remain active for the tooltip) with a tooltip stating "You must be a member of system:cluster-admins to approve or deny runs." This check is performed in the `useProposal` hook and exposed as `canApprove`/`canApproveLoading` on the returned view model. The `RemediationOptionCard` component receives `canApprove` as a prop to gate its Execute/Deny buttons, and `ConfirmationModal` is used for execution confirmation. The `approveExecution()` and `denyExecution()` callbacks in `useProposal` MUST also guard against `!canApprove` as defense-in-depth. This prevents confusing 403 errors — the API server enforces the real gate.
+14a. **Authorization gate.** Before rendering Approve/Deny buttons, the plugin MUST perform a `useAccessReview` check for `patch` verb on `agenticrunapprovals` resource in API group `agentic.openshift.io`. The namespace MUST fall back from `approval.metadata.namespace` to the run's `metadata.namespace` when the approval CR has not loaded yet. If the user lacks the permission, the buttons MUST be disabled (using `isAriaDisabled` so hover/focus events remain active for the tooltip) with a tooltip stating "You must be a member of system:cluster-admins to approve or deny runs." This check is performed in the `useAgenticRun` hook and exposed as `canApprove`/`canApproveLoading` on the returned view model. The `RemediationOptionCard` component receives `canApprove` as a prop to gate its Execute/Deny buttons, and `ConfirmationModal` is used for execution confirmation. The `approveStage()` and deny callbacks in `useAgenticRun` MUST also guard against `!canApprove` as defense-in-depth. This prevents confusing 403 errors — the API server enforces the real gate.
14b. **Stage approval gates.** When any non-Execution stage requires manual approval, the detail page shows an approval prompt (`StageApprovalBanner`) with an "Approve [stage]" button (primary) in place of the normal in-progress or skeleton UI for that stage. A "Deny run" button (secondary) is shown below the remediation hub when any non-Execution approval gate is pending. Both buttons are permission-gated via `canApprove` from `useAgenticRun`. Approving opens a confirmation modal; denying uses the existing deny confirmation modal. The plugin determines whether a stage needs manual approval from the `AgenticRunApproval` CR alone — when the `ApprovalPolicy` sets a stage to `Automatic`, the operator pre-populates the corresponding entry in `approval.spec.stages[]` at creation time, so a missing entry indicates manual approval is required. Execution keeps its own card-based approval flow (remediation option selection in the `Proposed` phase).
15. Approval decisions are written as JSON patches to the `AgenticRunApproval` CR, not to the `AgenticRun` CR.
16. When approving execution, the user can select a specific remediation option (by index) and specify retry count (0-3). Each option's remediation plan contains concrete bash commands (kubectl/oc) visible in the approval view.
@@ -78,7 +78,11 @@ The core domain of the plugin: displaying and managing runs through a multi-stag
29. Verification failure enables an "Escalate" button that opens a confirmation modal.
30. Escalation approval creates an Escalation stage in the `AgenticRunApproval` CR.
-31. Escalation results display a summary and optionally the full escalation content in an expandable section.
+31. The detail page watches `EscalationResult` CRs via the same label-selector pattern as other result CRs (`agentic.openshift.io/run`) and maps them into the run view (`EscalationView`).
+32. While the run is in the `Escalating` phase: if escalation requires manual approval, show `StageApprovalBanner`; otherwise show `StageInProgress` with sandbox log streaming from `status.steps.escalation.sandbox`.
+33. On terminal phases that include an escalation result, the page renders an `EscalationSummary` card. The card body is freeform AI-generated markdown from `EscalationResult.status.summary` and, when present and different, `status.content` — rendered as unmarked markdown (not titled subsections or an expandable section).
+34. `EscalationSummary` MUST NOT render when the mapped view has no `summary`, `content`, or escalation sandbox. Failure-only results (system/agent error with only `status.failureReason`) surface via the page-level danger alert that aggregates stage `failureReason` values, not as an empty card.
+35. Timeline events include Escalation started/completed conditions from the `EscalationResult` (same condition-to-event mapping as Analysis/Execution/Verification).
## Constraints
diff --git a/.ai/spec/what/system-overview.md b/.ai/spec/what/system-overview.md
index ed47359a..38400d40 100644
--- a/.ai/spec/what/system-overview.md
+++ b/.ai/spec/what/system-overview.md
@@ -45,4 +45,4 @@ The OpenShift Lightspeed Agentic Console Plugin is a dynamic plugin that loads i
| OLS-3578 | Run list page enhancements: nav restructure, trigger domain column/filter, tokens consumed column, kebab delete, page title/description |
| OLS-3579 | Run detail page enhancements: legal disclaimers, stop button, token count, execution record, download plan, approval buttons, confidence tag removal |
| OLS-3688 | Stage approval gates: extend approve/deny buttons to Analysis, Verification, and Escalation stages (currently only Execution has them) |
-| — | Auto-generate CRD types from OpenAPI schema (noted as TODO in `src/models/proposal.ts`) |
+| — | Auto-generate CRD types from OpenAPI schema (noted as TODO in `src/models/agenticrun.ts`) |
diff --git a/locales/en/plugin__lightspeed-agentic-console-plugin.json b/locales/en/plugin__lightspeed-agentic-console-plugin.json
index cf91e990..a6f712b6 100644
--- a/locales/en/plugin__lightspeed-agentic-console-plugin.json
+++ b/locales/en/plugin__lightspeed-agentic-console-plugin.json
@@ -58,7 +58,9 @@
"Endpoint": "Endpoint",
"Error": "Error",
"Error saving approval policy": "Error saving approval policy",
+ "Escalated": "Escalated",
"Escalation": "Escalation",
+ "Escalation summary": "Escalation summary",
"Estimated impact": "Estimated impact",
"Execute remediation": "Execute remediation",
"Execute remediation?": "Execute remediation?",
@@ -152,7 +154,7 @@
"Trigger domain": "Trigger domain",
"Type": "Type",
"Unable to load {{label}}": "Unable to load {{label}}",
- "Unable to load analysis results.": "Unable to load analysis results.",
+ "Unable to load run results.": "Unable to load run results.",
"URL override": "URL override",
"Verification": "Verification",
"Verification (seconds)": "Verification (seconds)",
diff --git a/src/components/runs/RunDetailPage.tsx b/src/components/runs/RunDetailPage.tsx
index f3370a41..46c4f86b 100644
--- a/src/components/runs/RunDetailPage.tsx
+++ b/src/components/runs/RunDetailPage.tsx
@@ -43,6 +43,7 @@ import { MarkdownContent } from '../MarkdownContent';
import PreviewBadge from '../PreviewBadge';
import StatusGuard from '../StatusGuard';
import { AnalysisSummary } from './detail/AnalysisSummary';
+import { EscalationSummary } from './detail/EscalationSummary';
import { ExecutionSummary } from './detail/ExecutionSummary';
import { RemediationOptionCard } from './detail/RemediationOptionCard';
import { RunPhaseLabel } from './detail/RunPhaseLabel';
@@ -251,7 +252,7 @@ const RunDetailPage: FC = () => {
{v.options.length > 0 && renderOptionCards({})}
{v.execution && }
{v.verification && }
- {needsApproval.Escalation && (
+ {needsApproval.Escalation ? (
{
onClearError={clearMutationError}
stageType="Escalation"
/>
+ ) : (
+ v.escalationSandbox && (
+
+ )
)}
>
);
+
default:
if (TERMINAL_PHASES.includes(v.phase)) {
return (
@@ -271,6 +281,7 @@ const RunDetailPage: FC = () => {
{v.options.length > 0 && renderOptionCards({})}
{v.execution && }
{v.verification && }
+ {v.escalation && }
>
);
}
@@ -385,7 +396,7 @@ const RunDetailPage: FC = () => {
{view?.failureReason && }
{resultsError && (
-
+
)}
diff --git a/src/components/runs/detail/EscalationSummary.tsx b/src/components/runs/detail/EscalationSummary.tsx
new file mode 100644
index 00000000..17c30466
--- /dev/null
+++ b/src/components/runs/detail/EscalationSummary.tsx
@@ -0,0 +1,71 @@
+import { Card, CardBody, CardHeader, Flex, FlexItem, Label, Title } from '@patternfly/react-core';
+import { ExclamationTriangleIcon } from '@patternfly/react-icons';
+import type { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import { EscalationView } from '../../../models/agenticrun-views';
+import { MarkdownContent } from '../../MarkdownContent';
+import { SandboxLogViewer } from './SandboxLogViewer';
+
+interface EscalationSummaryProps {
+ escalation: EscalationView;
+}
+
+export const EscalationSummary: FC = ({ escalation }) => {
+ const { t } = useTranslation('plugin__lightspeed-agentic-console-plugin');
+
+ // Failure-only EscalationResults (agent/system error before summary is produced)
+ // have no card body content — failureReason is shown via the page-level alert.
+ if (!escalation.summary && !escalation.content && !escalation.escalationSandbox) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t('Escalation summary')}
+
+
+
+
+
+
+
+
+
+
+ {escalation.summary && (
+
+
+
+ )}
+
+ {escalation.content && escalation.content !== escalation.summary && (
+
+
+
+ )}
+
+ {escalation.escalationSandbox && (
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/src/hooks/useAgenticRun.test.ts b/src/hooks/useAgenticRun.test.ts
index b762c667..2ff3cc3f 100644
--- a/src/hooks/useAgenticRun.test.ts
+++ b/src/hooks/useAgenticRun.test.ts
@@ -1,12 +1,21 @@
import { describe, expect, test } from 'vitest';
import {
AgenticRunCondition,
+ AgenticRunK8s,
AnalysisResultK8s,
derivePhaseFromConditions,
+ EscalationResultK8s,
ExecutionResultK8s,
RemediationOption,
} from '../models/agenticrun';
-import { filterLatest, mapExecution, mapOption, mapRootCause } from './useAgenticRun';
+import {
+ filterLatest,
+ mapEscalation,
+ mapExecution,
+ mapOption,
+ mapRootCause,
+ mapTimeline,
+} from './useAgenticRun';
const makeCondition = (
type: string,
@@ -380,3 +389,165 @@ describe('filterLatest', () => {
expect(filterLatest([older, newer], undefined)).toBe(newer);
});
});
+
+describe('mapEscalation', () => {
+ test('returns undefined when escalationResult is undefined', () => {
+ expect(mapEscalation(undefined)).toBeUndefined();
+ });
+
+ test('maps escalation result fields', () => {
+ const escalation: EscalationResultK8s = {
+ apiVersion: 'agentic.openshift.io/v1alpha1',
+ kind: 'EscalationResult',
+ metadata: { name: 'esc-1', namespace: 'default' },
+ spec: { agenticRunName: 'run-1' },
+ status: {
+ summary: 'Verification failed after remediation',
+ content: 'The pod was patched but the alert persisted. Recommend manual investigation.',
+ failureReason: undefined,
+ conditions: [
+ {
+ type: 'Started',
+ status: 'True',
+ lastTransitionTime: '2026-01-01T10:00:00Z',
+ },
+ {
+ type: 'Completed',
+ status: 'True',
+ reason: 'Succeeded',
+ lastTransitionTime: '2026-01-01T10:01:00Z',
+ },
+ ],
+ },
+ };
+ const result = mapEscalation(escalation);
+ expect(result).toBeDefined();
+ expect(result!.summary).toBe('Verification failed after remediation');
+ expect(result!.content).toBe(
+ 'The pod was patched but the alert persisted. Recommend manual investigation.',
+ );
+ expect(result!.failureReason).toBeUndefined();
+ expect(result!.escalationStartedAt).toBe('2026-01-01T10:00:00Z');
+ });
+
+ test('maps sandbox info when provided', () => {
+ const escalation: EscalationResultK8s = {
+ apiVersion: 'agentic.openshift.io/v1alpha1',
+ kind: 'EscalationResult',
+ metadata: { name: 'esc-1', namespace: 'default' },
+ spec: { agenticRunName: 'run-1' },
+ status: { summary: 'Summary' },
+ };
+ const sandbox = { claimName: 'sandbox-pod', namespace: 'test-ns' };
+ const result = mapEscalation(escalation, sandbox);
+ expect(result!.escalationSandbox).toEqual({ podName: 'sandbox-pod', namespace: 'test-ns' });
+ });
+
+ test('maps failureReason when escalation step itself fails', () => {
+ const escalation: EscalationResultK8s = {
+ apiVersion: 'agentic.openshift.io/v1alpha1',
+ kind: 'EscalationResult',
+ metadata: { name: 'esc-1', namespace: 'default' },
+ spec: { agenticRunName: 'run-1' },
+ status: { failureReason: 'Sandbox timeout after 120s' },
+ };
+ const result = mapEscalation(escalation);
+ expect(result!.failureReason).toBe('Sandbox timeout after 120s');
+ });
+});
+
+describe('mapTimeline escalation events', () => {
+ const t = ((key: string) => key) as unknown as Parameters[2];
+
+ const makeRun = (conditions?: AgenticRunCondition[]): AgenticRunK8s =>
+ ({
+ apiVersion: 'agentic.openshift.io/v1alpha1',
+ kind: 'AgenticRun',
+ metadata: { name: 'run-1', namespace: 'default', creationTimestamp: '2026-01-01T00:00:00Z' },
+ spec: { request: 'Fix alert' },
+ status: { conditions },
+ }) as AgenticRunK8s;
+
+ test('includes escalation started and completed events', () => {
+ const escalation: EscalationResultK8s = {
+ apiVersion: 'agentic.openshift.io/v1alpha1',
+ kind: 'EscalationResult',
+ metadata: { name: 'esc-1', namespace: 'default' },
+ spec: { agenticRunName: 'run-1' },
+ status: {
+ conditions: [
+ {
+ type: 'Started',
+ status: 'True',
+ lastTransitionTime: '2026-01-01T10:00:00Z',
+ },
+ {
+ type: 'Completed',
+ status: 'True',
+ reason: 'Succeeded',
+ message: 'Escalation complete',
+ lastTransitionTime: '2026-01-01T10:01:00Z',
+ },
+ ],
+ },
+ };
+
+ const run = makeRun([makeCondition('Escalated', 'True')]);
+ const events = mapTimeline(
+ run,
+ 'Escalated',
+ t,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ escalation,
+ );
+
+ const escalationEvents = events.filter(
+ (e) => e.label.includes('Escalation') || e.label.includes('escalation'),
+ );
+ expect(escalationEvents.length).toBe(2);
+ expect(escalationEvents[0].label).toContain('started');
+ expect(escalationEvents[1].label).toContain('completed');
+ expect(escalationEvents[1].description).toBe('Escalation complete');
+ });
+
+ test('includes escalation failure reason in timeline event', () => {
+ const escalation: EscalationResultK8s = {
+ apiVersion: 'agentic.openshift.io/v1alpha1',
+ kind: 'EscalationResult',
+ metadata: { name: 'esc-1', namespace: 'default' },
+ spec: { agenticRunName: 'run-1' },
+ status: {
+ failureReason: 'Sandbox crashed',
+ conditions: [
+ {
+ type: 'Completed',
+ status: 'True',
+ reason: 'Failed',
+ lastTransitionTime: '2026-01-01T10:01:00Z',
+ },
+ ],
+ },
+ };
+
+ const run = makeRun([makeCondition('Escalated', 'True')]);
+ const events = mapTimeline(
+ run,
+ 'Escalated',
+ t,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ escalation,
+ );
+
+ const failedEvent = events.find(
+ (e) => e.label.includes('Escalation') && e.variant === 'danger',
+ );
+ expect(failedEvent).toBeDefined();
+ expect(failedEvent!.description).toBe('Sandbox crashed');
+ });
+});
diff --git a/src/hooks/useAgenticRun.ts b/src/hooks/useAgenticRun.ts
index 40689178..28f2725b 100644
--- a/src/hooks/useAgenticRun.ts
+++ b/src/hooks/useAgenticRun.ts
@@ -16,6 +16,8 @@ import {
AnalysisResultK8s,
ApprovalStageType,
derivePhaseFromConditions,
+ EscalationResultGVK,
+ EscalationResultK8s,
ExecutionResultGVK,
ExecutionResultK8s,
LightspeedAgenticRunApprovalGVK,
@@ -30,6 +32,7 @@ import {
import { buildApprovalPatch, stageNeedsApproval } from '../utils/approval';
import {
AgenticRunView,
+ EscalationView,
ExecutionView,
RemediationOptionView,
RootCauseView,
@@ -136,6 +139,23 @@ export const mapVerification = (
};
};
+export const mapEscalation = (
+ escalationResult: EscalationResultK8s | undefined,
+ escalationSandbox?: { claimName?: string; namespace?: string },
+): EscalationView | undefined => {
+ if (!escalationResult) return undefined;
+
+ return {
+ summary: escalationResult.status?.summary,
+ content: escalationResult.status?.content,
+ failureReason: escalationResult.status?.failureReason,
+ escalationSandbox: mapSandbox(escalationSandbox),
+ escalationStartedAt: (escalationResult.status?.conditions ?? []).find(
+ (c) => c.type === 'Started',
+ )?.lastTransitionTime,
+ };
+};
+
const condVariant = (reason?: string): TimelineEvent['variant'] => {
if (reason === 'Succeeded' || reason === 'Complete') return 'success';
if (reason === 'Failed') return 'danger';
@@ -151,6 +171,7 @@ export const mapTimeline = (
execution?: ExecutionResultK8s,
verification?: VerificationResultK8s,
approval?: AgenticRunApprovalK8s,
+ escalation?: EscalationResultK8s,
): TimelineEvent[] => {
const events: TimelineEvent[] = [];
@@ -181,6 +202,12 @@ export const mapTimeline = (
currentPhase: 'Verifying',
failureReason: verification?.status?.failureReason,
},
+ {
+ conditions: escalation?.status?.conditions,
+ label: t('Escalation'),
+ currentPhase: 'Escalating',
+ failureReason: escalation?.status?.failureReason,
+ },
];
for (const { conditions, label, currentPhase, failureReason } of conditionSources) {
@@ -288,6 +315,7 @@ const mapToAgenticRunView = (
execution: ExecutionResultK8s | undefined,
verification: VerificationResultK8s | undefined,
approval: AgenticRunApprovalK8s | undefined,
+ escalation: EscalationResultK8s | undefined,
t: TFunction,
): AgenticRunView | undefined => {
if (!run?.metadata?.name) return undefined;
@@ -297,7 +325,8 @@ const mapToAgenticRunView = (
const failureReason =
analysis?.status?.failureReason ??
execution?.status?.failureReason ??
- verification?.status?.failureReason;
+ verification?.status?.failureReason ??
+ escalation?.status?.failureReason;
return {
phase,
@@ -318,12 +347,16 @@ const mapToAgenticRunView = (
(c) => c.type === 'Started',
)?.lastTransitionTime,
verificationSandbox: mapSandbox(run.status?.steps?.verification?.sandbox),
+ escalationStartedAt: (escalation?.status?.conditions ?? []).find((c) => c.type === 'Started')
+ ?.lastTransitionTime,
+ escalationSandbox: mapSandbox(run.status?.steps?.escalation?.sandbox),
executedOptionIndex: (approval?.spec?.stages ?? []).find((s) => s.type === 'Execution')
?.execution?.option,
options: (options ?? []).map((opt, i) => mapOption(opt, i)),
execution: mapExecution(options, execution, run.status?.steps?.execution?.sandbox),
verification: mapVerification(verification, run.status?.steps?.verification?.sandbox),
- timeline: mapTimeline(run, phase, t, analysis, execution, verification, approval),
+ escalation: mapEscalation(escalation, run.status?.steps?.escalation?.sandbox),
+ timeline: mapTimeline(run, phase, t, analysis, execution, verification, approval, escalation),
};
};
@@ -393,6 +426,19 @@ export const useAgenticRun = (
: null,
);
+ const [escalationResults, escalationLoaded, escalationError] = useK8sWatchResource<
+ EscalationResultK8s[]
+ >(
+ watchEnabled
+ ? {
+ groupVersionKind: EscalationResultGVK,
+ namespace,
+ isList: true,
+ selector: { matchLabels: { [RESULT_LABEL_RUN]: name } },
+ }
+ : null,
+ );
+
const [approval, approvalLoaded, approvalError] = useK8sWatchResource(
watchEnabled
? {
@@ -406,6 +452,7 @@ export const useAgenticRun = (
const analysisRefs = run?.status?.steps?.analysis?.results;
const executionRefs = run?.status?.steps?.execution?.results;
const verificationRefs = run?.status?.steps?.verification?.results;
+ const escalationRefs = run?.status?.steps?.escalation?.results;
const analysis = useMemo(
() => filterLatest(analysisResults, analysisRefs),
@@ -419,10 +466,14 @@ export const useAgenticRun = (
() => filterLatest(verificationResults, verificationRefs),
[verificationResults, verificationRefs],
);
+ const escalation = useMemo(
+ () => filterLatest(escalationResults, escalationRefs),
+ [escalationResults, escalationRefs],
+ );
const view = useMemo(
- () => mapToAgenticRunView(run, analysis, execution, verification, approval, t),
- [run, analysis, execution, verification, approval, t],
+ () => mapToAgenticRunView(run, analysis, execution, verification, approval, escalation, t),
+ [run, analysis, execution, verification, approval, escalation, t],
);
const conditions = run?.status?.conditions;
@@ -438,12 +489,14 @@ export const useAgenticRun = (
[approval, conditions, phase],
);
- const resultsLoaded = analysisLoaded && executionLoaded && verificationLoaded && approvalLoaded;
+ const resultsLoaded =
+ analysisLoaded && executionLoaded && verificationLoaded && escalationLoaded && approvalLoaded;
const approvalNotFound = approvalError instanceof HttpError && approvalError.code === 404;
const resultsError =
analysisError ??
executionError ??
verificationError ??
+ escalationError ??
(approvalNotFound ? undefined : approvalError);
const [canApprove, canApproveLoading] = useAccessReview({
diff --git a/src/models/agenticrun-views.ts b/src/models/agenticrun-views.ts
index c14a9851..5af9a00e 100644
--- a/src/models/agenticrun-views.ts
+++ b/src/models/agenticrun-views.ts
@@ -83,6 +83,14 @@ export interface ExecutionView {
executionStartedAt?: string;
}
+export interface EscalationView {
+ summary?: string;
+ content?: string;
+ failureReason?: string;
+ escalationSandbox?: SandboxView;
+ escalationStartedAt?: string;
+}
+
export interface AgenticRunView {
phase: AgenticRunPhase;
request: string;
@@ -98,9 +106,12 @@ export interface AgenticRunView {
executionSandbox?: SandboxView;
verificationStartedAt?: string;
verificationSandbox?: SandboxView;
+ escalationStartedAt?: string;
+ escalationSandbox?: SandboxView;
executedOptionIndex?: number;
options: RemediationOptionView[];
execution?: ExecutionView;
verification?: VerificationView;
+ escalation?: EscalationView;
timeline: TimelineEvent[];
}
diff --git a/src/models/agenticrun.ts b/src/models/agenticrun.ts
index 8ed753cc..820a9804 100644
--- a/src/models/agenticrun.ts
+++ b/src/models/agenticrun.ts
@@ -139,6 +139,23 @@ export const VerificationResultGVK = {
version: VerificationResultModel.apiVersion,
};
+export const EscalationResultModel: K8sModel = {
+ apiGroup: 'agentic.openshift.io',
+ apiVersion: 'v1alpha1',
+ kind: 'EscalationResult',
+ plural: 'escalationresults',
+ abbr: 'ESR',
+ namespaced: true,
+ label: 'EscalationResult',
+ labelPlural: 'EscalationResults',
+};
+
+export const EscalationResultGVK = {
+ group: EscalationResultModel.apiGroup,
+ kind: EscalationResultModel.kind,
+ version: EscalationResultModel.apiVersion,
+};
+
export const AgenticOLSConfigModel: K8sModel = {
apiGroup: 'agentic.openshift.io',
apiVersion: 'v1alpha1',
@@ -755,3 +772,4 @@ export type AgenticRunApprovalK8s = LightspeedAgenticRunApproval & K8sResourceCo
export type AnalysisResultK8s = AnalysisResultCR & K8sResourceCommon;
export type ExecutionResultK8s = ExecutionResultCR & K8sResourceCommon;
export type VerificationResultK8s = VerificationResultCR & K8sResourceCommon;
+export type EscalationResultK8s = EscalationResultCR & K8sResourceCommon;