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;