feat(ui): ship workflow visualization replay workspace

This commit is contained in:
master
2026-03-07 23:25:13 +02:00
parent e11c0a6b59
commit c568e09a1d
26 changed files with 2891 additions and 93 deletions

View File

@@ -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=<id>` 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=<id>` 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.

View File

@@ -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=<id>`, 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=<stepId>`
- `returnTo=<encoded route>`
- **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/<runId>/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=<id>` 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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -34,6 +34,13 @@ import {
/
<code>{{ runId() || 'run-unset' }}</code>
</p>
@if (runId()) {
<div class="replay-runtime-link">
<button class="btn btn-secondary" type="button" (click)="openRunReplayWorkspace()">
Open run replay workspace
</button>
</div>
}
}
</header>
@@ -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()}`,

View File

@@ -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() } }); }

View File

@@ -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: `
<div class="editor-container">
<!-- Header -->
@@ -106,6 +108,32 @@ interface ConnectionState {
</div>
}
@if (previewMode()) {
<section class="preview-mode-panel" data-testid="workflow-preview-mode">
<header class="preview-mode-panel__header">
<div>
<h2>Preview Graph</h2>
<p>Read-only graph preview that reuses the runtime visualizer without replay or live telemetry controls.</p>
</div>
<button class="btn btn-secondary" type="button" (click)="previewMode.set(false)">
Return to editor
</button>
</header>
@if (previewGraph(); as graph) {
<app-workflow-visualizer
[runId]="store.selectedWorkflow()?.id || 'workflow-preview'"
[graph]="graph"
[previewMode]="true"
[autoRefresh]="false"
[showMinimap]="false"
/>
} @else {
<div class="preview-mode-panel__loading">Preparing graph preview...</div>
}
</section>
}
<div class="editor-body">
<!-- Step Palette -->
<aside class="step-palette">
@@ -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<CanvasState>({
@@ -916,6 +981,47 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit
return workflow ? workflowToYaml(workflow) : '';
});
readonly previewGraph = computed<WorkflowGraph | null>(() => {
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 }[] = [];

View File

@@ -126,7 +126,7 @@ interface PlatformListResponse<T> {
<tbody>
@for (row of filteredRows(); track row.activityId) {
<tr>
<td><a [routerLink]="['/releases/runs', row.releaseId, 'timeline']">{{ row.activityId }}</a></td>
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
<td>{{ row.releaseName }}</td>
<td>{{ deriveLane(row) }}</td>
<td>{{ deriveOutcome(row) }}</td>

View File

@@ -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<string>();
@Output() retryRequested = new EventEmitter<string>();
@@ -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<void>();
private readonly searchSubject = new Subject<string>();
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<string, any> | null | undefined) ?? null,
outputs: (details.outputs as Record<string, any> | 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<string, unknown>;
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 {

View File

@@ -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<Record<number, SnapshotState>> | null;
@Output() snapshotChanged = new EventEmitter<SnapshotState>();
@Output() sessionCreated = new EventEmitter<DebugSession>();
@@ -213,6 +216,7 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
private readonly destroy$ = new Subject<void>();
private playbackInterval$ = new Subject<void>();
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();
}
}

View File

@@ -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) {
<g class="edge"
[class.critical]="showCriticalPath && criticalPathEdges.has(edge.id)"
[class.animated]="edge.isAnimated">
[class.animated]="edge.animated">
<path
[attr.d]="getEdgePath(edge)"
[attr.stroke]="getEdgeColor(edge)"
[attr.stroke-width]="getEdgeWidth(edge)"
[attr.marker-end]="edge.isAnimated ? 'url(#arrowhead-animated)' : 'url(#arrowhead)'"
[attr.marker-end]="edge.animated ? 'url(#arrowhead-animated)' : 'url(#arrowhead)'"
fill="none"
class="edge-path">
</path>
<!-- Animated dots for running edges -->
@if (edge.isAnimated) {
@if (edge.animated) {
<circle r="4" [attr.fill]="'var(--color-running)'">
<animateMotion
[attr.path]="getEdgePath(edge)"
@@ -162,7 +162,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
@for (node of nodes; track node.id) {
<g
class="node"
[class]="'node-' + node.status.toLowerCase()"
[class]="'node-' + nodeStatus(node)"
[class.selected]="selectedNodeId === node.id"
[class.critical]="showCriticalPath && criticalPathNodes.has(node.id)"
[attr.transform]="getNodeTransform(node)"
@@ -183,7 +183,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
<!-- Status icon -->
<g [attr.transform]="'translate(12, ' + (nodeHeight / 2) + ')'">
@switch (node.status) {
@switch (displayStatus(node)) {
@case ('Running') {
<circle r="6" fill="var(--color-running)" class="pulse" />
}
@@ -220,7 +220,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
</text>
<!-- Duration badge (if completed) -->
@if (node.data?.['duration']) {
@if (node.duration) {
<g [attr.transform]="'translate(' + (nodeWidth - 8) + ', 8)'">
<rect
x="-24" y="-8"
@@ -229,7 +229,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
fill="var(--color-badge-bg)"
class="duration-badge" />
<text x="-8" y="4" text-anchor="middle" font-size="9" fill="var(--color-badge-text)">
{{ formatDuration(node.data?.['duration']) }}
{{ formatDuration(node.duration) }}
</text>
</g>
}
@@ -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<HTMLDivElement>;
@ViewChild('svgCanvas') svgCanvas!: ElementRef<SVGElement>;
@Input() runId!: string;
@Input() graph: WorkflowGraph | null = null;
@Input() showMinimap = true;
@Input() autoRefresh = true;
@Input() refreshInterval = 2000;
@Input() previewMode = false;
@Output() nodeSelected = new EventEmitter<GraphNode>();
@Output() nodeDoubleClicked = new EventEmitter<GraphNode>();
@@ -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<string, string> = {
'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<string, string> = {
'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)';
}

View File

@@ -0,0 +1,262 @@
<section class="run-workspace" data-testid="run-graph-replay-page">
<header class="run-workspace__header">
<div>
<a routerLink="/releases/runs" class="run-workspace__back-link">Back to release runs</a>
<h1>{{ context()?.detail?.releaseName || 'Release Run' }}</h1>
<p class="run-workspace__subtitle">
Runtime graphing, replay, and evidence for
<code>{{ runId() }}</code>
</p>
</div>
@if (context(); as context) {
<div class="run-workspace__meta">
<span class="chip">{{ context.detail.releaseType }}</span>
<span class="chip">{{ context.detail.status }}</span>
<span class="chip">{{ context.detail.outcome }}</span>
<span class="chip">{{ context.detail.targetRegion || 'global' }}/{{ context.detail.targetEnvironment || 'all' }}</span>
</div>
}
</header>
@if (returnTo()) {
<div class="return-banner">
<span>Opened from another operator flow.</span>
<a [routerLink]="returnTo()">Return to previous context</a>
</div>
}
<nav class="run-tabs" aria-label="Run graph workspace tabs">
@for (tab of tabs; track tab.id) {
<button
type="button"
class="run-tab"
[class.run-tab--active]="activeTab() === tab.id"
[attr.data-testid]="'run-workspace-tab-' + tab.id"
(click)="setTab(tab.id)"
>
{{ tab.label }}
</button>
}
</nav>
@if (loading()) {
<div class="state-banner">Loading run graph and replay context...</div>
} @else if (error()) {
<div class="state-banner state-banner--error">{{ error() }}</div>
} @else if (context(); as context) {
<div class="run-workspace__layout" [class.run-workspace__layout--with-step]="!!selectedStep()">
<main class="run-workspace__main">
@switch (activeTab()) {
@case ('summary') {
<section class="summary-grid">
<article class="summary-card">
<h2>Gate posture</h2>
<p class="summary-value">{{ context.gateDecision?.verdict || context.detail.statusRow.gateStatus }}</p>
<p class="summary-help">{{ context.gateDecision?.blockers?.join(', ') || 'No blocking gate reasons reported.' }}</p>
<button type="button" class="btn btn--secondary" (click)="openLegacyRunTab('gate-decision')">Open gate details</button>
</article>
<article class="summary-card">
<h2>Approvals</h2>
<p class="summary-value">{{ context.approvals?.checkpoints?.length || 0 }}</p>
<p class="summary-help">
{{ context.detail.needsApproval ? 'This run still depends on approval checkpoints.' : 'No approval checkpoints are required for this run.' }}
</p>
<button type="button" class="btn btn--secondary" (click)="openLegacyRunTab('approvals')">Open approvals</button>
</article>
<article class="summary-card">
<h2>Deployment targets</h2>
<p class="summary-value">{{ context.deployments?.targets?.length || 0 }}</p>
<p class="summary-help">Track deployment-state detail in the legacy deployment workbench when needed.</p>
<button type="button" class="btn btn--secondary" (click)="openLegacyRunTab('deployments')">Open deployments</button>
</article>
<article class="summary-card">
<h2>Replay determinism</h2>
<p class="summary-value">{{ context.replay?.verdict || context.evidence?.replayDeterminismVerdict || 'unknown' }}</p>
<p class="summary-help">Replay and evidence tabs use the same run-scoped context.</p>
<button type="button" class="btn btn--primary" (click)="openReplayWorkspace()">Open replay</button>
</article>
</section>
<section class="detail-strip">
<article>
<h3>Security inputs</h3>
<p>Coverage: {{ context.securityInputs?.reachabilityCoveragePercent ?? 'n/a' }}%</p>
<p>Feed freshness: {{ context.securityInputs?.feedFreshnessStatus ?? 'unknown' }}</p>
<button type="button" class="btn btn--secondary" (click)="openLegacyRunTab('security-inputs')">Open security inputs</button>
</article>
<article>
<h3>Evidence posture</h3>
<p>Signature: {{ context.evidence?.signatureStatus ?? 'unknown' }}</p>
<p>Mismatch: {{ context.evidence?.replayMismatch ? 'yes' : 'no' }}</p>
<button type="button" class="btn btn--secondary" (click)="setTab('evidence')">Open evidence tab</button>
</article>
<article>
<h3>Critical path</h3>
<p>{{ context.criticalPath.path.join(' -> ') }}</p>
<p>Total runtime: {{ formatDuration(context.criticalPath.totalDuration) }}</p>
<button type="button" class="btn btn--secondary" (click)="setTab('critical-path')">Inspect critical path</button>
</article>
</section>
}
@case ('graph') {
<section class="graph-toolbar">
<div class="filter-group">
<button type="button" class="btn btn--secondary" [class.is-active]="graphFilter() === 'all'" (click)="graphFilter.set('all')">All steps</button>
<button type="button" class="btn btn--secondary" [class.is-active]="graphFilter() === 'failed'" (click)="graphFilter.set('failed')">Failed only</button>
<button type="button" class="btn btn--secondary" [class.is-active]="graphFilter() === 'active'" (click)="graphFilter.set('active')">Active only</button>
</div>
<button type="button" class="btn btn--secondary" [class.is-active]="criticalPathOnly()" (click)="toggleCriticalPath()">Critical path only</button>
</section>
<app-workflow-visualizer
[runId]="runId()"
[graph]="filteredGraph()"
[autoRefresh]="false"
(nodeSelected)="openStep($event.id)"
(nodeDoubleClicked)="openStep($event.id)"
/>
}
@case ('timeline') {
<section class="timeline-table">
<table>
<thead>
<tr>
<th>When</th>
<th>Phase</th>
<th>Status</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@for (row of context.timeline; track row.eventId) {
<tr (click)="onTimelineRowSelected(row)" [attr.data-testid]="'run-timeline-row-' + row.eventId">
<td>{{ formatWhen(row.occurredAt) }}</td>
<td>{{ row.phaseLabel }}</td>
<td>{{ row.status }}</td>
<td>{{ row.message }}</td>
</tr>
} @empty {
<tr><td colspan="4">No timeline events were returned for this run.</td></tr>
}
</tbody>
</table>
</section>
}
@case ('critical-path') {
<section class="critical-path-panel">
<header>
<h2>Critical path</h2>
<p>{{ context.criticalPath.path.join(' -> ') }}</p>
</header>
<div class="critical-path-cards">
@for (node of criticalPathNodes(); track node.id) {
<article class="critical-card" (click)="openStep(node.id)" [attr.data-testid]="'critical-path-node-' + node.id">
<h3>{{ node.label }}</h3>
<p>Status: {{ node.status }}</p>
<p>Duration: {{ formatDuration(node.duration) }}</p>
</article>
}
</div>
<button type="button" class="btn btn--secondary" (click)="setTab('graph')">Open graph view</button>
</section>
}
@case ('replay') {
<section class="replay-grid">
<article class="summary-card">
<h2>Replay verdict</h2>
<p class="summary-value">{{ context.replay?.verdict || 'unknown' }}</p>
<p class="summary-help">Run-scoped replay and debug snapshots stay attached to this release run.</p>
</article>
<article class="summary-card">
<h2>Signature status</h2>
<p class="summary-value">{{ context.evidence?.signatureStatus || 'unknown' }}</p>
<p class="summary-help">Mismatch: {{ context.evidence?.replayMismatch ? 'yes' : 'no' }}</p>
</article>
</section>
<app-time-travel-controls
[runId]="runId()"
[sessionData]="context.debugSession"
[snapshotsData]="context.snapshots"
[statesByIndex]="context.snapshotStates"
(snapshotChanged)="onSnapshotChanged($event)"
/>
<section class="replay-actions">
<button type="button" class="btn btn--secondary" (click)="openEvidenceEntry()">Open evidence entry</button>
</section>
}
@case ('evidence') {
<section class="evidence-grid">
<article class="summary-card">
<h2>Determinism</h2>
<p class="summary-value">{{ context.evidence?.replayDeterminismVerdict || context.replay?.verdict || 'unknown' }}</p>
<p class="summary-help">Review signed replay posture and drift inside the run shell, then use the evidence page for exports.</p>
</article>
<article class="summary-card">
<h2>Audit entries</h2>
<p class="summary-value">{{ context.audit?.entries?.length || 0 }}</p>
<p class="summary-help">Run audit events stay linked to the same correlation key.</p>
</article>
</section>
<section class="audit-table">
<table>
<thead>
<tr>
<th>When</th>
<th>Actor</th>
<th>Action</th>
<th>Correlation</th>
</tr>
</thead>
<tbody>
@for (entry of context.audit?.entries || []; track entry.auditId) {
<tr>
<td>{{ formatWhen(entry.occurredAt) }}</td>
<td>{{ entry.actorId }}</td>
<td>{{ entry.action }}</td>
<td>{{ entry.correlationKey }}</td>
</tr>
} @empty {
<tr><td colspan="4">No audit entries are available for this run.</td></tr>
}
</tbody>
</table>
</section>
<app-replay-controls />
}
}
</main>
@if (selectedStep()) {
<aside class="step-drawer" data-testid="run-step-drawer">
<div class="step-drawer__header">
<h2>Step detail</h2>
<button type="button" class="btn btn--secondary" (click)="closeStep()">Close</button>
</div>
<app-step-detail-panel
[runId]="runId()"
[stepId]="selectedStepId() ?? undefined"
[stepData]="selectedStep()"
[logsData]="selectedStepLogs()"
(stepSelected)="openStep($event)"
/>
</aside>
}
</div>
}
</section>

View File

@@ -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;
}
}

View File

@@ -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<string | null>(null);
readonly runId = signal('');
readonly activeTab = signal<RunWorkspaceTab>('summary');
readonly selectedStepId = signal<string | null>(null);
readonly context = signal<RunVisualizationContext | null>(null);
readonly graphFilter = signal<'all' | 'failed' | 'active'>('all');
readonly criticalPathOnly = signal(false);
readonly returnTo = signal<string | null>(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<typeof node> => !!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';
}
}
}

View File

@@ -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<string, unknown> | null;
readonly outputs: Record<string, unknown> | 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<Record<string, RunStepPanelData>>;
readonly stepLogs: Readonly<Record<string, readonly RunStepLogEntry[]>>;
readonly debugSession: DebugSession;
readonly snapshots: readonly SnapshotSummary[];
readonly snapshotStates: Readonly<Record<number, SnapshotState>>;
}
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<RunVisualizationContext> {
const runBase = `/api/v2/releases/runs/${encodeURIComponent(runId)}`;
return forkJoin({
detail: this.http.get<ReleaseRunDetailProjectionDto>(runBase),
timeline: this.http.get<ReleaseRunTimelineProjectionDto>(`${runBase}/timeline`).pipe(catchError(() => of(null))),
gateDecision: this.http.get<ReleaseRunGateDecisionProjectionDto>(`${runBase}/gate-decision`).pipe(catchError(() => of(null))),
approvals: this.http.get<ReleaseRunApprovalsProjectionDto>(`${runBase}/approvals`).pipe(catchError(() => of(null))),
deployments: this.http.get<ReleaseRunDeploymentsProjectionDto>(`${runBase}/deployments`).pipe(catchError(() => of(null))),
securityInputs: this.http.get<ReleaseRunSecurityInputsProjectionDto>(`${runBase}/security-inputs`).pipe(catchError(() => of(null))),
evidence: this.http.get<ReleaseRunEvidenceProjectionDto>(`${runBase}/evidence`).pipe(catchError(() => of(null))),
replay: this.http.get<ReleaseRunReplayProjectionDto>(`${runBase}/replay`).pipe(catchError(() => of(null))),
audit: this.http.get<ReleaseRunAuditProjectionDto>(`${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<PhaseId, readonly RunTimelineRow[]>();
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<Record<string, readonly RunStepLogEntry[]>> {
const grouped: Record<string, RunStepLogEntry[]> = {};
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<Record<string, readonly RunStepLogEntry[]>>,
bundle: {
gateDecision: ReleaseRunGateDecisionProjectionDto | null;
approvals: ReleaseRunApprovalsProjectionDto | null;
deployments: ReleaseRunDeploymentsProjectionDto | null;
evidence: ReleaseRunEvidenceProjectionDto | null;
replay: ReleaseRunReplayProjectionDto | null;
securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
}
): Readonly<Record<string, RunStepPanelData>> {
const result: Record<string, RunStepPanelData> = {};
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<Record<string, RunStepPanelData>>,
stepLogs: Readonly<Record<string, readonly RunStepLogEntry[]>>,
bundle: {
evidence: ReleaseRunEvidenceProjectionDto | null;
replay: ReleaseRunReplayProjectionDto | null;
gateDecision: ReleaseRunGateDecisionProjectionDto | null;
securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
}
): Readonly<Record<number, SnapshotState>> {
const result: Record<number, SnapshotState> = {};
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<string, unknown> | 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<string, unknown> | 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';
}
}

View File

@@ -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<string, unknown>;
logs: string[];
metadata: Record<string, unknown>;
diff?: Record<string, unknown> | null;
}
@Injectable({

View File

@@ -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) => ({

View File

@@ -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<string, string>, 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<string, string>, 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<string, string>, fragment),
},
{
path: 'approvals',

View File

@@ -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']);
});
});

View File

@@ -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<string, string>,
};
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<ComponentFixture<WorkflowEditorComponent>> {
async function createEditor(query: Record<string, string> = {}): Promise<ComponentFixture<WorkflowEditorComponent>> {
routeState.query = query;
const fixture: ComponentFixture<WorkflowEditorComponent> =
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;

View File

@@ -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<RunGraphReplayPageComponent>;
let component: RunGraphReplayPageComponent;
let paramMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let routeData$: BehaviorSubject<Record<string, unknown>>;
let loadContextSpy: jasmine.Spy;
let activatedRouteStub: {
paramMap: ReturnType<BehaviorSubject<ReturnType<typeof convertToParamMap>>['asObservable']>;
queryParamMap: ReturnType<BehaviorSubject<ReturnType<typeof convertToParamMap>>['asObservable']>;
data: ReturnType<BehaviorSubject<Record<string, unknown>>['asObservable']>;
snapshot: {
readonly paramMap: ReturnType<typeof convertToParamMap>;
readonly queryParamMap: ReturnType<typeof convertToParamMap>;
};
};
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<Record<string, unknown>>({ 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,
});
});
});

View File

@@ -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<void> {
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
}
async function setupHarness(page: Page): Promise<void> {
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/);
});