diff --git a/docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md b/docs-archived/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md
similarity index 67%
rename from docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md
rename to docs-archived/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md
index f2a291a25..592389c97 100644
--- a/docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md
+++ b/docs-archived/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md
@@ -31,7 +31,7 @@
## Delivery Tracker
### FE-WV-001 - Wire run-detail tabs into Releases and Evidence
-Status: TODO
+Status: DONE
Dependency: none
Owners: Product Manager, FE Architect
Task description:
@@ -39,12 +39,12 @@ Task description:
- Ensure operators can reach the graph and replay experience from the active shells.
Completion criteria:
-- [ ] Canonical run-detail tabs are active in the router.
-- [ ] Runtime graph and replay routes are mounted and reachable.
-- [ ] Evidence-side entry points open the working runtime context.
+- [x] Canonical run-detail tabs are active in the router.
+- [x] Runtime graph and replay routes are mounted and reachable.
+- [x] Evidence-side entry points open the working runtime context.
### FE-WV-002 - Ship the Graph, Timeline, and Critical Path tabs
-Status: TODO
+Status: DONE
Dependency: FE-WV-001
Owners: Developer, FE Architect
Task description:
@@ -52,12 +52,12 @@ Task description:
- Ensure these tabs are usable for real run diagnosis, not just visual placeholders.
Completion criteria:
-- [ ] Graph, timeline, and critical-path tabs render with working filters and node metadata.
-- [ ] Operators can diagnose run state from the shipped tabs.
-- [ ] Step selection opens the working detail drawer.
+- [x] Graph, timeline, and critical-path tabs render with working filters and node metadata.
+- [x] Operators can diagnose run state from the shipped tabs.
+- [x] Step selection opens the working detail drawer.
### FE-WV-003 - Ship Replay and Evidence integration
-Status: TODO
+Status: DONE
Dependency: FE-WV-001
Owners: Developer, Product Manager
Task description:
@@ -65,12 +65,12 @@ Task description:
- Integrate the existing evidence replay controls and proof replay surfaces into the active run-detail shell.
Completion criteria:
-- [ ] Replay tab works from the active run-detail shell.
-- [ ] Existing evidence replay controls are usable from the new tab model.
-- [ ] Evidence and Replay tabs both expose their intended workflows in the shipped UI.
+- [x] Replay tab works from the active run-detail shell.
+- [x] Existing evidence replay controls are usable from the new tab model.
+- [x] Evidence and Replay tabs both expose their intended workflows in the shipped UI.
### FE-WV-004 - Ship step-detail drill-in and deep-link behavior
-Status: TODO
+Status: DONE
Dependency: FE-WV-002
Owners: Developer, FE Architect
Task description:
@@ -78,12 +78,12 @@ Task description:
- Ensure failed-step drill-ins work from graph and timeline views without losing run context.
Completion criteria:
-- [ ] Step-detail drawer works from graph and timeline tabs.
-- [ ] `step=` deep-link behavior is usable and shareable.
-- [ ] Any required full-route escalation is implemented only where necessary.
+- [x] Step-detail drawer works from graph and timeline tabs.
+- [x] `step=` deep-link behavior is usable and shareable.
+- [x] Any required full-route escalation is implemented only where necessary.
### FE-WV-005 - Implement bounded workflow-editor preview reuse
-Status: TODO
+Status: DONE
Dependency: FE-WV-001
Owners: FE Architect, Developer
Task description:
@@ -91,12 +91,12 @@ Task description:
- Keep authoring preview clearly separate from live run telemetry and troubleshooting.
Completion criteria:
-- [ ] Workflow editor can render the bounded preview mode.
-- [ ] Runtime-only behaviors are excluded from preview.
-- [ ] Shared and runtime-only graph components are separated in the shipped implementation.
+- [x] Workflow editor can render the bounded preview mode.
+- [x] Runtime-only behaviors are excluded from preview.
+- [x] Shared and runtime-only graph components are separated in the shipped implementation.
### FE-WV-006 - Verify, redirect, and document the shipped capability
-Status: TODO
+Status: DONE
Dependency: FE-WV-003
Owners: QA, Documentation author
Task description:
@@ -104,24 +104,37 @@ Task description:
- Update release and evidence docs so workflow visualization and replay ship as a usable capability, not just a merge target.
Completion criteria:
-- [ ] Verification covers graph, timeline, replay, and evidence tabs.
-- [ ] Step deep links and alias redirects are included in testing.
-- [ ] Docs reflect the shipped run-detail and replay capability.
+- [x] Verification covers graph, timeline, replay, and evidence tabs.
+- [x] Step deep links and alias redirects are included in testing.
+- [x] Docs reflect the shipped run-detail and replay capability.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-07 | Sprint created to ship workflow graphing and replay as a run-detail capability under Releases, with evidence-side entry points and a bounded authoring preview reuse model. | Project Manager |
+| 2026-03-07 | Implementation resumed; shared workflow components, route wiring, and verification were moved into active delivery. | Developer |
+| 2026-03-07 | Shipped the canonical `/releases/runs/:runId/{summary|graph|timeline|critical-path|replay|evidence}` workspace, bounded workflow-editor preview reuse, evidence replay handoff, and legacy `workflow-visualization/*` alias redirects into the mounted run shell. | Developer |
+| 2026-03-07 | Verified the feature with targeted Angular tests (`npx ng test --watch=false --include src/tests/release-control/release-control-routes.spec.ts --include src/tests/releases/release-detail.live-refresh.spec.ts --include src/tests/evidence/replay-controls-reachability-handoff.spec.ts --include src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts --include src/tests/workflow_visualization/run-graph-replay-page.behavior.spec.ts`) and Playwright browser scenarios (`npx playwright test tests/e2e/workflow-visualization-replay.spec.ts --workers=1`). | QA |
## Decisions & Risks
- Decision: runtime workflow visualization is owned by `Releases > Runs`, not by a standalone product.
- Decision: `Evidence > Verify & Replay` becomes a secondary entry point into the same replay model.
+- Decision: the canonical mounted routes are `/releases/runs/:runId/{summary|graph|timeline|critical-path|replay|evidence}` and legacy `workflow-visualization/*` URLs now redirect into that route family.
+- Decision: workflow editor preview reuse is graph-only and cannot trigger runtime graph or replay loading.
- Risk: runtime and authoring semantics may get mixed in one component tree and confuse operators.
- Mitigation: freeze a strict preview versus runtime boundary before implementation begins.
- Risk: replay controls already living in evidence routes may diverge from the run-detail experience.
- Mitigation: require one shared route and tab model for replay semantics.
- Delivery rule: this sprint is only complete when release operators can use the mounted graph, timeline, replay, and evidence flows without depending on the dead workflow-visualization branch.
- Reference design note: `docs/modules/ui/workflow-visualization-replay/README.md`.
+- Docs synced:
+ - `docs/modules/ui/workflow-visualization-replay/README.md`
+ - `docs/features/checked/web/workflow-visualization-replay-ui.md`
+ - `docs/modules/ui/restoration-topics/workflow-visualization-and-replay.md`
+ - `docs/modules/ui/restoration-topics/README.md`
+ - `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md`
+ - `docs/modules/ui/TASKS.md`
+ - `docs/modules/ui/implementation_plan.md`
## Next Checkpoints
- 2026-03-08: confirm runtime ownership, tab model, and evidence entry-point rules.
diff --git a/docs/features/checked/web/workflow-visualization-replay-ui.md b/docs/features/checked/web/workflow-visualization-replay-ui.md
new file mode 100644
index 000000000..ce9ae3a99
--- /dev/null
+++ b/docs/features/checked/web/workflow-visualization-replay-ui.md
@@ -0,0 +1,59 @@
+# Workflow Visualization Replay UI
+
+## Module
+Web
+
+## Status
+VERIFIED
+
+## Description
+Shipped the canonical `Releases > Runs` graph and replay workspace with stable summary, graph, timeline, critical-path, replay, and evidence tabs. Operators can drill into failed steps, deep-link a step drawer with `step=`, move into `Evidence > Verify & Replay`, and return to the same run context without depending on the dead `workflow-visualization` branch.
+
+## Implementation Details
+- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/workflow-visualization/`
+- **Primary components**:
+ - `run-graph-replay-page` (`src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts`)
+ - `workflow-visualizer` (`src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/workflow-visualizer/workflow-visualizer.component.ts`)
+ - `time-travel-controls` (`src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/time-travel-controls/time-travel-controls.component.ts`)
+ - `step-detail-panel` (`src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/step-detail-panel/step-detail-panel.component.ts`)
+- **Canonical routes**:
+ - `/releases/runs/:runId/summary`
+ - `/releases/runs/:runId/graph`
+ - `/releases/runs/:runId/timeline`
+ - `/releases/runs/:runId/critical-path`
+ - `/releases/runs/:runId/replay`
+ - `/releases/runs/:runId/evidence`
+- **Legacy aliases**:
+ - `/workflow-visualization/:runId`
+ - `/workflow-visualization/:runId/:tab`
+- **Query state**:
+ - `step=`
+ - `returnTo=`
+- **Secondary entry points**:
+ - `Releases > Activity`
+ - release detail replay actions
+ - `Evidence > Verify & Replay`
+ - workflow editor `Preview DAG` mode
+
+## E2E Test Plan
+- **Setup**:
+ - [ ] Log in with a user that can access `Releases`, `Evidence`, and workflow administration.
+ - [ ] Navigate to `/releases/runs//graph`.
+ - [ ] Ensure run detail, timeline, replay, and workflow graph fixtures or seeded data exist.
+- **Core verification**:
+ - [ ] Verify `Summary`, `Graph`, `Timeline`, `Critical Path`, `Replay`, and `Evidence` tabs render in one mounted run shell.
+ - [ ] Verify graph and timeline step selection open the step drawer and preserve `step=` in the URL.
+ - [ ] Verify replay and evidence actions preserve run context and hand off correctly to `Evidence > Verify & Replay`.
+- **Legacy verification**:
+ - [ ] Verify `workflow-visualization/*` aliases redirect into `/releases/runs/:runId/*`.
+ - [ ] Verify authoring preview renders the graph-only workflow editor mode without runtime replay loading.
+ - [ ] Verify redirect and deep-link behavior tolerate additional context query params.
+
+## Verification
+- Run:
+ - `npx ng test --watch=false --include src/tests/release-control/release-control-routes.spec.ts --include src/tests/releases/release-detail.live-refresh.spec.ts --include src/tests/evidence/replay-controls-reachability-handoff.spec.ts --include src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts --include src/tests/workflow_visualization/run-graph-replay-page.behavior.spec.ts`
+ - `npx playwright test tests/e2e/workflow-visualization-replay.spec.ts --workers=1`
+- Tier 0 (source): pass
+- Tier 1 (build/tests): pass
+- Tier 2 (behavior): pass
+- Verified on (UTC): 2026-03-07T21:24:11.4848681Z
diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md
index f476a4047..6ddf76b59 100644
--- a/docs/modules/ui/TASKS.md
+++ b/docs/modules/ui/TASKS.md
@@ -9,7 +9,6 @@
- `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md`
- `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md`
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md`
-- `docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`
## Delivery Tasks
@@ -92,12 +91,12 @@
- [DONE] FE-TX-004 Audit bundles page and create-flow slice
- [DONE] FE-TX-005 Supporting component merge matrix for Triage explainability
- [DONE] FE-TX-006 QA, rollout, and docs sync for Triage explainability
-- [TODO] FE-WV-001 Freeze run-detail tab and route contract for workflow visualization
-- [TODO] FE-WV-002 Graph, timeline, and critical-path slice
-- [TODO] FE-WV-003 Replay and evidence integration slice
-- [TODO] FE-WV-004 Step-detail drawer and deep-link behavior
-- [TODO] FE-WV-005 Workflow-editor preview reuse boundary
-- [TODO] FE-WV-006 QA, rollout, alias migration, and docs sync for workflow visualization
+- [DONE] FE-WV-001 Freeze run-detail tab and route contract for workflow visualization
+- [DONE] FE-WV-002 Graph, timeline, and critical-path slice
+- [DONE] FE-WV-003 Replay and evidence integration slice
+- [DONE] FE-WV-004 Step-detail drawer and deep-link behavior
+- [DONE] FE-WV-005 Workflow-editor preview reuse boundary
+- [DONE] FE-WV-006 QA, rollout, alias migration, and docs sync for workflow visualization
- [TODO] FE-CA-001 Freeze contextual placement decision matrix and route-state contract
- [TODO] FE-CA-002 Shared contextual drawer host
- [TODO] FE-CA-003 Split list-detail and right-rail primitives
diff --git a/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md b/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md
index 5e9a4c277..91796834b 100644
--- a/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md
+++ b/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md
@@ -93,7 +93,8 @@ The order is by confidence that the capability should exist in the final Stella
- `/releases/runs`, `/evidence`, and release-context views
- Notes:
- Detailed UX dossier: `docs/modules/ui/workflow-visualization-replay/README.md`
- - Implementation sprint: `docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
+ - Implementation sprint: `docs-archived/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
+ - Shipped verification note: `docs/features/checked/web/workflow-visualization-replay-ui.md`
## Tier 2 - Surface Existing Capability Instead Of Rebuilding
diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md
index 497615ec1..0bc5b3b86 100644
--- a/docs/modules/ui/implementation_plan.md
+++ b/docs/modules/ui/implementation_plan.md
@@ -13,7 +13,6 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components.
- `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation.
- `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself.
-- `SPRINT_20260307_028_FE_workflow_visualization_replay.md` - ship run-detail graph, timeline, replay, and evidence tabs plus bounded workflow-editor preview reuse.
- `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - ship the shared tabs, drawers, right rails, split views, and contextual detail primitives adopted by the restoration features.
## Latest evidence
@@ -27,6 +26,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs.
- `docs/features/checked/web/operations-consolidation-ui.md` - shipped verification note for the canonical Operations shell, overview grouping, and legacy alias cutover.
- `docs/features/checked/web/triage-explainability-workspace-ui.md` - shipped verification note for the canonical triage artifact workspace, explainability rail, audit bundles, and security alias cutover.
+- `docs/features/checked/web/workflow-visualization-replay-ui.md` - shipped verification note for the canonical run-detail graph, timeline, replay, evidence tabs, and workflow-editor preview reuse boundary.
- `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract.
- `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan.
- `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier.
diff --git a/docs/modules/ui/restoration-topics/README.md b/docs/modules/ui/restoration-topics/README.md
index bf7b56240..649b1c8fd 100644
--- a/docs/modules/ui/restoration-topics/README.md
+++ b/docs/modules/ui/restoration-topics/README.md
@@ -31,7 +31,7 @@ It answers four questions for each topic:
- `docs/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md`
- `docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md`
- `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
-- `docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
+- `docs-archived/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md` - shipped workflow visualization and replay restoration
- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`
## Placement Matrix
diff --git a/docs/modules/ui/restoration-topics/workflow-visualization-and-replay.md b/docs/modules/ui/restoration-topics/workflow-visualization-and-replay.md
index 38aaf365d..3b5e40904 100644
--- a/docs/modules/ui/restoration-topics/workflow-visualization-and-replay.md
+++ b/docs/modules/ui/restoration-topics/workflow-visualization-and-replay.md
@@ -96,7 +96,8 @@ The graph/timeline/critical-path experiences should be facets of the same run de
## Detailed UX And Sprint
- Detailed UX dossier: `../workflow-visualization-replay/README.md`
-- Implementation sprint: `../../../implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
+- Implementation sprint: `../../../docs-archived/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
+- Shipped verification note: `../../../features/checked/web/workflow-visualization-replay-ui.md`
## Corroborating Inputs
diff --git a/docs/modules/ui/workflow-visualization-replay/README.md b/docs/modules/ui/workflow-visualization-replay/README.md
index 7791d29c1..76197d570 100644
--- a/docs/modules/ui/workflow-visualization-replay/README.md
+++ b/docs/modules/ui/workflow-visualization-replay/README.md
@@ -10,6 +10,15 @@ Restore workflow graphing, time-travel, and replay as a run-detail capability un
This capability should help operators understand what happened in a release run, why a run failed or slowed, and how evidence or replay confirms the outcome.
+## Shipped Outcome
+
+- The canonical runtime workspace now ships under `/releases/runs/:runId/{summary|graph|timeline|critical-path|replay|evidence}`.
+- `Releases > Activity` and release detail flows deep-link into the mounted run workspace instead of the dead prototype route family.
+- `Evidence > Verify & Replay` can open the same run context, and replay controls can return operators into the canonical run workspace.
+- Legacy `workflow-visualization/:runId/*` URLs now redirect into the canonical run workspace.
+- Workflow editor preview reuse is bounded to graph preview mode and does not load runtime replay semantics.
+- Verification evidence is captured in `docs/features/checked/web/workflow-visualization-replay-ui.md`.
+
## Why This Is The Right Shape
- The abandoned visualizer models runtime concepts such as graph state, auto-refresh, step detail, critical path, and time travel.
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts
index 658ce62c9..ce98fe414 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts
@@ -34,6 +34,13 @@ import {
/
{{ runId() || 'run-unset' }}
+ @if (runId()) {
+
+
+ Open run replay workspace
+
+
+ }
}
@@ -281,6 +288,10 @@ import {
font-size: 0.875rem;
}
+ .replay-runtime-link {
+ margin-top: 0.75rem;
+ }
+
section {
margin-bottom: 2.5rem;
}
@@ -843,6 +854,19 @@ export class ReplayControlsComponent {
});
}
+ openRunReplayWorkspace(): void {
+ if (!this.runId()) {
+ return;
+ }
+
+ void this.router.navigate(['/releases/runs', this.runId(), 'replay'], {
+ queryParams: {
+ releaseId: this.releaseId(),
+ returnTo: this.buildReplayReturnTo(),
+ },
+ });
+ }
+
requestReplay(): void {
const newRequest: ReplayRequest = {
id: `rr-${Date.now()}`,
diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts
index 87aa7519e..ecd331c1c 100644
--- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts
@@ -645,14 +645,22 @@ export class ReleaseDetailComponent {
void this.router.navigate(['/security/reachability/witnesses'], { queryParams });
}
- replayRun(): void { void this.router.navigate(['/evidence/verify-replay'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
+ replayRun(): void {
+ void this.router.navigate(['/releases/runs', this.releaseId(), 'replay'], {
+ queryParams: { releaseId: this.releaseContextId() },
+ });
+ }
exportRunEvidence(): void { void this.router.navigate(['/evidence/capsules'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
openAgentLogs(target: string): void { void this.router.navigate(['/ops/operations/jobs-queues'], { queryParams: { releaseId: this.releaseContextId(), target } }); }
openTopology(target: string): void { void this.router.navigate(['/setup/topology/targets'], { queryParams: { releaseId: this.releaseContextId(), target } }); }
openGlobalFindings(): void { void this.router.navigate(['/security/triage'], { queryParams: { releaseId: this.releaseContextId() } }); }
exportSecurityEvidence(): void { void this.router.navigate(['/evidence/capsules'], { queryParams: { releaseId: this.releaseContextId(), scope: 'security' } }); }
openProofChain(): void { void this.router.navigate(['/evidence/proofs'], { queryParams: { releaseId: this.releaseContextId() } }); }
- openReplay(): void { void this.router.navigate(['/evidence/verify-replay'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
+ openReplay(): void {
+ void this.router.navigate(['/releases/runs', this.releaseId(), 'replay'], {
+ queryParams: { releaseId: this.releaseContextId() },
+ });
+ }
exportReleaseEvidence(): void { void this.router.navigate(['/evidence/exports'], { queryParams: { releaseId: this.releaseContextId(), scope: 'release' } }); }
openUnifiedAudit(): void { void this.router.navigate(['/evidence/audit-log'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component.ts
index a3c4d4b93..4b70adc0d 100644
--- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component.ts
@@ -25,6 +25,8 @@ import {
type WorkflowStepType,
type StepTypeDefinition,
} from '../../../../core/api/workflow.models';
+import { WorkflowVisualizerComponent } from '../../../workflow-visualization/components/workflow-visualizer/workflow-visualizer.component';
+import type { WorkflowGraph } from '../../../workflow-visualization/services/workflow-visualization.service';
interface CanvasState {
offsetX: number;
@@ -50,7 +52,7 @@ interface ConnectionState {
@Component({
selector: 'app-workflow-editor',
- imports: [RouterLink, FormsModule],
+ imports: [RouterLink, FormsModule, WorkflowVisualizerComponent],
template: `
@@ -106,6 +108,32 @@ interface ConnectionState {
}
+ @if (previewMode()) {
+
+
+
+ @if (previewGraph(); as graph) {
+
+ } @else {
+ Preparing graph preview...
+ }
+
+ }
+
@@ -857,6 +885,42 @@ interface ConnectionState {
opacity: 0.5;
cursor: not-allowed;
}
+
+ .preview-mode-panel {
+ display: grid;
+ gap: 0.75rem;
+ margin: 0 1.25rem 1rem;
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-primary);
+ padding: 1rem;
+ }
+
+ .preview-mode-panel__header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: flex-start;
+ }
+
+ .preview-mode-panel__header h2 {
+ margin: 0;
+ }
+
+ .preview-mode-panel__header p {
+ margin: 0.35rem 0 0;
+ color: var(--color-text-secondary);
+ font-size: 0.8rem;
+ line-height: 1.4;
+ }
+
+ .preview-mode-panel__loading {
+ border: 1px dashed var(--color-border-primary);
+ border-radius: var(--radius-md);
+ color: var(--color-text-secondary);
+ padding: 1rem;
+ text-align: center;
+ }
`]
})
export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit {
@@ -871,6 +935,7 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit
// View state
readonly showYamlView = signal(false);
+ readonly previewMode = signal(this.route.snapshot.queryParamMap?.get('view') === 'preview');
// Canvas state
readonly canvasState = signal({
@@ -916,6 +981,47 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit
return workflow ? workflowToYaml(workflow) : '';
});
+ readonly previewGraph = computed(() => {
+ const workflow = this.store.selectedWorkflow();
+ const steps = this.store.steps();
+ if (!workflow || steps.length === 0) {
+ return null;
+ }
+
+ return {
+ id: workflow.id,
+ name: workflow.name,
+ layoutAlgorithm: 'dagre',
+ nodes: steps.map((step) => ({
+ id: step.id,
+ label: step.name,
+ type: step.type === 'gate' ? 'gate' : step.type === 'approval' ? 'approval' : 'task',
+ status: 'pending',
+ metadata: {
+ description: step.description ?? '',
+ triggerEnvironments: workflow.triggerEnvironments,
+ },
+ })),
+ edges: steps.flatMap((step) =>
+ step.dependencies.map((dependency) => ({
+ id: `${dependency}-${step.id}`,
+ source: dependency,
+ target: step.id,
+ type: 'dependency' as const,
+ }))
+ ),
+ positions: steps.map((step) => ({
+ nodeId: step.id,
+ x: step.position.x + 160,
+ y: step.position.y + 120,
+ })),
+ metadata: {
+ workflowStatus: workflow.status,
+ preview: true,
+ },
+ };
+ });
+
readonly connections = computed(() => {
const steps = this.store.steps();
const connections: { id: string; path: string; from: string; to: string }[] = [];
diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts
index 959c75f7e..f0265a6a3 100644
--- a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts
@@ -126,7 +126,7 @@ interface PlatformListResponse {
@for (row of filteredRows(); track row.activityId) {
- {{ row.activityId }}
+ {{ row.activityId }}
{{ row.releaseName }}
{{ deriveLane(row) }}
{{ deriveOutcome(row) }}
diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/step-detail-panel/step-detail-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/step-detail-panel/step-detail-panel.component.ts
index 2515d9e19..0a70df0bb 100644
--- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/step-detail-panel/step-detail-panel.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/step-detail-panel/step-detail-panel.component.ts
@@ -382,6 +382,8 @@ import { WorkflowVisualizationService, StepDetails as ServiceStepDetails } from
export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
@Input() runId!: string;
@Input() stepId?: string;
+ @Input() stepData?: StepDetails | null;
+ @Input() logsData?: readonly LogEntry[] | null;
@Output() stepSelected = new EventEmitter();
@Output() retryRequested = new EventEmitter();
@@ -394,6 +396,7 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
// Logs state
logs: LogEntry[] = [];
+ private allLogs: LogEntry[] = [];
loadingLogs = false;
hasMoreLogs = false;
autoScroll = true;
@@ -402,6 +405,7 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
private readonly destroy$ = new Subject();
private readonly searchSubject = new Subject();
+ protected readonly Object = Object;
constructor(
private visualizationService: WorkflowVisualizationService,
@@ -424,12 +428,27 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
}
ngOnChanges(changes: SimpleChanges): void {
+ if (changes['stepData'] && this.stepData) {
+ this.stepDetails = this.stepData;
+ this.loading = false;
+ this.applyLocalLogs();
+ this.cdr.markForCheck();
+ return;
+ }
+
if (changes['stepId'] && this.stepId) {
this.loadStepDetails();
}
}
loadStepDetails(): void {
+ if (this.stepData) {
+ this.stepDetails = this.stepData;
+ this.loading = false;
+ this.applyLocalLogs();
+ return;
+ }
+
if (!this.stepId) return;
this.loading = true;
@@ -437,11 +456,12 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (details) => {
- this.stepDetails = details;
+ const normalized = this.normalizeStepDetails(details);
+ this.stepDetails = normalized;
this.loading = false;
// Auto-switch to logs tab if there are errors
- if (details.logSummary.errorCount > 0 && this.activeTab === 'overview') {
+ if (normalized.logSummary.errorCount > 0 && this.activeTab === 'overview') {
this.activeTab = 'logs';
}
@@ -457,6 +477,11 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
}
loadLogs(reset = false): void {
+ if (this.logsData) {
+ this.applyLocalLogs();
+ return;
+ }
+
if (!this.stepId) return;
if (reset) {
@@ -474,7 +499,8 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (result) => {
- this.logs = reset ? result.logs as any : [...this.logs, ...result.logs as any];
+ const normalizedLogs = this.normalizeServiceLogs(result.logs as readonly unknown[]);
+ this.logs = reset ? normalizedLogs : [...this.logs, ...normalizedLogs];
this.logPageToken = (result as any).nextPageToken;
this.hasMoreLogs = !!(result as any).nextPageToken;
this.loadingLogs = false;
@@ -498,10 +524,19 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
}
onLogFilterChange(): void {
+ if (this.logsData) {
+ this.applyLocalLogs();
+ return;
+ }
this.loadLogs(true);
}
onSearchChange(search: string): void {
+ if (this.logsData) {
+ this.logFilter.search = search;
+ this.applyLocalLogs();
+ return;
+ }
this.searchSubject.next(search);
}
@@ -575,6 +610,87 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
return new Date(timestamp).toLocaleTimeString();
}
+ private applyLocalLogs(): void {
+ this.allLogs = [...(this.logsData ?? [])];
+ const level = this.logFilter.level.trim().toLowerCase();
+ const search = this.logFilter.search.trim().toLowerCase();
+
+ this.logs = this.allLogs.filter((entry) => {
+ if (level && entry.level.toLowerCase() !== level) {
+ return false;
+ }
+ if (search && !entry.message.toLowerCase().includes(search)) {
+ return false;
+ }
+ return true;
+ });
+ this.hasMoreLogs = false;
+ this.loadingLogs = false;
+ }
+
+ private normalizeStepDetails(details: ServiceStepDetails): StepDetails {
+ const normalizedLogs = this.normalizeServiceLogs(details.logs as readonly unknown[] | undefined);
+
+ return {
+ runId: this.runId,
+ stepId: details.nodeId,
+ stepName: details.name,
+ stepType: details.description ?? details.name,
+ status: this.normalizeStatus(details.status),
+ inputs: (details.inputs as Record | null | undefined) ?? null,
+ outputs: (details.outputs as Record | null | undefined) ?? null,
+ inputSources: [],
+ outputConsumers: [],
+ timing: {
+ queuedAt: details.startTime ?? null,
+ startedAt: details.startTime ?? null,
+ completedAt: details.endTime ?? null,
+ queueTime: '00:00:00.000',
+ executionTime: this.formatExecutionDuration(details.duration),
+ },
+ dependencies: {
+ dependsOn: [],
+ blocks: [],
+ blockedBy: [],
+ },
+ logSummary: {
+ totalLines: normalizedLogs.length,
+ errorCount: details.error ? 1 : 0,
+ warningCount: normalizedLogs.filter((entry) => entry.level.toLowerCase() === 'warning').length,
+ },
+ error: details.error
+ ? {
+ message: details.error.message,
+ type: 'StepFailure',
+ isRetryable: true,
+ }
+ : null,
+ retryCount: 0,
+ };
+ }
+
+ private normalizeStatus(status: string): string {
+ const normalized = status.toLowerCase();
+ if (normalized.includes('fail') || normalized.includes('error')) return 'Failed';
+ if (normalized.includes('run') || normalized.includes('progress')) return 'Running';
+ if (normalized.includes('skip')) return 'Skipped';
+ if (normalized.includes('cancel')) return 'Cancelled';
+ if (normalized.includes('success') || normalized.includes('complete')) return 'Succeeded';
+ return 'Pending';
+ }
+
+ private formatExecutionDuration(duration?: number): string | null {
+ if (typeof duration !== 'number') {
+ return null;
+ }
+
+ const hours = Math.floor(duration / 3600000);
+ const minutes = Math.floor((duration % 3600000) / 60000);
+ const seconds = Math.floor((duration % 60000) / 1000);
+ const millis = duration % 1000;
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${millis.toString().padStart(3, '0')}`;
+ }
+
getTimeSegmentFlex(segment: 'queue' | 'execution'): number {
if (!this.stepDetails?.timing) return 0;
@@ -598,6 +714,47 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
}
return 0;
}
+
+ private normalizeServiceLogs(logs: readonly unknown[] | undefined): LogEntry[] {
+ return [...(logs ?? [])].map((entry, index) => {
+ if (typeof entry === 'string') {
+ return {
+ timestamp: new Date(Date.now() + index).toISOString(),
+ level: this.inferLogLevel(entry),
+ message: entry,
+ };
+ }
+
+ if (entry && typeof entry === 'object') {
+ const record = entry as Record;
+ return {
+ timestamp: typeof record['timestamp'] === 'string' ? record['timestamp'] : new Date(Date.now() + index).toISOString(),
+ level: typeof record['level'] === 'string' ? record['level'] : this.inferLogLevel(String(record['message'] ?? '')),
+ message: typeof record['message'] === 'string' ? record['message'] : JSON.stringify(record),
+ };
+ }
+
+ return {
+ timestamp: new Date(Date.now() + index).toISOString(),
+ level: 'info',
+ message: String(entry),
+ };
+ });
+ }
+
+ private inferLogLevel(message: string): string {
+ const normalized = message.toLowerCase();
+ if (normalized.includes('error') || normalized.includes('failed') || normalized.includes('exception')) {
+ return 'error';
+ }
+ if (normalized.includes('warn') || normalized.includes('degrad')) {
+ return 'warning';
+ }
+ if (normalized.includes('debug') || normalized.includes('trace')) {
+ return 'debug';
+ }
+ return 'info';
+ }
}
interface LogEntry {
diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/time-travel-controls/time-travel-controls.component.ts b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/time-travel-controls/time-travel-controls.component.ts
index cd341362a..7fb675deb 100644
--- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/time-travel-controls/time-travel-controls.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/time-travel-controls/time-travel-controls.component.ts
@@ -5,7 +5,7 @@
// Description: Controls for time-travel debugging with playback and timeline
// -----------------------------------------------------------------------------
-import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
+import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, SimpleChanges, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil, interval, timer } from 'rxjs';
@@ -182,9 +182,12 @@ import { TimeTravelService, DebugSession, SnapshotSummary, SnapshotState } from
styles: [``],
changeDetection: ChangeDetectionStrategy.OnPush
})
-export class TimeTravelControlsComponent implements OnInit, OnDestroy {
+export class TimeTravelControlsComponent implements OnInit, OnDestroy, OnChanges {
@Input() runId!: string;
@Input() sessionId?: string;
+ @Input() sessionData?: DebugSession | null;
+ @Input() snapshotsData?: readonly SnapshotSummary[] | null;
+ @Input() statesByIndex?: Readonly> | null;
@Output() snapshotChanged = new EventEmitter();
@Output() sessionCreated = new EventEmitter();
@@ -213,6 +216,7 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
private readonly destroy$ = new Subject();
private playbackInterval$ = new Subject();
+ private readonly keydownHandler = (event: KeyboardEvent) => this.handleKeydown(event);
constructor(
private timeTravelService: TimeTravelService,
@@ -220,21 +224,29 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
) {}
ngOnInit(): void {
- if (this.sessionId) {
+ if (this.hasPreloadedState()) {
+ this.hydrateFromInputs();
+ } else if (this.sessionId) {
this.loadSession(this.sessionId);
} else {
this.createSession();
}
// Keyboard shortcuts
- document.addEventListener('keydown', this.handleKeydown.bind(this));
+ document.addEventListener('keydown', this.keydownHandler);
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if ((changes['sessionData'] || changes['snapshotsData'] || changes['statesByIndex']) && this.hasPreloadedState()) {
+ this.hydrateFromInputs();
+ }
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.playbackInterval$.complete();
- document.removeEventListener('keydown', this.handleKeydown.bind(this));
+ document.removeEventListener('keydown', this.keydownHandler);
}
get progressPercentage(): number {
@@ -283,6 +295,11 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
}
loadSnapshots(): void {
+ if (this.hasPreloadedState()) {
+ this.hydrateFromInputs();
+ return;
+ }
+
if (!this.session) return;
this.timeTravelService.getSnapshots(this.session.sessionId)
@@ -331,6 +348,17 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
}
private navigateTo(index: number): void {
+ if (this.hasPreloadedState()) {
+ this.currentIndex = Math.max(0, Math.min(index, this.totalSnapshots - 1));
+ this.currentSnapshot = this.snapshots[this.currentIndex] ?? null;
+ this.currentState = this.statesByIndex?.[this.currentIndex] ?? null;
+ if (this.currentState) {
+ this.snapshotChanged.emit(this.currentState);
+ }
+ this.cdr.markForCheck();
+ return;
+ }
+
if (!this.session || this.loading) return;
this.loading = true;
@@ -338,7 +366,7 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (state) => {
- this.currentIndex = state.snapshotIndex;
+ this.currentIndex = state.snapshotIndex ?? index;
this.currentSnapshot = this.snapshots[this.currentIndex];
this.currentState = state;
this.snapshotChanged.emit(state);
@@ -520,4 +548,22 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
onShowDiffChange(): void {
// Could load diff data if not already loaded
}
+
+ private hasPreloadedState(): boolean {
+ return !!this.sessionData || !!this.snapshotsData?.length;
+ }
+
+ private hydrateFromInputs(): void {
+ this.session = this.sessionData ?? null;
+ this.snapshots = [...(this.snapshotsData ?? [])];
+ this.totalSnapshots = this.snapshots.length;
+ const requestedIndex = this.session?.currentSnapshotIndex ?? Math.max(0, this.totalSnapshots - 1);
+ this.currentIndex = Math.max(0, Math.min(requestedIndex, Math.max(0, this.totalSnapshots - 1)));
+ this.currentSnapshot = this.snapshots[this.currentIndex] ?? this.snapshots.at(-1) ?? null;
+ this.currentState = this.statesByIndex?.[this.currentIndex] ?? null;
+ this.startTime = this.snapshots[0] ? new Date(this.snapshots[0].timestamp) : null;
+ this.endTime = this.snapshots.at(-1) ? new Date(this.snapshots.at(-1)!.timestamp) : null;
+ this.loading = false;
+ this.cdr.markForCheck();
+ }
}
diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/workflow-visualizer/workflow-visualizer.component.ts b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/workflow-visualizer/workflow-visualizer.component.ts
index af6e88e83..73b5de2ad 100644
--- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/workflow-visualizer/workflow-visualizer.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/workflow-visualizer/workflow-visualizer.component.ts
@@ -5,7 +5,7 @@
// Description: React Flow based workflow DAG visualization component
// -----------------------------------------------------------------------------
-import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
+import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, SimpleChanges, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil, interval, switchMap, filter } from 'rxjs';
@@ -134,18 +134,18 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
@for (edge of edges; track edge.id) {
+ [class.animated]="edge.animated">
- @if (edge.isAnimated) {
+ @if (edge.animated) {
- @switch (node.status) {
+ @switch (displayStatus(node)) {
@case ('Running') {
}
@@ -220,7 +220,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
- @if (node.data?.['duration']) {
+ @if (node.duration) {
- {{ formatDuration(node.data?.['duration']) }}
+ {{ formatDuration(node.duration) }}
}
@@ -310,14 +310,16 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
styleUrls: ['./workflow-visualizer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
-export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
+export class WorkflowVisualizerComponent implements OnInit, OnDestroy, OnChanges {
@ViewChild('canvasContainer') canvasContainer!: ElementRef;
@ViewChild('svgCanvas') svgCanvas!: ElementRef;
@Input() runId!: string;
+ @Input() graph: WorkflowGraph | null = null;
@Input() showMinimap = true;
@Input() autoRefresh = true;
@Input() refreshInterval = 2000;
+ @Input() previewMode = false;
@Output() nodeSelected = new EventEmitter();
@Output() nodeDoubleClicked = new EventEmitter();
@@ -369,9 +371,14 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
constructor(private visualizationService: WorkflowVisualizationService) {}
ngOnInit(): void {
+ if (this.graph) {
+ this.setGraph(this.graph);
+ return;
+ }
+
this.loadGraph();
- if (this.autoRefresh) {
+ if (this.autoRefresh && !this.previewMode && !this.graph) {
interval(this.refreshInterval)
.pipe(
takeUntil(this.destroy$),
@@ -385,6 +392,12 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
}
}
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['graph'] && this.graph) {
+ this.setGraph(this.graph);
+ }
+ }
+
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
@@ -397,6 +410,11 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
}
loadGraph(): void {
+ if (this.graph) {
+ this.setGraph(this.graph);
+ return;
+ }
+
this.loading = true;
this.error = null;
@@ -404,12 +422,7 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (graph) => {
- this.nodes = graph.nodes;
- this.edges = graph.edges;
- this.positions.clear();
- graph.positions.forEach(p => this.positions.set(p.nodeId, p));
- this.loading = false;
- this.graphLoaded.emit(graph);
+ this.setGraph(graph);
},
error: (err) => {
this.error = err.message || 'Failed to load workflow graph';
@@ -487,6 +500,9 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
// Layout
onLayoutChange(): void {
+ if (this.graph) {
+ return;
+ }
this.loadGraph();
}
@@ -520,9 +536,44 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
}
retry(): void {
+ if (this.graph) {
+ return;
+ }
this.loadGraph();
}
+ private setGraph(graph: WorkflowGraph): void {
+ this.nodes = [...graph.nodes];
+ this.edges = [...graph.edges];
+ this.positions.clear();
+ graph.positions.forEach((position) => this.positions.set(position.nodeId, position));
+ this.loading = false;
+ this.error = null;
+ this.graphLoaded.emit(graph);
+ }
+
+ displayStatus(node: GraphNode): 'Pending' | 'Running' | 'Succeeded' | 'Failed' | 'Skipped' | 'Cancelled' {
+ switch (this.nodeStatus(node)) {
+ case 'running':
+ return 'Running';
+ case 'succeeded':
+ return 'Succeeded';
+ case 'failed':
+ return 'Failed';
+ case 'skipped':
+ return 'Skipped';
+ case 'cancelled':
+ return 'Cancelled';
+ case 'pending':
+ default:
+ return 'Pending';
+ }
+ }
+
+ nodeStatus(node: GraphNode): GraphNode['status'] {
+ return (node.status ?? 'pending').toLowerCase() as GraphNode['status'];
+ }
+
// Rendering helpers
getNodeTransform(node: GraphNode): string {
const pos = this.positions.get(node.id);
@@ -563,32 +614,33 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
getNodeFill(node: GraphNode): string {
const colors: Record = {
- 'Pending': 'var(--color-pending-bg)',
- 'Queued': 'var(--color-queued-bg)',
- 'Running': 'var(--color-running-bg)',
- 'Succeeded': 'var(--color-success-bg)',
- 'Failed': 'var(--color-error-bg)',
- 'Skipped': 'var(--color-skipped-bg)',
- 'Cancelled': 'var(--color-cancelled-bg)'
+ pending: 'var(--color-pending-bg)',
+ queued: 'var(--color-queued-bg)',
+ running: 'var(--color-running-bg)',
+ succeeded: 'var(--color-success-bg)',
+ failed: 'var(--color-error-bg)',
+ skipped: 'var(--color-skipped-bg)',
+ cancelled: 'var(--color-cancelled-bg)'
};
- return colors[node.status] || 'var(--color-default-bg)';
+ return colors[this.nodeStatus(node)] || 'var(--color-default-bg)';
}
getNodeStroke(node: GraphNode): string {
const colors: Record = {
- 'Pending': 'var(--color-pending-stroke)',
- 'Queued': 'var(--color-queued-stroke)',
- 'Running': 'var(--color-running)',
- 'Succeeded': 'var(--color-success)',
- 'Failed': 'var(--color-error)',
- 'Skipped': 'var(--color-skipped-stroke)',
- 'Cancelled': 'var(--color-cancelled-stroke)'
+ pending: 'var(--color-pending-stroke)',
+ queued: 'var(--color-queued-stroke)',
+ running: 'var(--color-running)',
+ succeeded: 'var(--color-success)',
+ failed: 'var(--color-error)',
+ skipped: 'var(--color-skipped-stroke)',
+ cancelled: 'var(--color-cancelled-stroke)'
};
- return colors[node.status] || 'var(--color-default-stroke)';
+ return colors[this.nodeStatus(node)] || 'var(--color-default-stroke)';
}
getNodeTextColor(node: GraphNode): string {
- return node.status === 'failed' || node.status === 'running'
+ const status = this.nodeStatus(node);
+ return status === 'failed' || status === 'running'
? 'var(--color-text-light)'
: 'var(--color-text)';
}
diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html
new file mode 100644
index 000000000..b736089f3
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html
@@ -0,0 +1,262 @@
+
+
+
+ @if (returnTo()) {
+
+ }
+
+
+ @for (tab of tabs; track tab.id) {
+
+ {{ tab.label }}
+
+ }
+
+
+ @if (loading()) {
+ Loading run graph and replay context...
+ } @else if (error()) {
+ {{ error() }}
+ } @else if (context(); as context) {
+
+
+ @switch (activeTab()) {
+ @case ('summary') {
+
+
+ Gate posture
+ {{ context.gateDecision?.verdict || context.detail.statusRow.gateStatus }}
+ {{ context.gateDecision?.blockers?.join(', ') || 'No blocking gate reasons reported.' }}
+ Open gate details
+
+
+
+ Approvals
+ {{ context.approvals?.checkpoints?.length || 0 }}
+
+ {{ context.detail.needsApproval ? 'This run still depends on approval checkpoints.' : 'No approval checkpoints are required for this run.' }}
+
+ Open approvals
+
+
+
+ Deployment targets
+ {{ context.deployments?.targets?.length || 0 }}
+ Track deployment-state detail in the legacy deployment workbench when needed.
+ Open deployments
+
+
+
+ Replay determinism
+ {{ context.replay?.verdict || context.evidence?.replayDeterminismVerdict || 'unknown' }}
+ Replay and evidence tabs use the same run-scoped context.
+ Open replay
+
+
+
+
+
+ Security inputs
+ Coverage: {{ context.securityInputs?.reachabilityCoveragePercent ?? 'n/a' }}%
+ Feed freshness: {{ context.securityInputs?.feedFreshnessStatus ?? 'unknown' }}
+ Open security inputs
+
+
+ Evidence posture
+ Signature: {{ context.evidence?.signatureStatus ?? 'unknown' }}
+ Mismatch: {{ context.evidence?.replayMismatch ? 'yes' : 'no' }}
+ Open evidence tab
+
+
+ Critical path
+ {{ context.criticalPath.path.join(' -> ') }}
+ Total runtime: {{ formatDuration(context.criticalPath.totalDuration) }}
+ Inspect critical path
+
+
+ }
+
+ @case ('graph') {
+
+
+
+ }
+
+ @case ('timeline') {
+
+
+
+
+ When
+ Phase
+ Status
+ Message
+
+
+
+ @for (row of context.timeline; track row.eventId) {
+
+ {{ formatWhen(row.occurredAt) }}
+ {{ row.phaseLabel }}
+ {{ row.status }}
+ {{ row.message }}
+
+ } @empty {
+ No timeline events were returned for this run.
+ }
+
+
+
+ }
+
+ @case ('critical-path') {
+
+
+
+
+ @for (node of criticalPathNodes(); track node.id) {
+
+ {{ node.label }}
+ Status: {{ node.status }}
+ Duration: {{ formatDuration(node.duration) }}
+
+ }
+
+
+ Open graph view
+
+ }
+
+ @case ('replay') {
+
+
+ Replay verdict
+ {{ context.replay?.verdict || 'unknown' }}
+ Run-scoped replay and debug snapshots stay attached to this release run.
+
+
+ Signature status
+ {{ context.evidence?.signatureStatus || 'unknown' }}
+ Mismatch: {{ context.evidence?.replayMismatch ? 'yes' : 'no' }}
+
+
+
+
+
+
+ }
+
+ @case ('evidence') {
+
+
+ Determinism
+ {{ context.evidence?.replayDeterminismVerdict || context.replay?.verdict || 'unknown' }}
+ Review signed replay posture and drift inside the run shell, then use the evidence page for exports.
+
+
+ Audit entries
+ {{ context.audit?.entries?.length || 0 }}
+ Run audit events stay linked to the same correlation key.
+
+
+
+
+
+
+
+ When
+ Actor
+ Action
+ Correlation
+
+
+
+ @for (entry of context.audit?.entries || []; track entry.auditId) {
+
+ {{ formatWhen(entry.occurredAt) }}
+ {{ entry.actorId }}
+ {{ entry.action }}
+ {{ entry.correlationKey }}
+
+ } @empty {
+ No audit entries are available for this run.
+ }
+
+
+
+
+
+ }
+ }
+
+
+ @if (selectedStep()) {
+
+ }
+
+ }
+
diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.scss b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.scss
new file mode 100644
index 000000000..3bfde573c
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.scss
@@ -0,0 +1,243 @@
+:host {
+ display: block;
+}
+
+.run-workspace {
+ display: grid;
+ gap: 1rem;
+}
+
+.run-workspace__header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: flex-start;
+}
+
+.run-workspace__header h1 {
+ margin: 0.35rem 0 0;
+ font-size: 1.6rem;
+}
+
+.run-workspace__subtitle {
+ margin: 0.35rem 0 0;
+ color: var(--color-text-secondary);
+}
+
+.run-workspace__back-link {
+ color: var(--color-status-info-text);
+ text-decoration: none;
+ font-weight: var(--font-weight-semibold);
+}
+
+.run-workspace__meta {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-full);
+ padding: 0.2rem 0.55rem;
+ font-size: 0.75rem;
+}
+
+.return-banner,
+.state-banner {
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-primary);
+ padding: 0.8rem 1rem;
+}
+
+.return-banner {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.state-banner--error {
+ border-color: var(--color-status-error-border);
+ background: var(--color-status-error-bg);
+ color: var(--color-status-error-text);
+}
+
+.run-tabs {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.run-tab {
+ border: 1px solid var(--color-border-primary);
+ background: var(--color-surface-primary);
+ color: var(--color-text-secondary);
+ border-radius: var(--radius-full);
+ padding: 0.45rem 0.8rem;
+ cursor: pointer;
+}
+
+.run-tab--active {
+ border-color: var(--color-brand-primary);
+ color: var(--color-brand-primary);
+}
+
+.run-workspace__layout {
+ display: grid;
+ gap: 1rem;
+}
+
+.run-workspace__layout--with-step {
+ grid-template-columns: minmax(0, 1fr) 360px;
+ align-items: start;
+}
+
+.summary-grid,
+.replay-grid,
+.evidence-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 0.9rem;
+}
+
+.summary-card,
+.detail-strip article,
+.critical-card,
+.timeline-table,
+.critical-path-panel,
+.audit-table,
+.step-drawer,
+.graph-toolbar,
+.replay-actions {
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-primary);
+}
+
+.summary-card,
+.detail-strip article,
+.critical-card,
+.critical-path-panel,
+.replay-actions,
+.step-drawer {
+ padding: 1rem;
+}
+
+.summary-value {
+ margin: 0.35rem 0;
+ font-size: 1.35rem;
+ font-weight: var(--font-weight-semibold);
+}
+
+.summary-help {
+ margin: 0 0 0.8rem;
+ color: var(--color-text-secondary);
+ line-height: 1.45;
+}
+
+.detail-strip {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 0.9rem;
+}
+
+.graph-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+}
+
+.filter-group {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.timeline-table table,
+.audit-table table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.timeline-table th,
+.timeline-table td,
+.audit-table th,
+.audit-table td {
+ padding: 0.7rem 0.85rem;
+ border-bottom: 1px solid var(--color-border-primary);
+ text-align: left;
+ vertical-align: top;
+}
+
+.timeline-table tbody tr,
+.critical-card {
+ cursor: pointer;
+}
+
+.timeline-table tbody tr:hover,
+.critical-card:hover {
+ background: color-mix(in srgb, var(--color-brand-primary) 6%, transparent);
+}
+
+.critical-path-panel header {
+ margin-bottom: 1rem;
+}
+
+.critical-path-cards {
+ display: grid;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.step-drawer {
+ position: sticky;
+ top: 1rem;
+}
+
+.step-drawer__header {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ align-items: center;
+ margin-bottom: 0.75rem;
+}
+
+.step-drawer__header h2 {
+ margin: 0;
+ font-size: 1rem;
+}
+
+.btn {
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface-primary);
+ color: var(--color-text-primary);
+ padding: 0.45rem 0.8rem;
+ cursor: pointer;
+}
+
+.btn--primary {
+ border-color: var(--color-brand-primary);
+ background: color-mix(in srgb, var(--color-brand-primary) 14%, var(--color-surface-primary));
+}
+
+.btn--secondary.is-active {
+ border-color: var(--color-brand-primary);
+ color: var(--color-brand-primary);
+}
+
+@media (max-width: 1100px) {
+ .run-workspace__layout--with-step {
+ grid-template-columns: 1fr;
+ }
+
+ .step-drawer {
+ position: static;
+ }
+}
diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts
new file mode 100644
index 000000000..f253b0511
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts
@@ -0,0 +1,262 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { ActivatedRoute, Router, RouterLink } from '@angular/router';
+
+import { ReplayControlsComponent } from '../evidence-export/replay-controls.component';
+import { StepDetailPanelComponent } from './components/step-detail-panel/step-detail-panel.component';
+import { TimeTravelControlsComponent } from './components/time-travel-controls/time-travel-controls.component';
+import { WorkflowVisualizerComponent } from './components/workflow-visualizer/workflow-visualizer.component';
+import {
+ RunVisualizationShellService,
+ type RunTimelineRow,
+ type RunVisualizationContext,
+ type RunWorkspaceTab,
+} from './services/run-visualization-shell.service';
+
+@Component({
+ selector: 'app-run-graph-replay-page',
+ standalone: true,
+ imports: [
+ CommonModule,
+ RouterLink,
+ WorkflowVisualizerComponent,
+ StepDetailPanelComponent,
+ TimeTravelControlsComponent,
+ ReplayControlsComponent,
+ ],
+ templateUrl: './run-graph-replay-page.component.html',
+ styleUrl: './run-graph-replay-page.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RunGraphReplayPageComponent {
+ private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
+ private readonly shellService = inject(RunVisualizationShellService);
+
+ readonly loading = signal(false);
+ readonly error = signal(null);
+ readonly runId = signal('');
+ readonly activeTab = signal('summary');
+ readonly selectedStepId = signal(null);
+ readonly context = signal(null);
+
+ readonly graphFilter = signal<'all' | 'failed' | 'active'>('all');
+ readonly criticalPathOnly = signal(false);
+ readonly returnTo = signal(null);
+
+ readonly tabs: readonly { id: RunWorkspaceTab; label: string }[] = [
+ { id: 'summary', label: 'Summary' },
+ { id: 'graph', label: 'Graph' },
+ { id: 'timeline', label: 'Timeline' },
+ { id: 'critical-path', label: 'Critical Path' },
+ { id: 'replay', label: 'Replay' },
+ { id: 'evidence', label: 'Evidence' },
+ ];
+
+ readonly filteredGraph = computed(() => {
+ const context = this.context();
+ if (!context) {
+ return null;
+ }
+
+ const allowedIds = new Set(context.graph.nodes.map((node) => node.id));
+
+ if (this.criticalPathOnly()) {
+ allowedIds.clear();
+ context.criticalPath.path.forEach((id) => allowedIds.add(id));
+ }
+
+ const filter = this.graphFilter();
+ if (filter !== 'all') {
+ for (const node of context.graph.nodes) {
+ const isActive = node.status === 'running' || node.status === 'pending';
+ const keep = filter === 'failed' ? node.status === 'failed' : isActive;
+ if (!keep) {
+ allowedIds.delete(node.id);
+ }
+ }
+ }
+
+ return {
+ ...context.graph,
+ nodes: context.graph.nodes.filter((node) => allowedIds.has(node.id)),
+ edges: context.graph.edges.filter((edge) => allowedIds.has(edge.source) && allowedIds.has(edge.target)),
+ positions: context.graph.positions.filter((position) => allowedIds.has(position.nodeId)),
+ };
+ });
+
+ readonly selectedStep = computed(() => {
+ const context = this.context();
+ const stepId = this.selectedStepId();
+ if (!context || !stepId) {
+ return null;
+ }
+
+ return context.stepDetails[stepId] ?? null;
+ });
+
+ readonly selectedStepLogs = computed(() => {
+ const context = this.context();
+ const stepId = this.selectedStepId();
+ if (!context || !stepId) {
+ return [];
+ }
+
+ return context.stepLogs[stepId] ?? [];
+ });
+
+ readonly criticalPathNodes = computed(() => {
+ const context = this.context();
+ if (!context) {
+ return [];
+ }
+
+ const byId = new Map(context.graph.nodes.map((node) => [node.id, node]));
+ return context.criticalPath.path
+ .map((id) => byId.get(id))
+ .filter((node): node is NonNullable => !!node);
+ });
+
+ constructor() {
+ this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
+ const nextRunId = params.get('runId') ?? '';
+ if (nextRunId && nextRunId !== this.runId()) {
+ this.runId.set(nextRunId);
+ this.load(nextRunId);
+ }
+ });
+
+ this.route.data.pipe(takeUntilDestroyed()).subscribe((data) => {
+ const tabFromData = typeof data['tab'] === 'string' ? data['tab'] : this.route.snapshot.paramMap.get('tab');
+ this.activeTab.set(this.normalizeTab(tabFromData));
+ });
+
+ this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((params) => {
+ this.returnTo.set(params.get('returnTo'));
+ this.selectedStepId.set(params.get('step'));
+ });
+ }
+
+ setTab(tab: RunWorkspaceTab): void {
+ void this.router.navigate(['/releases/runs', this.runId(), tab], {
+ queryParams: {
+ step: this.selectedStepId(),
+ returnTo: this.returnTo(),
+ },
+ queryParamsHandling: 'merge',
+ replaceUrl: true,
+ });
+ }
+
+ openStep(stepId: string): void {
+ this.selectedStepId.set(stepId);
+ void this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams: { step: stepId },
+ queryParamsHandling: 'merge',
+ replaceUrl: true,
+ });
+ }
+
+ closeStep(): void {
+ this.selectedStepId.set(null);
+ void this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams: { step: null },
+ queryParamsHandling: 'merge',
+ replaceUrl: true,
+ });
+ }
+
+ toggleCriticalPath(): void {
+ this.criticalPathOnly.update((value) => !value);
+ }
+
+ openLegacyRunTab(tab: 'gate-decision' | 'deployments' | 'security-inputs' | 'approvals'): void {
+ void this.router.navigate(['/releases/runs', this.runId(), tab]);
+ }
+
+ openEvidenceEntry(): void {
+ const context = this.context();
+ void this.router.navigate(['/evidence/verify-replay'], {
+ queryParams: {
+ releaseId: context?.detail.releaseId,
+ runId: this.runId(),
+ },
+ });
+ }
+
+ openReplayWorkspace(): void {
+ this.setTab('replay');
+ }
+
+ onTimelineRowSelected(row: RunTimelineRow): void {
+ this.openStep(row.phaseId);
+ }
+
+ onSnapshotChanged(state: { stepId?: string | null }): void {
+ if (typeof state.stepId === 'string' && state.stepId.length > 0) {
+ this.openStep(state.stepId);
+ }
+ }
+
+ formatWhen(value: string | null | undefined): string {
+ if (!value) {
+ return 'n/a';
+ }
+
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
+ }
+
+ formatDuration(durationMs: number | null | undefined): string {
+ if (!durationMs || durationMs <= 0) {
+ return '0s';
+ }
+
+ if (durationMs < 1000) {
+ return `${durationMs}ms`;
+ }
+
+ if (durationMs < 60000) {
+ return `${(durationMs / 1000).toFixed(1)}s`;
+ }
+
+ return `${Math.floor(durationMs / 60000)}m ${Math.floor((durationMs % 60000) / 1000)}s`;
+ }
+
+ private load(runId: string): void {
+ this.loading.set(true);
+ this.error.set(null);
+
+ this.shellService.loadContext(runId).pipe(takeUntilDestroyed()).subscribe({
+ next: (context) => {
+ this.context.set(context);
+ if (!this.selectedStepId() && context.criticalPath.path.length > 0 && this.activeTab() === 'critical-path') {
+ this.selectedStepId.set(context.criticalPath.path[0]);
+ }
+ this.loading.set(false);
+ },
+ error: (err: unknown) => {
+ this.error.set(err instanceof Error ? err.message : 'Failed to load run graph and replay context.');
+ this.context.set(null);
+ this.loading.set(false);
+ },
+ });
+ }
+
+ private normalizeTab(value: string | null): RunWorkspaceTab {
+ switch ((value ?? '').toLowerCase()) {
+ case 'graph':
+ case 'timeline':
+ case 'critical-path':
+ case 'replay':
+ case 'evidence':
+ return value!.toLowerCase() as RunWorkspaceTab;
+ case 'summary':
+ default:
+ return 'summary';
+ }
+ }
+}
diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/services/run-visualization-shell.service.ts b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/services/run-visualization-shell.service.ts
new file mode 100644
index 000000000..2c0a8de07
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/services/run-visualization-shell.service.ts
@@ -0,0 +1,723 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { catchError, forkJoin, map, of, type Observable } from 'rxjs';
+
+import type { DebugSession, SnapshotState, SnapshotSummary } from './time-travel.service';
+import {
+ WorkflowVisualizationService,
+ type CriticalPathResult,
+ type GraphNode,
+ type NodePosition,
+ type WorkflowGraph,
+} from './workflow-visualization.service';
+
+export type RunWorkspaceTab = 'summary' | 'graph' | 'timeline' | 'critical-path' | 'replay' | 'evidence';
+
+interface ReleaseRunDetailProjectionDto {
+ runId: string;
+ releaseId: string;
+ releaseName: string;
+ releaseSlug: string;
+ releaseType: string;
+ releaseVersionId: string;
+ releaseVersionNumber: number;
+ releaseVersionDigest: string;
+ lane: string;
+ status: string;
+ outcome: string;
+ targetEnvironment?: string | null;
+ targetRegion?: string | null;
+ scopeSummary: string;
+ requestedAt: string;
+ updatedAt: string;
+ needsApproval: boolean;
+ blockedByDataIntegrity: boolean;
+ correlationKey: string;
+ statusRow?: {
+ runStatus: string;
+ gateStatus: string;
+ approvalStatus: string;
+ dataTrustStatus: string;
+ };
+}
+
+interface ReleaseRunTimelineProjectionDto {
+ runId: string;
+ events: Array<{
+ eventId: string;
+ eventClass: string;
+ phase: string;
+ status: string;
+ occurredAt: string;
+ message: string;
+ }>;
+}
+
+interface ReleaseRunGateDecisionProjectionDto {
+ runId: string;
+ verdict: string;
+ blockers: string[];
+ riskBudgetDelta: number;
+}
+
+interface ReleaseRunApprovalsProjectionDto {
+ runId: string;
+ checkpoints: Array<{
+ checkpointId: string;
+ status: string;
+ approvedAt?: string | null;
+ }>;
+}
+
+interface ReleaseRunDeploymentsProjectionDto {
+ runId: string;
+ targets: Array<{
+ targetId: string;
+ targetName: string;
+ environment: string;
+ region: string;
+ status: string;
+ }>;
+}
+
+interface ReleaseRunSecurityInputsProjectionDto {
+ runId: string;
+ reachabilityCoveragePercent: number;
+ feedFreshnessStatus: string;
+ vexStatementsApplied: number;
+ exceptionsApplied: number;
+}
+
+interface ReleaseRunEvidenceProjectionDto {
+ runId: string;
+ replayDeterminismVerdict: string;
+ replayMismatch: boolean;
+ signatureStatus: string;
+}
+
+interface ReleaseRunReplayProjectionDto {
+ runId: string;
+ verdict: string;
+}
+
+interface ReleaseRunAuditProjectionDto {
+ runId: string;
+ entries: Array<{
+ auditId: string;
+ action: string;
+ actorId: string;
+ occurredAt: string;
+ correlationKey: string;
+ }>;
+}
+
+export interface RunTimelineRow {
+ readonly eventId: string;
+ readonly phaseId: string;
+ readonly phaseLabel: string;
+ readonly eventClass: string;
+ readonly status: string;
+ readonly occurredAt: string;
+ readonly message: string;
+}
+
+export interface RunStepLogEntry {
+ readonly timestamp: string;
+ readonly level: string;
+ readonly message: string;
+}
+
+export interface RunStepPanelData {
+ readonly runId: string;
+ readonly stepId: string;
+ readonly stepName: string;
+ readonly stepType: string;
+ readonly status: string;
+ readonly inputs: Record | null;
+ readonly outputs: Record | null;
+ readonly inputSources: Array<{ inputKey: string; sourceStepId: string; sourceOutputKey?: string }>;
+ readonly outputConsumers: Array<{ outputKey: string; consumerStepId: string }>;
+ readonly timing: {
+ queuedAt: string | null;
+ startedAt: string | null;
+ completedAt: string | null;
+ queueTime: string | null;
+ executionTime: string | null;
+ };
+ readonly dependencies: {
+ dependsOn: string[];
+ blocks: string[];
+ blockedBy: string[];
+ };
+ readonly logSummary: {
+ totalLines: number;
+ errorCount: number;
+ warningCount: number;
+ };
+ readonly error: {
+ message: string;
+ type: string;
+ isRetryable: boolean;
+ } | null;
+ readonly retryCount: number;
+}
+
+export interface RunVisualizationContext {
+ readonly detail: ReleaseRunDetailProjectionDto;
+ readonly timeline: readonly RunTimelineRow[];
+ readonly graph: WorkflowGraph;
+ readonly criticalPath: CriticalPathResult;
+ readonly securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
+ readonly gateDecision: ReleaseRunGateDecisionProjectionDto | null;
+ readonly approvals: ReleaseRunApprovalsProjectionDto | null;
+ readonly deployments: ReleaseRunDeploymentsProjectionDto | null;
+ readonly evidence: ReleaseRunEvidenceProjectionDto | null;
+ readonly replay: ReleaseRunReplayProjectionDto | null;
+ readonly audit: ReleaseRunAuditProjectionDto | null;
+ readonly stepDetails: Readonly>;
+ readonly stepLogs: Readonly>;
+ readonly debugSession: DebugSession;
+ readonly snapshots: readonly SnapshotSummary[];
+ readonly snapshotStates: Readonly>;
+}
+
+type PhaseId = 'ingest' | 'gate' | 'approval' | 'evidence' | 'deployment';
+
+interface PhaseAggregate {
+ readonly phaseId: PhaseId;
+ readonly label: string;
+ readonly type: GraphNode['type'];
+ readonly dependsOn: readonly PhaseId[];
+}
+
+const PHASES: readonly PhaseAggregate[] = [
+ { phaseId: 'ingest', label: 'Ingest & Scan', type: 'task', dependsOn: [] },
+ { phaseId: 'gate', label: 'Policy Gate', type: 'gate', dependsOn: ['ingest'] },
+ { phaseId: 'approval', label: 'Approval', type: 'approval', dependsOn: ['gate'] },
+ { phaseId: 'evidence', label: 'Evidence', type: 'task', dependsOn: ['approval'] },
+ { phaseId: 'deployment', label: 'Deployment', type: 'task', dependsOn: ['evidence'] },
+];
+
+@Injectable({ providedIn: 'root' })
+export class RunVisualizationShellService {
+ private readonly http = inject(HttpClient);
+ private readonly workflowVisualization = inject(WorkflowVisualizationService);
+
+ loadContext(runId: string): Observable {
+ const runBase = `/api/v2/releases/runs/${encodeURIComponent(runId)}`;
+
+ return forkJoin({
+ detail: this.http.get(runBase),
+ timeline: this.http.get(`${runBase}/timeline`).pipe(catchError(() => of(null))),
+ gateDecision: this.http.get(`${runBase}/gate-decision`).pipe(catchError(() => of(null))),
+ approvals: this.http.get(`${runBase}/approvals`).pipe(catchError(() => of(null))),
+ deployments: this.http.get(`${runBase}/deployments`).pipe(catchError(() => of(null))),
+ securityInputs: this.http.get(`${runBase}/security-inputs`).pipe(catchError(() => of(null))),
+ evidence: this.http.get(`${runBase}/evidence`).pipe(catchError(() => of(null))),
+ replay: this.http.get(`${runBase}/replay`).pipe(catchError(() => of(null))),
+ audit: this.http.get(`${runBase}/audit`).pipe(catchError(() => of(null))),
+ liveGraph: this.workflowVisualization.getGraph(runId).pipe(catchError(() => of(null))),
+ liveCriticalPath: this.workflowVisualization.getCriticalPath(runId).pipe(catchError(() => of(null))),
+ }).pipe(map((bundle) => this.toContext(runId, bundle)));
+ }
+
+ private toContext(
+ runId: string,
+ bundle: {
+ detail: ReleaseRunDetailProjectionDto;
+ timeline: ReleaseRunTimelineProjectionDto | null;
+ gateDecision: ReleaseRunGateDecisionProjectionDto | null;
+ approvals: ReleaseRunApprovalsProjectionDto | null;
+ deployments: ReleaseRunDeploymentsProjectionDto | null;
+ securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
+ evidence: ReleaseRunEvidenceProjectionDto | null;
+ replay: ReleaseRunReplayProjectionDto | null;
+ audit: ReleaseRunAuditProjectionDto | null;
+ liveGraph: WorkflowGraph | null;
+ liveCriticalPath: CriticalPathResult | null;
+ }
+ ): RunVisualizationContext {
+ const timeline = this.toTimelineRows(bundle.timeline);
+ const graph = bundle.liveGraph ?? this.buildSyntheticGraph(bundle.detail, timeline, bundle);
+ const criticalPath = bundle.liveCriticalPath ?? this.buildSyntheticCriticalPath(graph);
+ const stepLogs = this.buildStepLogs(timeline);
+ const stepDetails = this.buildStepDetails(bundle.detail, graph, stepLogs, bundle);
+ const snapshots = this.buildSnapshots(timeline, graph);
+ const snapshotStates = this.buildSnapshotStates(bundle.detail, graph, snapshots, stepDetails, stepLogs, bundle);
+ const debugSession = this.buildDebugSession(runId, bundle.detail, snapshots);
+
+ return {
+ detail: bundle.detail,
+ timeline,
+ graph,
+ criticalPath,
+ securityInputs: bundle.securityInputs,
+ gateDecision: bundle.gateDecision,
+ approvals: bundle.approvals,
+ deployments: bundle.deployments,
+ evidence: bundle.evidence,
+ replay: bundle.replay,
+ audit: bundle.audit,
+ stepDetails,
+ stepLogs,
+ debugSession,
+ snapshots,
+ snapshotStates,
+ };
+ }
+
+ private toTimelineRows(source: ReleaseRunTimelineProjectionDto | null): readonly RunTimelineRow[] {
+ return [...(source?.events ?? [])]
+ .sort((left, right) => left.occurredAt.localeCompare(right.occurredAt))
+ .map((event) => {
+ const phaseId = this.normalizePhase(event.phase, event.eventClass);
+ return {
+ eventId: event.eventId,
+ phaseId,
+ phaseLabel: this.phaseLabel(phaseId),
+ eventClass: event.eventClass,
+ status: event.status,
+ occurredAt: event.occurredAt,
+ message: event.message,
+ };
+ });
+ }
+
+ private buildSyntheticGraph(
+ detail: ReleaseRunDetailProjectionDto,
+ timeline: readonly RunTimelineRow[],
+ bundle: {
+ gateDecision: ReleaseRunGateDecisionProjectionDto | null;
+ approvals: ReleaseRunApprovalsProjectionDto | null;
+ deployments: ReleaseRunDeploymentsProjectionDto | null;
+ evidence: ReleaseRunEvidenceProjectionDto | null;
+ securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
+ }
+ ): WorkflowGraph {
+ const phaseSummaries = new Map();
+ for (const phase of PHASES) {
+ phaseSummaries.set(phase.phaseId, timeline.filter((event) => event.phaseId === phase.phaseId));
+ }
+
+ const nodes: GraphNode[] = PHASES.map((phase) => {
+ const events = phaseSummaries.get(phase.phaseId) ?? [];
+ const startTime = events[0]?.occurredAt ?? detail.requestedAt;
+ const endTime = events.at(-1)?.occurredAt ?? detail.updatedAt;
+ const duration = this.durationMs(startTime, endTime);
+ const status = this.resolvePhaseStatus(phase.phaseId, detail, bundle, events);
+
+ return {
+ id: phase.phaseId,
+ label: phase.label,
+ type: phase.type,
+ status,
+ startTime,
+ endTime,
+ duration,
+ metadata: {
+ blockers: bundle.gateDecision?.blockers ?? [],
+ approvalCount: bundle.approvals?.checkpoints.length ?? 0,
+ deploymentTargets: bundle.deployments?.targets.length ?? 0,
+ feedFreshnessStatus: bundle.securityInputs?.feedFreshnessStatus ?? 'unknown',
+ message: events.at(-1)?.message ?? detail.scopeSummary,
+ },
+ };
+ });
+
+ const edges = PHASES.flatMap((phase) =>
+ phase.dependsOn.map((dependency) => ({
+ id: `${dependency}-${phase.phaseId}`,
+ source: dependency,
+ target: phase.phaseId,
+ type: 'dependency' as const,
+ animated: nodes.find((node) => node.id === phase.phaseId)?.status === 'running',
+ }))
+ );
+
+ return {
+ id: detail.runId,
+ name: `${detail.releaseName} Run`,
+ nodes,
+ edges,
+ positions: this.syntheticPositions(nodes),
+ layoutAlgorithm: 'dagre',
+ metadata: {
+ runId: detail.runId,
+ releaseId: detail.releaseId,
+ },
+ };
+ }
+
+ private buildSyntheticCriticalPath(graph: WorkflowGraph): CriticalPathResult {
+ const path = graph.nodes
+ .filter((node) => node.status !== 'skipped')
+ .sort((left, right) => this.phaseOrder(left.id) - this.phaseOrder(right.id))
+ .map((node) => node.id);
+
+ const totalDuration = graph.nodes.reduce((total, node) => total + (node.duration ?? 0), 0);
+ return { path, totalDuration };
+ }
+
+ private syntheticPositions(nodes: readonly GraphNode[]): NodePosition[] {
+ return nodes.map((node, index) => ({
+ nodeId: node.id,
+ x: 180 + index * 230,
+ y: 220 + (index % 2 === 0 ? 0 : 60),
+ }));
+ }
+
+ private buildStepLogs(timeline: readonly RunTimelineRow[]): Readonly> {
+ const grouped: Record = {};
+ for (const row of timeline) {
+ grouped[row.phaseId] ??= [];
+ grouped[row.phaseId].push({
+ timestamp: row.occurredAt,
+ level: this.statusToLogLevel(row.status),
+ message: row.message,
+ });
+ }
+ return grouped;
+ }
+
+ private buildStepDetails(
+ detail: ReleaseRunDetailProjectionDto,
+ graph: WorkflowGraph,
+ stepLogs: Readonly>,
+ bundle: {
+ gateDecision: ReleaseRunGateDecisionProjectionDto | null;
+ approvals: ReleaseRunApprovalsProjectionDto | null;
+ deployments: ReleaseRunDeploymentsProjectionDto | null;
+ evidence: ReleaseRunEvidenceProjectionDto | null;
+ replay: ReleaseRunReplayProjectionDto | null;
+ securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
+ }
+ ): Readonly> {
+ const result: Record = {};
+
+ for (const node of graph.nodes) {
+ const logs = [...(stepLogs[node.id] ?? [])];
+ const dependsOn = graph.edges.filter((edge) => edge.target === node.id).map((edge) => edge.source);
+ const blocks = graph.edges.filter((edge) => edge.source === node.id).map((edge) => edge.target);
+
+ result[node.id] = {
+ runId: detail.runId,
+ stepId: node.id,
+ stepName: node.label,
+ stepType: node.type,
+ status: this.titleCaseStatus(node.status),
+ inputs: this.inputsForNode(node.id, detail, bundle),
+ outputs: this.outputsForNode(node.id, detail, bundle),
+ inputSources: dependsOn.map((sourceId) => ({ inputKey: `${sourceId}-result`, sourceStepId: sourceId })),
+ outputConsumers: blocks.map((targetId) => ({ outputKey: `${node.id}-result`, consumerStepId: targetId })),
+ timing: {
+ queuedAt: node.startTime ?? detail.requestedAt,
+ startedAt: node.startTime ?? null,
+ completedAt: node.endTime ?? null,
+ queueTime: node.startTime && detail.requestedAt ? this.toDurationString(this.durationMs(detail.requestedAt, node.startTime)) : '00:00:00.000',
+ executionTime: this.toDurationString(node.duration ?? 0),
+ },
+ dependencies: {
+ dependsOn,
+ blocks,
+ blockedBy: dependsOn,
+ },
+ logSummary: {
+ totalLines: logs.length,
+ errorCount: logs.filter((entry) => entry.level === 'error').length,
+ warningCount: logs.filter((entry) => entry.level === 'warning').length,
+ },
+ error: node.status === 'failed'
+ ? {
+ message: logs.at(-1)?.message ?? `${node.label} failed.`,
+ type: 'RunPhaseFailure',
+ isRetryable: node.id !== 'approval',
+ }
+ : null,
+ retryCount: 0,
+ };
+ }
+
+ return result;
+ }
+
+ private buildSnapshots(timeline: readonly RunTimelineRow[], graph: WorkflowGraph): readonly SnapshotSummary[] {
+ if (timeline.length > 0) {
+ return timeline.map((row, index) => ({
+ id: row.eventId,
+ index,
+ timestamp: row.occurredAt,
+ stepId: row.phaseId,
+ stepName: row.phaseLabel,
+ type: row.status.toLowerCase().includes('failed') ? 'error' : 'state-change',
+ eventType: `${row.phaseId}.${row.status}`.toLowerCase(),
+ description: row.message,
+ }));
+ }
+
+ return graph.nodes.map((node, index) => ({
+ id: `snapshot-${node.id}`,
+ index,
+ timestamp: node.endTime ?? node.startTime ?? new Date().toISOString(),
+ stepId: node.id,
+ stepName: node.label,
+ type: node.status === 'failed' ? 'error' : 'state-change',
+ eventType: `${node.id}.${node.status}`,
+ description: `${node.label} is ${node.status}.`,
+ }));
+ }
+
+ private buildSnapshotStates(
+ detail: ReleaseRunDetailProjectionDto,
+ graph: WorkflowGraph,
+ snapshots: readonly SnapshotSummary[],
+ stepDetails: Readonly>,
+ stepLogs: Readonly>,
+ bundle: {
+ evidence: ReleaseRunEvidenceProjectionDto | null;
+ replay: ReleaseRunReplayProjectionDto | null;
+ gateDecision: ReleaseRunGateDecisionProjectionDto | null;
+ securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
+ }
+ ): Readonly> {
+ const result: Record = {};
+
+ snapshots.forEach((snapshot, index) => {
+ const previous = snapshots[index - 1];
+ const node = graph.nodes.find((item) => item.id === snapshot.stepId);
+
+ result[index] = {
+ id: snapshot.id,
+ snapshotIndex: index,
+ timestamp: snapshot.timestamp,
+ stepId: snapshot.stepId,
+ stepName: snapshot.stepName,
+ status: node?.status ?? snapshot.type,
+ variables: {
+ releaseId: detail.releaseId,
+ correlationKey: detail.correlationKey,
+ feedFreshnessStatus: bundle.securityInputs?.feedFreshnessStatus ?? 'unknown',
+ },
+ inputs: stepDetails[snapshot.stepId]?.inputs ?? {},
+ outputs: stepDetails[snapshot.stepId]?.outputs ?? {},
+ logs: (stepLogs[snapshot.stepId] ?? []).map((entry) => `[${entry.level}] ${entry.message}`),
+ metadata: {
+ replayVerdict: bundle.replay?.verdict ?? 'unknown',
+ signatureStatus: bundle.evidence?.signatureStatus ?? 'unknown',
+ gateVerdict: bundle.gateDecision?.verdict ?? 'unknown',
+ },
+ diff: previous
+ ? {
+ previousSnapshot: previous.id,
+ previousStep: previous.stepId,
+ currentStep: snapshot.stepId,
+ }
+ : null,
+ };
+ });
+
+ return result;
+ }
+
+ private buildDebugSession(runId: string, detail: ReleaseRunDetailProjectionDto, snapshots: readonly SnapshotSummary[]): DebugSession {
+ const createdAt = snapshots[0]?.timestamp ?? detail.requestedAt;
+ return {
+ id: `debug-${runId}`,
+ sessionId: `debug-${runId}`,
+ workflowId: runId,
+ createdAt,
+ expiresAt: new Date(new Date(createdAt).getTime() + 2 * 60 * 60 * 1000).toISOString(),
+ status: 'active',
+ currentSnapshotIndex: Math.max(0, snapshots.length - 1),
+ totalSnapshots: snapshots.length,
+ };
+ }
+
+ private resolvePhaseStatus(
+ phaseId: PhaseId,
+ detail: ReleaseRunDetailProjectionDto,
+ bundle: {
+ gateDecision: ReleaseRunGateDecisionProjectionDto | null;
+ approvals: ReleaseRunApprovalsProjectionDto | null;
+ deployments: ReleaseRunDeploymentsProjectionDto | null;
+ evidence: ReleaseRunEvidenceProjectionDto | null;
+ },
+ events: readonly RunTimelineRow[],
+ ): GraphNode['status'] {
+ const eventStatus = this.normalizeStatus(events.at(-1)?.status ?? null);
+ if (eventStatus) {
+ return eventStatus;
+ }
+
+ switch (phaseId) {
+ case 'ingest':
+ if (detail.status.toLowerCase() === 'queued' || detail.status.toLowerCase() === 'pending') return 'pending';
+ if (detail.outcome.toLowerCase() === 'failed' && detail.status.toLowerCase().includes('scan')) return 'failed';
+ return 'succeeded';
+ case 'gate':
+ return this.mapGateStatus(bundle.gateDecision?.verdict ?? detail.statusRow?.gateStatus ?? detail.status);
+ case 'approval':
+ if (!detail.needsApproval) return 'skipped';
+ if ((bundle.approvals?.checkpoints ?? []).some((item) => item.status.toLowerCase() === 'pending')) return 'pending';
+ if ((bundle.approvals?.checkpoints ?? []).some((item) => item.status.toLowerCase() === 'rejected')) return 'failed';
+ return 'succeeded';
+ case 'evidence':
+ return this.mapEvidenceStatus(bundle.evidence?.signatureStatus, bundle.evidence?.replayMismatch ?? false);
+ case 'deployment':
+ if ((bundle.deployments?.targets ?? []).some((target) => target.status.toLowerCase().includes('running'))) return 'running';
+ if ((bundle.deployments?.targets ?? []).some((target) => target.status.toLowerCase().includes('failed'))) return 'failed';
+ if (detail.outcome.toLowerCase() === 'deployed') return 'succeeded';
+ if (detail.outcome.toLowerCase() === 'failed') return 'failed';
+ return 'pending';
+ default:
+ return 'pending';
+ }
+ }
+
+ private inputsForNode(
+ nodeId: string,
+ detail: ReleaseRunDetailProjectionDto,
+ bundle: {
+ gateDecision: ReleaseRunGateDecisionProjectionDto | null;
+ approvals: ReleaseRunApprovalsProjectionDto | null;
+ deployments: ReleaseRunDeploymentsProjectionDto | null;
+ securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
+ }
+ ): Record | null {
+ switch (nodeId) {
+ case 'ingest':
+ return { releaseId: detail.releaseId, scopeSummary: detail.scopeSummary };
+ case 'gate':
+ return {
+ verdict: bundle.gateDecision?.verdict ?? detail.statusRow?.gateStatus ?? detail.status,
+ blockers: bundle.gateDecision?.blockers ?? [],
+ riskBudgetDelta: bundle.gateDecision?.riskBudgetDelta ?? 0,
+ };
+ case 'approval':
+ return { needsApproval: detail.needsApproval, checkpoints: bundle.approvals?.checkpoints ?? [] };
+ case 'evidence':
+ return {
+ reachabilityCoveragePercent: bundle.securityInputs?.reachabilityCoveragePercent ?? null,
+ feedFreshnessStatus: bundle.securityInputs?.feedFreshnessStatus ?? 'unknown',
+ vexStatementsApplied: bundle.securityInputs?.vexStatementsApplied ?? 0,
+ };
+ case 'deployment':
+ return { targets: bundle.deployments?.targets ?? [] };
+ default:
+ return null;
+ }
+ }
+
+ private outputsForNode(
+ nodeId: string,
+ detail: ReleaseRunDetailProjectionDto,
+ bundle: {
+ evidence: ReleaseRunEvidenceProjectionDto | null;
+ replay: ReleaseRunReplayProjectionDto | null;
+ }
+ ): Record | null {
+ switch (nodeId) {
+ case 'gate':
+ return {
+ runStatus: detail.statusRow?.runStatus ?? detail.status,
+ approvalStatus: detail.statusRow?.approvalStatus ?? (detail.needsApproval ? 'pending' : 'approved'),
+ };
+ case 'evidence':
+ return {
+ signatureStatus: bundle.evidence?.signatureStatus ?? 'unknown',
+ determinismVerdict: bundle.evidence?.replayDeterminismVerdict ?? 'unknown',
+ replayMismatch: bundle.evidence?.replayMismatch ?? false,
+ };
+ case 'deployment':
+ return { outcome: detail.outcome, updatedAt: detail.updatedAt };
+ default:
+ return { replayVerdict: bundle.replay?.verdict ?? 'unknown' };
+ }
+ }
+
+ private mapGateStatus(value: string): GraphNode['status'] {
+ const normalized = value.toLowerCase();
+ if (normalized.includes('block') || normalized.includes('fail') || normalized.includes('deny')) return 'failed';
+ if (normalized.includes('pending') || normalized.includes('review')) return 'pending';
+ if (normalized.includes('warn')) return 'running';
+ return 'succeeded';
+ }
+
+ private mapEvidenceStatus(signatureStatus: string | undefined, mismatch: boolean): GraphNode['status'] {
+ const normalized = (signatureStatus ?? '').toLowerCase();
+ if (mismatch || normalized === 'invalid' || normalized === 'failed') return 'failed';
+ if (normalized === 'verified') return 'succeeded';
+ if (normalized === 'collecting' || normalized === 'pending') return 'running';
+ return 'pending';
+ }
+
+ private normalizePhase(rawPhase: string, rawEventClass: string): PhaseId {
+ const normalized = `${rawPhase} ${rawEventClass}`.toLowerCase();
+ if (normalized.includes('deploy')) return 'deployment';
+ if (normalized.includes('evidence') || normalized.includes('replay') || normalized.includes('verify')) return 'evidence';
+ if (normalized.includes('approval')) return 'approval';
+ if (normalized.includes('gate') || normalized.includes('policy')) return 'gate';
+ return 'ingest';
+ }
+
+ private normalizeStatus(value: string | null): GraphNode['status'] | null {
+ if (!value) return null;
+ const normalized = value.toLowerCase();
+ if (normalized.includes('fail') || normalized.includes('block') || normalized.includes('error')) return 'failed';
+ if (normalized.includes('run') || normalized.includes('progress') || normalized.includes('warn')) return 'running';
+ if (normalized.includes('skip')) return 'skipped';
+ if (normalized.includes('cancel')) return 'cancelled';
+ if (normalized.includes('pass') || normalized.includes('success') || normalized.includes('complete') || normalized.includes('approve')) return 'succeeded';
+ return 'pending';
+ }
+
+ private phaseLabel(phaseId: PhaseId): string {
+ return PHASES.find((phase) => phase.phaseId === phaseId)?.label ?? phaseId;
+ }
+
+ private phaseOrder(nodeId: string): number {
+ const index = PHASES.findIndex((phase) => phase.phaseId === nodeId);
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
+ }
+
+ private durationMs(startAt: string | null | undefined, endAt: string | null | undefined): number {
+ if (!startAt || !endAt) return 0;
+ const start = new Date(startAt).getTime();
+ const end = new Date(endAt).getTime();
+ if (Number.isNaN(start) || Number.isNaN(end)) return 0;
+ return Math.max(0, end - start);
+ }
+
+ private toDurationString(durationMs: number): string {
+ const totalSeconds = Math.floor(durationMs / 1000);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+ const millis = durationMs % 1000;
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${millis.toString().padStart(3, '0')}`;
+ }
+
+ private titleCaseStatus(status: GraphNode['status']): string {
+ switch (status) {
+ case 'running':
+ return 'Running';
+ case 'succeeded':
+ return 'Succeeded';
+ case 'failed':
+ return 'Failed';
+ case 'skipped':
+ return 'Skipped';
+ case 'cancelled':
+ return 'Cancelled';
+ case 'pending':
+ default:
+ return 'Pending';
+ }
+ }
+
+ private statusToLogLevel(status: string): string {
+ const normalized = status.toLowerCase();
+ if (normalized.includes('fail') || normalized.includes('block') || normalized.includes('error')) return 'error';
+ if (normalized.includes('warn') || normalized.includes('pending')) return 'warning';
+ return 'info';
+ }
+}
diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/services/time-travel.service.ts b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/services/time-travel.service.ts
index fad39ecd4..89efde730 100644
--- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/services/time-travel.service.ts
+++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/services/time-travel.service.ts
@@ -28,6 +28,7 @@ export interface SnapshotSummary {
export interface SnapshotState {
id: string;
+ snapshotIndex?: number;
timestamp: string;
stepId: string;
stepName: string;
@@ -37,6 +38,7 @@ export interface SnapshotState {
outputs: Record;
logs: string[];
metadata: Record;
+ diff?: Record | null;
}
@Injectable({
diff --git a/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts b/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts
index 3ab662657..7f2610495 100644
--- a/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts
+++ b/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts
@@ -28,6 +28,16 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTempla
redirectTo: '/security/findings/:findingId',
pathMatch: 'full',
},
+ {
+ path: 'workflow-visualization/:runId',
+ redirectTo: '/releases/runs/:runId/graph',
+ pathMatch: 'full',
+ },
+ {
+ path: 'workflow-visualization/:runId/:tab',
+ redirectTo: '/releases/runs/:runId/:tab',
+ pathMatch: 'full',
+ },
];
export const LEGACY_REDIRECT_ROUTES: Routes = LEGACY_REDIRECT_ROUTE_TEMPLATES.map((template) => ({
diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts
index 2530f689f..0c53f127f 100644
--- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts
+++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts
@@ -1,4 +1,16 @@
-import { Routes } from '@angular/router';
+import { inject } from '@angular/core';
+import { Router, Routes } from '@angular/router';
+
+const RUN_WORKSPACE_TABS = ['summary', 'graph', 'timeline', 'critical-path', 'replay', 'evidence'] as const;
+const LEGACY_RUN_TABS = ['overview', 'gate-decision', 'deployments', 'security-inputs', 'approvals', 'rollback'] as const;
+
+function redirectRunTab(runId: string, tab: string, queryParams: Record, fragment?: string | null) {
+ const router = inject(Router);
+ const target = router.parseUrl(`/releases/runs/${encodeURIComponent(runId)}/${tab}`);
+ target.queryParams = { ...queryParams };
+ target.fragment = fragment ?? null;
+ return target;
+}
export const RELEASES_ROUTES: Routes = [
{
@@ -60,19 +72,35 @@ export const RELEASES_ROUTES: Routes = [
path: 'runs/:runId',
title: 'Release Run Detail',
data: { breadcrumb: 'Release Run', semanticObject: 'run' },
+ pathMatch: 'full',
+ redirectTo: ({ params, queryParams, fragment }) =>
+ redirectRunTab(params['runId'] ?? '', 'summary', queryParams as Record, fragment),
+ },
+ ...RUN_WORKSPACE_TABS.map((tab) => ({
+ path: `runs/:runId/${tab}`,
+ title: 'Release Run Detail',
+ data: { breadcrumb: 'Release Run', semanticObject: 'run', tab },
+ loadComponent: () =>
+ import('../features/workflow-visualization/run-graph-replay-page.component').then(
+ (m) => m.RunGraphReplayPageComponent,
+ ),
+ })),
+ ...LEGACY_RUN_TABS.map((tab) => ({
+ path: `runs/:runId/${tab}`,
+ title: 'Release Run Detail',
+ data: { breadcrumb: 'Release Run', semanticObject: 'run', legacyTab: tab },
loadComponent: () =>
import('../features/release-orchestrator/releases/release-detail/release-detail.component').then(
(m) => m.ReleaseDetailComponent,
),
- },
+ })),
{
path: 'runs/:runId/:tab',
title: 'Release Run Detail',
data: { breadcrumb: 'Release Run', semanticObject: 'run' },
- loadComponent: () =>
- import('../features/release-orchestrator/releases/release-detail/release-detail.component').then(
- (m) => m.ReleaseDetailComponent,
- ),
+ pathMatch: 'full',
+ redirectTo: ({ params, queryParams, fragment }) =>
+ redirectRunTab(params['runId'] ?? '', 'summary', queryParams as Record, fragment),
},
{
path: 'approvals',
diff --git a/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts b/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts
index bdc0636c7..589203625 100644
--- a/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts
+++ b/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts
@@ -20,7 +20,17 @@ describe('RELEASES_ROUTES (pre-alpha)', () => {
'versions/:versionId/:tab',
'runs',
'runs/:runId',
+ 'runs/:runId/summary',
+ 'runs/:runId/graph',
+ 'runs/:runId/timeline',
+ 'runs/:runId/critical-path',
+ 'runs/:runId/replay',
+ 'runs/:runId/evidence',
'runs/:runId/:tab',
+ 'runs/:runId/gate-decision',
+ 'runs/:runId/deployments',
+ 'runs/:runId/security-inputs',
+ 'runs/:runId/approvals',
'approvals',
'promotion-queue',
'hotfixes',
@@ -36,10 +46,9 @@ describe('RELEASES_ROUTES (pre-alpha)', () => {
}
});
- it('has no redirects', () => {
- for (const route of RELEASES_ROUTES) {
- expect(route.redirectTo).toBeUndefined();
- }
+ it('uses redirects only for canonical run-shell entry points', () => {
+ const redirectPaths = RELEASES_ROUTES.filter((route) => route.redirectTo).map((route) => route.path);
+ expect(redirectPaths).toEqual(['', 'runs/:runId', 'runs/:runId/:tab']);
});
});
diff --git a/src/Web/StellaOps.Web/src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts b/src/Web/StellaOps.Web/src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts
index 08d116e2e..db5b96af9 100644
--- a/src/Web/StellaOps.Web/src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts
+++ b/src/Web/StellaOps.Web/src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts
@@ -6,7 +6,15 @@ import { STEP_TYPES } from '../../app/core/api/workflow.models';
import { WorkflowEditorComponent } from '../../app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component';
describe('visual-workflow-editor behavior', () => {
+ const routeState = {
+ workflowId: 'wf-001',
+ query: {} as Record,
+ };
+
beforeEach(async () => {
+ routeState.workflowId = 'wf-001';
+ routeState.query = {};
+
await TestBed.configureTestingModule({
imports: [WorkflowEditorComponent],
providers: [
@@ -15,7 +23,12 @@ describe('visual-workflow-editor behavior', () => {
provide: ActivatedRoute,
useValue: {
snapshot: {
- paramMap: convertToParamMap({ id: 'wf-001' }),
+ get paramMap() {
+ return convertToParamMap({ id: routeState.workflowId });
+ },
+ get queryParamMap() {
+ return convertToParamMap(routeState.query);
+ },
},
},
},
@@ -27,7 +40,8 @@ describe('visual-workflow-editor behavior', () => {
}).compileComponents();
});
- async function createEditor(): Promise> {
+ async function createEditor(query: Record = {}): Promise> {
+ routeState.query = query;
const fixture: ComponentFixture =
TestBed.createComponent(WorkflowEditorComponent);
fixture.detectChanges();
@@ -64,6 +78,16 @@ describe('visual-workflow-editor behavior', () => {
expect(yamlEditor.value).toContain('steps:');
});
+ it('renders the bounded preview visualizer when opened in preview mode', async () => {
+ const fixture = await createEditor({ view: 'preview' });
+ const text = fixture.nativeElement.textContent as string;
+
+ expect(fixture.nativeElement.querySelector('[data-testid="workflow-preview-mode"]')).toBeTruthy();
+ expect(text).toContain('Preview Graph');
+ expect(text).toContain('Return to editor');
+ expect(text).not.toContain('Debug Session');
+ });
+
it('rejects invalid dependency edits for self-links and cycle creation', async () => {
const fixture = await createEditor();
const component = fixture.componentInstance;
diff --git a/src/Web/StellaOps.Web/src/tests/workflow_visualization/run-graph-replay-page.behavior.spec.ts b/src/Web/StellaOps.Web/src/tests/workflow_visualization/run-graph-replay-page.behavior.spec.ts
new file mode 100644
index 000000000..cfb075cb6
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/tests/workflow_visualization/run-graph-replay-page.behavior.spec.ts
@@ -0,0 +1,419 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { RunGraphReplayPageComponent } from '../../app/features/workflow-visualization/run-graph-replay-page.component';
+import {
+ RunVisualizationShellService,
+ type RunVisualizationContext,
+} from '../../app/features/workflow-visualization/services/run-visualization-shell.service';
+
+describe('RunGraphReplayPageComponent', () => {
+ let fixture: ComponentFixture;
+ let component: RunGraphReplayPageComponent;
+ let paramMap$: BehaviorSubject>;
+ let queryParamMap$: BehaviorSubject>;
+ let routeData$: BehaviorSubject>;
+ let loadContextSpy: jasmine.Spy;
+ let activatedRouteStub: {
+ paramMap: ReturnType>['asObservable']>;
+ queryParamMap: ReturnType>['asObservable']>;
+ data: ReturnType>['asObservable']>;
+ snapshot: {
+ readonly paramMap: ReturnType;
+ readonly queryParamMap: ReturnType;
+ };
+ };
+
+ const mockContext: RunVisualizationContext = {
+ detail: {
+ runId: 'run-demo',
+ releaseId: 'rel-demo',
+ releaseName: 'Billing API',
+ releaseSlug: 'billing-api',
+ releaseType: 'standard',
+ releaseVersionId: 'ver-demo',
+ releaseVersionNumber: 7,
+ releaseVersionDigest: 'sha256:release-demo',
+ lane: 'standard',
+ status: 'running',
+ outcome: 'in_progress',
+ targetEnvironment: 'prod',
+ targetRegion: 'eu-west',
+ scopeSummary: 'stage->prod',
+ requestedAt: '2026-03-07T10:00:00Z',
+ updatedAt: '2026-03-07T10:22:00Z',
+ needsApproval: true,
+ blockedByDataIntegrity: false,
+ correlationKey: 'corr-demo',
+ statusRow: {
+ runStatus: 'running',
+ gateStatus: 'warn',
+ approvalStatus: 'pending',
+ dataTrustStatus: 'healthy',
+ },
+ },
+ timeline: [
+ {
+ eventId: 'evt-ingest',
+ phaseId: 'ingest',
+ phaseLabel: 'Ingest & Scan',
+ eventClass: 'scan',
+ status: 'completed',
+ occurredAt: '2026-03-07T10:02:00Z',
+ message: 'Ingest completed successfully.',
+ },
+ {
+ eventId: 'evt-gate',
+ phaseId: 'gate',
+ phaseLabel: 'Policy Gate',
+ eventClass: 'gate',
+ status: 'warn',
+ occurredAt: '2026-03-07T10:07:00Z',
+ message: 'Gate requires operator review.',
+ },
+ {
+ eventId: 'evt-approval',
+ phaseId: 'approval',
+ phaseLabel: 'Approval',
+ eventClass: 'approval',
+ status: 'pending',
+ occurredAt: '2026-03-07T10:11:00Z',
+ message: 'Awaiting manual approval.',
+ },
+ ],
+ graph: {
+ id: 'run-demo',
+ name: 'Billing API Run',
+ layoutAlgorithm: 'dagre',
+ nodes: [
+ { id: 'ingest', label: 'Ingest & Scan', type: 'task', status: 'succeeded', duration: 180000 },
+ { id: 'gate', label: 'Policy Gate', type: 'gate', status: 'running', duration: 120000 },
+ { id: 'approval', label: 'Approval', type: 'approval', status: 'pending', duration: 0 },
+ ],
+ edges: [
+ { id: 'ingest-gate', source: 'ingest', target: 'gate', type: 'dependency' },
+ { id: 'gate-approval', source: 'gate', target: 'approval', type: 'dependency' },
+ ],
+ positions: [
+ { nodeId: 'ingest', x: 100, y: 100 },
+ { nodeId: 'gate', x: 300, y: 100 },
+ { nodeId: 'approval', x: 500, y: 100 },
+ ],
+ metadata: {
+ runId: 'run-demo',
+ releaseId: 'rel-demo',
+ },
+ },
+ criticalPath: {
+ path: ['ingest', 'gate', 'approval'],
+ totalDuration: 300000,
+ },
+ securityInputs: {
+ runId: 'run-demo',
+ reachabilityCoveragePercent: 92,
+ feedFreshnessStatus: 'healthy',
+ vexStatementsApplied: 3,
+ exceptionsApplied: 1,
+ },
+ gateDecision: {
+ runId: 'run-demo',
+ verdict: 'warn',
+ blockers: ['manual-review'],
+ riskBudgetDelta: 21,
+ },
+ approvals: {
+ runId: 'run-demo',
+ checkpoints: [{ checkpointId: 'ap-001', status: 'pending', approvedAt: null }],
+ },
+ deployments: {
+ runId: 'run-demo',
+ targets: [
+ {
+ targetId: 'target-1',
+ targetName: 'billing-api-prod',
+ environment: 'prod',
+ region: 'eu-west',
+ status: 'queued',
+ },
+ ],
+ },
+ evidence: {
+ runId: 'run-demo',
+ replayDeterminismVerdict: 'match',
+ replayMismatch: false,
+ signatureStatus: 'verified',
+ },
+ replay: {
+ runId: 'run-demo',
+ verdict: 'match',
+ },
+ audit: {
+ runId: 'run-demo',
+ entries: [
+ {
+ auditId: 'audit-001',
+ action: 'release.run.updated',
+ actorId: 'ops@example.com',
+ occurredAt: '2026-03-07T10:12:00Z',
+ correlationKey: 'corr-demo',
+ },
+ ],
+ },
+ stepDetails: {
+ ingest: {
+ runId: 'run-demo',
+ stepId: 'ingest',
+ stepName: 'Ingest & Scan',
+ stepType: 'task',
+ status: 'Succeeded',
+ inputs: { releaseId: 'rel-demo' },
+ outputs: { sbomDigest: 'sha256:sbom-demo' },
+ inputSources: [],
+ outputConsumers: [{ outputKey: 'ingest-result', consumerStepId: 'gate' }],
+ timing: {
+ queuedAt: '2026-03-07T10:00:00Z',
+ startedAt: '2026-03-07T10:00:00Z',
+ completedAt: '2026-03-07T10:02:00Z',
+ queueTime: '00:00:00.000',
+ executionTime: '00:02:00.000',
+ },
+ dependencies: { dependsOn: [], blocks: ['gate'], blockedBy: [] },
+ logSummary: { totalLines: 1, errorCount: 0, warningCount: 0 },
+ error: null,
+ retryCount: 0,
+ },
+ gate: {
+ runId: 'run-demo',
+ stepId: 'gate',
+ stepName: 'Policy Gate',
+ stepType: 'gate',
+ status: 'Running',
+ inputs: { blockers: ['manual-review'] },
+ outputs: { verdict: 'warn' },
+ inputSources: [{ inputKey: 'ingest-result', sourceStepId: 'ingest' }],
+ outputConsumers: [{ outputKey: 'gate-result', consumerStepId: 'approval' }],
+ timing: {
+ queuedAt: '2026-03-07T10:02:00Z',
+ startedAt: '2026-03-07T10:03:00Z',
+ completedAt: null,
+ queueTime: '00:01:00.000',
+ executionTime: '00:04:00.000',
+ },
+ dependencies: { dependsOn: ['ingest'], blocks: ['approval'], blockedBy: ['ingest'] },
+ logSummary: { totalLines: 2, errorCount: 0, warningCount: 1 },
+ error: null,
+ retryCount: 0,
+ },
+ approval: {
+ runId: 'run-demo',
+ stepId: 'approval',
+ stepName: 'Approval',
+ stepType: 'approval',
+ status: 'Pending',
+ inputs: { needsApproval: true },
+ outputs: null,
+ inputSources: [{ inputKey: 'gate-result', sourceStepId: 'gate' }],
+ outputConsumers: [],
+ timing: {
+ queuedAt: '2026-03-07T10:07:00Z',
+ startedAt: null,
+ completedAt: null,
+ queueTime: '00:04:00.000',
+ executionTime: '00:00:00.000',
+ },
+ dependencies: { dependsOn: ['gate'], blocks: [], blockedBy: ['gate'] },
+ logSummary: { totalLines: 1, errorCount: 0, warningCount: 0 },
+ error: null,
+ retryCount: 0,
+ },
+ },
+ stepLogs: {
+ ingest: [{ timestamp: '2026-03-07T10:02:00Z', level: 'info', message: 'Ingest completed successfully.' }],
+ gate: [
+ { timestamp: '2026-03-07T10:06:00Z', level: 'warning', message: 'Risk budget threshold requires review.' },
+ { timestamp: '2026-03-07T10:07:00Z', level: 'info', message: 'Operator review requested.' },
+ ],
+ approval: [{ timestamp: '2026-03-07T10:11:00Z', level: 'info', message: 'Awaiting manual approval.' }],
+ },
+ debugSession: {
+ id: 'debug-run-demo',
+ sessionId: 'debug-run-demo',
+ workflowId: 'run-demo',
+ createdAt: '2026-03-07T10:02:00Z',
+ expiresAt: '2026-03-07T12:02:00Z',
+ status: 'active',
+ currentSnapshotIndex: 2,
+ totalSnapshots: 3,
+ },
+ snapshots: [
+ {
+ id: 'snapshot-1',
+ index: 0,
+ timestamp: '2026-03-07T10:02:00Z',
+ stepId: 'ingest',
+ stepName: 'Ingest & Scan',
+ type: 'state-change',
+ eventType: 'ingest.completed',
+ description: 'Ingest completed successfully.',
+ },
+ {
+ id: 'snapshot-2',
+ index: 1,
+ timestamp: '2026-03-07T10:07:00Z',
+ stepId: 'gate',
+ stepName: 'Policy Gate',
+ type: 'state-change',
+ eventType: 'gate.warn',
+ description: 'Gate requires operator review.',
+ },
+ {
+ id: 'snapshot-3',
+ index: 2,
+ timestamp: '2026-03-07T10:11:00Z',
+ stepId: 'approval',
+ stepName: 'Approval',
+ type: 'state-change',
+ eventType: 'approval.pending',
+ description: 'Awaiting manual approval.',
+ },
+ ],
+ snapshotStates: {
+ 0: {
+ id: 'snapshot-1',
+ snapshotIndex: 0,
+ timestamp: '2026-03-07T10:02:00Z',
+ stepId: 'ingest',
+ stepName: 'Ingest & Scan',
+ status: 'succeeded',
+ variables: {},
+ inputs: { releaseId: 'rel-demo' },
+ outputs: { sbomDigest: 'sha256:sbom-demo' },
+ logs: ['[info] Ingest completed successfully.'],
+ metadata: {},
+ diff: null,
+ },
+ 1: {
+ id: 'snapshot-2',
+ snapshotIndex: 1,
+ timestamp: '2026-03-07T10:07:00Z',
+ stepId: 'gate',
+ stepName: 'Policy Gate',
+ status: 'running',
+ variables: {},
+ inputs: { blockers: ['manual-review'] },
+ outputs: { verdict: 'warn' },
+ logs: ['[warning] Risk budget threshold requires review.'],
+ metadata: {},
+ diff: { previousStep: 'ingest', currentStep: 'gate' },
+ },
+ 2: {
+ id: 'snapshot-3',
+ snapshotIndex: 2,
+ timestamp: '2026-03-07T10:11:00Z',
+ stepId: 'approval',
+ stepName: 'Approval',
+ status: 'pending',
+ variables: {},
+ inputs: { needsApproval: true },
+ outputs: {},
+ logs: ['[info] Awaiting manual approval.'],
+ metadata: {},
+ diff: { previousStep: 'gate', currentStep: 'approval' },
+ },
+ },
+ };
+
+ beforeEach(async () => {
+ paramMap$ = new BehaviorSubject(convertToParamMap({ runId: 'run-demo', tab: 'graph' }));
+ queryParamMap$ = new BehaviorSubject(
+ convertToParamMap({ step: 'gate', returnTo: '/evidence/verify-replay?runId=run-demo' }),
+ );
+ routeData$ = new BehaviorSubject>({ tab: 'graph' });
+
+ loadContextSpy = jasmine.createSpy('loadContext').and.returnValue(of(mockContext));
+
+ activatedRouteStub = {
+ paramMap: paramMap$.asObservable(),
+ queryParamMap: queryParamMap$.asObservable(),
+ data: routeData$.asObservable(),
+ snapshot: {
+ get paramMap() {
+ return paramMap$.value;
+ },
+ get queryParamMap() {
+ return queryParamMap$.value;
+ },
+ },
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [RunGraphReplayPageComponent],
+ providers: [
+ provideRouter([]),
+ { provide: ActivatedRoute, useValue: activatedRouteStub },
+ { provide: RunVisualizationShellService, useValue: { loadContext: loadContextSpy } },
+ ],
+ }).compileComponents();
+ });
+
+ function createComponent(): void {
+ fixture = TestBed.createComponent(RunGraphReplayPageComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ }
+
+ it('loads the run context and restores the step drawer from deep-link state', () => {
+ createComponent();
+
+ expect(loadContextSpy).toHaveBeenCalledWith('run-demo');
+ expect(component.activeTab()).toBe('graph');
+ expect(component.selectedStepId()).toBe('gate');
+ expect(fixture.nativeElement.querySelector('[data-testid="run-step-drawer"]')).toBeTruthy();
+ expect(fixture.nativeElement.textContent).toContain('Policy Gate');
+ expect(
+ fixture.nativeElement.querySelector('[data-testid="run-workspace-tab-graph"].run-tab--active'),
+ ).toBeTruthy();
+ });
+
+ it('navigates to the evidence replay entry from the replay tab', () => {
+ paramMap$.next(convertToParamMap({ runId: 'run-demo', tab: 'replay' }));
+ queryParamMap$.next(convertToParamMap({ returnTo: '/releases/runs' }));
+ routeData$.next({ tab: 'replay' });
+ createComponent();
+
+ const router = TestBed.inject(Router);
+ const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
+
+ component.openEvidenceEntry();
+
+ expect(navigateSpy).toHaveBeenCalledWith(['/evidence/verify-replay'], {
+ queryParams: {
+ releaseId: 'rel-demo',
+ runId: 'run-demo',
+ },
+ });
+ });
+
+ it('opens timeline rows as step deep links while preserving query-state merges', () => {
+ paramMap$.next(convertToParamMap({ runId: 'run-demo', tab: 'timeline' }));
+ queryParamMap$.next(convertToParamMap({ returnTo: '/ops/operations' }));
+ routeData$.next({ tab: 'timeline' });
+ createComponent();
+
+ const router = TestBed.inject(Router);
+ const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
+
+ const approvalRow = fixture.nativeElement.querySelector('[data-testid="run-timeline-row-evt-approval"]');
+ approvalRow.click();
+ fixture.detectChanges();
+
+ expect(component.selectedStepId()).toBe('approval');
+ expect(navigateSpy).toHaveBeenCalledWith([], {
+ relativeTo: activatedRouteStub as unknown as ActivatedRoute,
+ queryParams: { step: 'approval' },
+ queryParamsHandling: 'merge',
+ replaceUrl: true,
+ });
+ });
+});
diff --git a/src/Web/StellaOps.Web/tests/e2e/workflow-visualization-replay.spec.ts b/src/Web/StellaOps.Web/tests/e2e/workflow-visualization-replay.spec.ts
new file mode 100644
index 000000000..21f2621bd
--- /dev/null
+++ b/src/Web/StellaOps.Web/tests/e2e/workflow-visualization-replay.spec.ts
@@ -0,0 +1,341 @@
+import { expect, test, type Page, type Route } from '@playwright/test';
+
+import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
+
+const releaseOperatorSession: StubAuthSession = {
+ subjectId: 'release-e2e-user',
+ tenant: 'tenant-default',
+ scopes: ['admin', 'ui.read', 'release:read', 'policy:read', 'signer:read', 'vex:export'],
+};
+
+const mockConfig = {
+ authority: {
+ issuer: '/authority',
+ clientId: 'stella-ops-ui',
+ authorizeEndpoint: '/authority/connect/authorize',
+ tokenEndpoint: '/authority/connect/token',
+ logoutEndpoint: '/authority/connect/logout',
+ redirectUri: 'https://127.0.0.1:4400/auth/callback',
+ postLogoutRedirectUri: 'https://127.0.0.1:4400/',
+ scope: 'openid profile email ui.read',
+ audience: '/gateway',
+ dpopAlgorithms: ['ES256'],
+ refreshLeewaySeconds: 60,
+ },
+ apiBaseUrls: {
+ authority: '/authority',
+ scanner: '/scanner',
+ policy: '/policy',
+ concelier: '/concelier',
+ attestor: '/attestor',
+ gateway: '/gateway',
+ },
+ quickstartMode: true,
+ setup: 'complete',
+};
+
+const runDetail = {
+ runId: 'run-e2e',
+ releaseId: 'rel-e2e',
+ releaseName: 'Billing API',
+ releaseSlug: 'billing-api',
+ releaseType: 'standard',
+ releaseVersionId: 'ver-e2e',
+ releaseVersionNumber: 42,
+ releaseVersionDigest: 'sha256:release-e2e',
+ lane: 'standard',
+ status: 'running',
+ outcome: 'in_progress',
+ targetEnvironment: 'prod',
+ targetRegion: 'eu-west',
+ scopeSummary: 'stage->prod',
+ requestedAt: '2026-03-07T10:00:00Z',
+ updatedAt: '2026-03-07T10:24:00Z',
+ needsApproval: true,
+ blockedByDataIntegrity: false,
+ correlationKey: 'corr-e2e',
+ statusRow: {
+ runStatus: 'running',
+ gateStatus: 'warn',
+ approvalStatus: 'pending',
+ dataTrustStatus: 'healthy',
+ },
+};
+
+const runTimeline = {
+ runId: 'run-e2e',
+ events: [
+ {
+ eventId: 'evt-ingest',
+ eventClass: 'scan',
+ phase: 'ingest',
+ status: 'completed',
+ occurredAt: '2026-03-07T10:02:00Z',
+ message: 'Ingest completed successfully.',
+ },
+ {
+ eventId: 'evt-gate',
+ eventClass: 'gate',
+ phase: 'gate',
+ status: 'warn',
+ occurredAt: '2026-03-07T10:07:00Z',
+ message: 'Gate requires operator review.',
+ },
+ {
+ eventId: 'evt-approval',
+ eventClass: 'approval',
+ phase: 'approval',
+ status: 'pending',
+ occurredAt: '2026-03-07T10:11:00Z',
+ message: 'Awaiting manual approval.',
+ },
+ {
+ eventId: 'evt-evidence',
+ eventClass: 'evidence',
+ phase: 'evidence',
+ status: 'completed',
+ occurredAt: '2026-03-07T10:18:00Z',
+ message: 'Evidence bundle prepared.',
+ },
+ ],
+};
+
+const liveGraph = {
+ id: 'run-e2e',
+ name: 'Billing API Run',
+ layoutAlgorithm: 'dagre',
+ nodes: [
+ { id: 'ingest', label: 'Ingest & Scan', type: 'task', status: 'succeeded', duration: 120000 },
+ { id: 'gate', label: 'Policy Gate', type: 'gate', status: 'running', duration: 240000 },
+ { id: 'approval', label: 'Approval', type: 'approval', status: 'pending', duration: 0 },
+ { id: 'evidence', label: 'Evidence', type: 'task', status: 'succeeded', duration: 60000 },
+ ],
+ edges: [
+ { id: 'ingest-gate', source: 'ingest', target: 'gate', type: 'dependency' },
+ { id: 'gate-approval', source: 'gate', target: 'approval', type: 'dependency' },
+ { id: 'approval-evidence', source: 'approval', target: 'evidence', type: 'dependency' },
+ ],
+ positions: [
+ { nodeId: 'ingest', x: 180, y: 180 },
+ { nodeId: 'gate', x: 420, y: 180 },
+ { nodeId: 'approval', x: 660, y: 180 },
+ { nodeId: 'evidence', x: 900, y: 180 },
+ ],
+ metadata: {
+ runId: 'run-e2e',
+ releaseId: 'rel-e2e',
+ },
+};
+
+async function fulfillJson(route: Route, body: unknown, status = 200): Promise {
+ await route.fulfill({
+ status,
+ contentType: 'application/json',
+ body: JSON.stringify(body),
+ });
+}
+
+async function setupHarness(page: Page): Promise {
+ await page.addInitScript((session) => {
+ (window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
+ }, releaseOperatorSession);
+
+ await page.route('**/api/**', (route) => fulfillJson(route, {}));
+ await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
+ await page.route('**/platform/i18n/*.json', (route) => fulfillJson(route, {}));
+ await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
+ await page.route('**/.well-known/openid-configuration', (route) =>
+ fulfillJson(route, {
+ issuer: 'https://127.0.0.1:4400/authority',
+ authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
+ token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
+ jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
+ response_types_supported: ['code'],
+ subject_types_supported: ['public'],
+ id_token_signing_alg_values_supported: ['RS256'],
+ }),
+ );
+ await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
+ await page.route('**/authority/console/tenants**', (route) =>
+ fulfillJson(route, {
+ tenants: [
+ {
+ tenantId: releaseOperatorSession.tenant,
+ displayName: 'Default Tenant',
+ isDefault: true,
+ isActive: true,
+ },
+ ],
+ }),
+ );
+ await page.route('**/console/branding**', (route) =>
+ fulfillJson(route, {
+ tenantId: releaseOperatorSession.tenant,
+ appName: 'Stella Ops',
+ logoUrl: null,
+ cssVariables: {},
+ }),
+ );
+ await page.route('**/console/profile**', (route) =>
+ fulfillJson(route, {
+ subjectId: releaseOperatorSession.subjectId,
+ username: 'release-e2e',
+ displayName: 'Release E2E',
+ tenant: releaseOperatorSession.tenant,
+ roles: ['release-operator'],
+ scopes: releaseOperatorSession.scopes,
+ }),
+ );
+ await page.route('**/console/token/introspect**', (route) =>
+ fulfillJson(route, {
+ active: true,
+ tenant: releaseOperatorSession.tenant,
+ subject: releaseOperatorSession.subjectId,
+ scopes: releaseOperatorSession.scopes,
+ }),
+ );
+ await page.route('**/api/v2/context/regions**', (route) =>
+ fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]),
+ );
+ await page.route('**/api/v2/context/environments**', (route) =>
+ fulfillJson(route, [
+ {
+ environmentId: 'prod',
+ regionId: 'eu-west',
+ environmentType: 'prod',
+ displayName: 'Prod',
+ sortOrder: 1,
+ enabled: true,
+ },
+ ]),
+ );
+ await page.route('**/api/v2/context/preferences**', (route) =>
+ fulfillJson(route, {
+ tenantId: releaseOperatorSession.tenant,
+ actorId: releaseOperatorSession.subjectId,
+ regions: ['eu-west'],
+ environments: ['prod'],
+ timeWindow: '24h',
+ stage: 'all',
+ updatedAt: '2026-03-07T12:00:00Z',
+ updatedBy: releaseOperatorSession.subjectId,
+ }),
+ );
+ await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, []));
+ await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, []));
+ await page.route('**/api/v1/telemetry/ttfs', (route) => fulfillJson(route, { accepted: true }, 202));
+
+ await page.route(/\/api\/v2\/releases\/runs\/run-e2e(?:\?.*)?$/, (route) => fulfillJson(route, runDetail));
+ await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/timeline(?:\?.*)?$/, (route) => fulfillJson(route, runTimeline));
+ await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/gate-decision(?:\?.*)?$/, (route) =>
+ fulfillJson(route, {
+ runId: 'run-e2e',
+ verdict: 'warn',
+ blockers: ['manual-review'],
+ riskBudgetDelta: 14,
+ }),
+ );
+ await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/approvals(?:\?.*)?$/, (route) =>
+ fulfillJson(route, {
+ runId: 'run-e2e',
+ checkpoints: [{ checkpointId: 'approve-prod', status: 'pending', approvedAt: null }],
+ }),
+ );
+ await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/deployments(?:\?.*)?$/, (route) =>
+ fulfillJson(route, {
+ runId: 'run-e2e',
+ targets: [
+ {
+ targetId: 'prod-1',
+ targetName: 'billing-api-prod',
+ environment: 'prod',
+ region: 'eu-west',
+ status: 'queued',
+ },
+ ],
+ }),
+ );
+ await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/security-inputs(?:\?.*)?$/, (route) =>
+ fulfillJson(route, {
+ runId: 'run-e2e',
+ reachabilityCoveragePercent: 94,
+ feedFreshnessStatus: 'healthy',
+ vexStatementsApplied: 3,
+ exceptionsApplied: 1,
+ }),
+ );
+ await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/evidence(?:\?.*)?$/, (route) =>
+ fulfillJson(route, {
+ runId: 'run-e2e',
+ replayDeterminismVerdict: 'match',
+ replayMismatch: false,
+ signatureStatus: 'verified',
+ }),
+ );
+ await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/replay(?:\?.*)?$/, (route) =>
+ fulfillJson(route, {
+ runId: 'run-e2e',
+ verdict: 'match',
+ }),
+ );
+ await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/audit(?:\?.*)?$/, (route) =>
+ fulfillJson(route, {
+ runId: 'run-e2e',
+ entries: [
+ {
+ auditId: 'audit-001',
+ action: 'release.run.updated',
+ actorId: 'ops@example.com',
+ occurredAt: '2026-03-07T10:19:00Z',
+ correlationKey: 'corr-e2e',
+ },
+ ],
+ }),
+ );
+
+ await page.route(/\/api\/v1\/workflows\/run-e2e\/graph(?:\?.*)?$/, (route) => fulfillJson(route, liveGraph));
+ await page.route(/\/api\/v1\/workflows\/run-e2e\/critical-path(?:\?.*)?$/, (route) =>
+ fulfillJson(route, {
+ path: ['ingest', 'gate', 'approval', 'evidence'],
+ totalDuration: 420000,
+ }),
+ );
+}
+
+test.beforeEach(async ({ page }) => {
+ await setupHarness(page);
+});
+
+test('run workspace supports timeline drill-in and evidence replay handoff', async ({ page }) => {
+ await page.goto('/releases/runs/run-e2e/graph?step=gate', { waitUntil: 'domcontentloaded' });
+
+ await expect(page.getByTestId('run-graph-replay-page')).toBeVisible();
+ await expect(page.getByTestId('run-workspace-tab-graph')).toHaveClass(/run-tab--active/);
+ await expect(page.getByRole('button', { name: 'Critical path only' })).toBeVisible();
+
+ await page.getByTestId('run-workspace-tab-timeline').click();
+ await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/timeline\?.*step=gate/);
+ await page.getByTestId('run-timeline-row-evt-approval').click();
+ await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/timeline\?.*step=approval/);
+ await expect(page.getByTestId('run-step-drawer')).toContainText('Approval');
+
+ await page.getByTestId('run-workspace-tab-replay').click();
+ await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/replay\?.*step=approval/);
+ await expect(page.getByText('Replay verdict')).toBeVisible();
+ await expect(page.getByText('Debug Session')).toBeVisible();
+
+ await page.getByRole('button', { name: 'Open evidence entry' }).click();
+ await expect(page).toHaveURL(/\/evidence\/verify-replay\?.*releaseId=rel-e2e.*runId=run-e2e/);
+ await expect(page.getByRole('heading', { name: 'Verdict Replay' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Open run replay workspace' }).click();
+ await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/replay\?.*releaseId=rel-e2e.*returnTo=/);
+ await expect(page.getByText('Opened from another operator flow.')).toBeVisible();
+});
+
+test('legacy workflow visualization aliases redirect into the canonical run shell', async ({ page }) => {
+ await page.goto('/workflow-visualization/run-e2e/graph?step=evidence', { waitUntil: 'domcontentloaded' });
+
+ await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/graph\?.*step=evidence/);
+ await expect(page.getByTestId('run-workspace-tab-graph')).toHaveClass(/run-tab--active/);
+});