From b521b5bde82aed073543c6822f5e1c0a14a58371 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 8 Mar 2026 12:41:09 +0200 Subject: [PATCH] feat(ui): ship evidence capsules cutover --- ..._FE_evidence_capsules_canonical_cutover.md | 95 ++++ .../evidence-capsules-canonical-cutover-ui.md | 54 ++ docs/modules/ui/TASKS.md | 4 + .../README.md | 46 ++ docs/modules/ui/implementation_plan.md | 2 + src/Web/StellaOps.Web/src/app/app.routes.ts | 12 + .../ai-runs/ai-run-viewer.component.ts | 10 +- .../evidence-pack-list.component.ts | 23 +- .../evidence-pack-viewer.component.ts | 90 +++- .../evidence-capsules-cutover.spec.ts | 346 +++++++++++++ .../e2e/evidence-capsules-cutover.spec.ts | 477 ++++++++++++++++++ 11 files changed, 1145 insertions(+), 14 deletions(-) create mode 100644 docs-archived/implplan/SPRINT_20260308_011_FE_evidence_capsules_canonical_cutover.md create mode 100644 docs/features/checked/web/evidence-capsules-canonical-cutover-ui.md create mode 100644 docs/modules/ui/evidence-capsules-canonical-cutover/README.md create mode 100644 src/Web/StellaOps.Web/src/tests/evidence/evidence-capsules-cutover.spec.ts create mode 100644 src/Web/StellaOps.Web/tests/e2e/evidence-capsules-cutover.spec.ts diff --git a/docs-archived/implplan/SPRINT_20260308_011_FE_evidence_capsules_canonical_cutover.md b/docs-archived/implplan/SPRINT_20260308_011_FE_evidence_capsules_canonical_cutover.md new file mode 100644 index 000000000..b6442efc2 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260308_011_FE_evidence_capsules_canonical_cutover.md @@ -0,0 +1,95 @@ +# Sprint 20260308_011_FE - Evidence Capsules Canonical Cutover + +## Topic & Scope +- Restore the usable decision-capsule workflow under the canonical Evidence shell instead of leaving it split between working components and dead legacy paths. +- Make `/evidence/capsules` and `/evidence/capsules/:capsuleId` the only operator-facing owner routes for evidence-pack browsing, while preserving stale `/evidence-packs*` bookmarks. +- Complete the cross-shell handoffs so AI Runs and release/evidence contexts deep-link into capsules with deterministic return-to behavior and an actionable related-run jump. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: focused Angular route/component tests, browser verification, updated UI docs, archived sprint. + +## Dependencies & Concurrency +- Builds on the shipped Evidence shell, workflow-visualization run workspace, and contextual route-state utilities already present in `src/Web/StellaOps.Web`. +- Safe to implement in parallel with unrelated backend or Router work as long as edits stay inside `src/Web/StellaOps.Web` and scoped UI docs. + +## Documentation Prerequisites +- `docs/modules/ui/README.md` +- `docs/modules/ui/implementation_plan.md` +- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### FE-EC-001 - Canonical capsule owner route and alias repair +Status: DONE +Dependency: none +Owners: Developer / Implementer +Task description: +- Preserve the current Evidence shell as the owner of decision capsules and repair the stale route contract that still points live components at `/evidence-packs/:id`. +- Add bookmark-safe alias handling for legacy evidence-pack paths so operators and old links land on the canonical capsule detail route without dropping query state. + +Completion criteria: +- [x] Live components navigate to `/evidence/capsules` and `/evidence/capsules/:capsuleId`. +- [x] Legacy `/evidence-packs` aliases redirect to canonical capsule routes while preserving query params and fragments. +- [x] The capsule list/detail flow is discoverable and usable from the live Evidence shell. + +### FE-EC-002 - Cross-shell handoffs and related-run navigation +Status: DONE +Dependency: FE-EC-001 +Owners: Developer / Implementer +Task description: +- Complete the worthwhile behavior from the dropped evidence/proof surfaces by wiring cross-shell entry points into the canonical capsule detail route with deterministic return-to context. +- Ensure the capsule viewer can jump to the related owner workflow instead of the stale AI-run-only path, using the active Releases run workspace when the pack represents release evidence. + +Completion criteria: +- [x] AI Runs and other current entry points deep-link into canonical capsule detail routes with a valid return-to contract. +- [x] Capsule detail provides a usable back action to the originating context or canonical capsule list. +- [x] Related-run navigation lands on a live owner route instead of a stale or orphaned path. + +### FE-EC-003 - Verification coverage for the cutover +Status: DONE +Dependency: FE-EC-002 +Owners: Developer / Implementer, Test Automation, QA +Task description: +- Add focused Angular tests for alias handling, canonical route hydration, and the related-run / return-to navigation behavior. +- Add browser verification that proves both the live capsule flow and a legacy bookmark cut over correctly. + +Completion criteria: +- [x] Targeted Angular tests cover the canonical capsule route contract and navigation behavior. +- [x] Browser verification covers at least one live entry point plus one legacy alias. +- [x] The scoped test suite passes deterministically. + +### FE-EC-004 - Docs sync, archive, and shipped-feature note +Status: DONE +Dependency: FE-EC-003 +Owners: Developer / Implementer, Documentation author +Task description: +- Document the canonical capsule owner route, alias rules, and cross-shell handoff behavior in the UI module docs. +- Record the verified behavior in a checked-feature note, update the task board and implementation plan, then archive the sprint only after the delivery tasks are actually complete. + +Completion criteria: +- [x] Module docs describe the canonical capsule workflow and alias contract. +- [x] Checked-feature verification note records the actual commands and outcomes. +- [x] Sprint is archived with all tasks marked `DONE`. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-08 | Sprint created and moved to DOING for the canonical evidence capsules cutover. | Implementer | +| 2026-03-08 | Repaired the canonical Decision Capsule list and detail routes, added bookmark-safe `/evidence-packs*` aliases, and switched live capsule navigation away from the stale pack paths. | Implementer | +| 2026-03-08 | Completed cross-shell handoffs from AI Runs and release evidence so capsule detail pages preserve `returnTo` context and open the live owner workspace for related runs. | Implementer | +| 2026-03-08 | Verification passed: Angular targeted tests `1` file / `7` tests, Playwright `2` scenarios, and `npm run build` with existing bundle-budget warnings only. | Implementer | + +## Decisions & Risks +- This sprint is intentionally bounded to the canonical capsule list/detail flow plus handoffs. Broader proof-chain or evidence-thread restoration should land in separate evidence-domain sprints. +- Evidence packs come from more than one product area; related-run navigation must prefer live owner routes and avoid reintroducing dead `/evidence-packs` or orphaned `/ai-runs` paths. +- Route alias handling must preserve query params and fragments so release-context and AI-run bookmarks remain deterministic. +- Browser verification initially failed because the release-run evidence workspace fixture returned the base run detail object for sibling endpoints like `/approvals` and `/deployments`; the E2E harness was corrected to return the real per-endpoint shapes instead of weakening the assertion surface. +- Verification commands: + - `npm run test -- --watch=false --include src/tests/evidence/evidence-capsules-cutover.spec.ts` + - `npx playwright test --config playwright.config.ts tests/e2e/evidence-capsules-cutover.spec.ts --workers=1` + - `npm run build` + +## Next Checkpoints +- Canonical route and alias cutover complete with focused tests. +- Cross-shell handoffs verified in browser. +- Sprint archived and committed locally without unrelated files. diff --git a/docs/features/checked/web/evidence-capsules-canonical-cutover-ui.md b/docs/features/checked/web/evidence-capsules-canonical-cutover-ui.md new file mode 100644 index 000000000..05b665bea --- /dev/null +++ b/docs/features/checked/web/evidence-capsules-canonical-cutover-ui.md @@ -0,0 +1,54 @@ +# Evidence Capsules Canonical Cutover UI + +## Module +Web + +## Status +VERIFIED + +## Description +Shipped the canonical Decision Capsule flow under `Evidence`, repaired stale `/evidence-packs*` bookmarks, and completed the cross-shell handoffs so AI Runs and release evidence can open capsule detail pages and return to their live owner workspaces without dead ends. + +## Implementation Details +- **Feature directories**: + - `src/Web/StellaOps.Web/src/app/features/evidence-pack/` + - `src/Web/StellaOps.Web/src/app/features/ai-runs/` + - `src/Web/StellaOps.Web/src/app/features/workflow-visualization/` +- **Primary components**: + - `evidence-pack-list` (`src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts`) + - `evidence-pack-viewer` (`src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-viewer.component.ts`) + - `ai-run-viewer` (`src/Web/StellaOps.Web/src/app/features/ai-runs/ai-run-viewer.component.ts`) +- **Canonical routes**: + - `/evidence/capsules` + - `/evidence/capsules/:capsuleId` +- **Legacy aliases**: + - `/evidence-packs` + - `/evidence-packs/:capsuleId` +- **Secondary entry points**: + - `Ops > Operations > AI Runs` + - `Releases > Runs > Evidence` + +## E2E Test Plan +- **Setup**: + - [x] Start the local Angular test server with `npm run serve:test`. + - [x] Use a test session with ops, release, policy, and signer scopes. +- **Core verification**: + - [x] Verify AI Run detail opens canonical Decision Capsule detail with a usable back action. + - [x] Verify Decision Capsule detail opens the live related-run workspace, not a stale route. +- **Legacy verification**: + - [x] Verify `/evidence-packs/:capsuleId` bookmarks land on `/evidence/capsules/:capsuleId`. + - [x] Verify the legacy bookmark can continue into the canonical release evidence workspace. + +## Verification +- Run: + - `npm run test -- --watch=false --include src/tests/evidence/evidence-capsules-cutover.spec.ts` + - `npx playwright test --config playwright.config.ts tests/e2e/evidence-capsules-cutover.spec.ts --workers=1` + - `npm run build` +- Tier 0 (source): pass +- Tier 1 (build/tests): pass +- Tier 2 (behavior): pass +- Notes: + - Angular targeted tests passed: `1` file, `7` tests. + - Playwright passed: `2` scenarios. + - Production build passed; existing bundle-budget warnings remain unchanged from the baseline. +- Verified on (UTC): 2026-03-08T10:37:03Z diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index c36deba12..3e471a91d 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -115,6 +115,10 @@ - [DONE] FE-RP-002 Wire release-context handoff into the canonical promotion wizard - [DONE] FE-RP-003 Verify route cutover and usable promotion request workflow - [DONE] FE-RP-004 Sync docs, archive the sprint, and record the shipped feature +- [DONE] FE-EC-001 Repair canonical capsule ownership and preserve `/evidence-packs*` aliases +- [DONE] FE-EC-002 Complete cross-shell capsule handoffs and related-run navigation +- [DONE] FE-EC-003 Verify route cutover and usable capsule workflow +- [DONE] FE-EC-004 Sync docs, archive the sprint, and record the shipped feature - [DONE] FE-PO-001 Freeze Operations overview taxonomy and submenu structure - [DONE] FE-PO-002 Overview page regrouping and blocking-card contract - [DONE] FE-PO-003 Legacy widget absorption matrix for Platform Ops diff --git a/docs/modules/ui/evidence-capsules-canonical-cutover/README.md b/docs/modules/ui/evidence-capsules-canonical-cutover/README.md new file mode 100644 index 000000000..02bfc6c0a --- /dev/null +++ b/docs/modules/ui/evidence-capsules-canonical-cutover/README.md @@ -0,0 +1,46 @@ +# Evidence Capsules Canonical Cutover + +## Purpose +- Keep Decision Capsules owned by the live `Evidence` shell instead of splitting them across canonical routes, stale legacy bookmarks, and context-specific dead ends. +- Finish the usable workflow, not just the references: list, detail, back-navigation, related-run handoff, and bookmark repair all need to work for operators. + +## Canonical Routes +- `/evidence/capsules` +- `/evidence/capsules/:capsuleId` + +## Alias Policy +- Preserve `/evidence-packs` +- Preserve `/evidence-packs/:capsuleId` +- Redirect aliases into the canonical `/evidence/capsules*` subtree while preserving query params and fragments. + +## Shell Ownership +- `Evidence` remains the only operator-facing owner of Decision Capsule browsing. +- The capsule list uses Decision Capsule terminology consistently and opens detail pages inside the canonical Evidence shell. +- Capsule detail exposes a back action that prefers the originating context and falls back to the canonical capsule list. + +## Cross-Shell Handoffs +- `Ops > Operations > AI Runs` opens canonical capsule detail routes with a deterministic `returnTo` contract. +- Capsule detail routes related AI-generated evidence back into `/ops/operations/ai-runs/:runId`. +- Capsule detail routes release evidence into `/releases/runs/:runId/evidence`. +- Related-run handoffs always preserve a valid return path back to the canonical capsule detail page. + +## Merge Notes From Dropped Surfaces +- The stale `/evidence-packs*` surface was not preserved as a second owner route. +- The useful behavior from the half-wired pack flow was merged into the canonical Evidence shell: + - bookmark-safe alias repair + - context-aware back navigation + - live owner-route jumps for AI and release contexts + +## Verification +- Angular tests cover: + - canonical capsule route ownership + - legacy alias redirects + - list/detail navigation behavior + - capsule viewer `returnTo` and related-run handoffs +- Playwright covers: + - a live AI Runs entry point into capsule detail and back + - a legacy `/evidence-packs/:capsuleId` bookmark that cuts over into the live release evidence workspace + +## Related +- `docs/features/checked/web/evidence-capsules-canonical-cutover-ui.md` +- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md` diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index c1146f7d0..17cea5bc1 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.md @@ -35,6 +35,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - `docs/features/checked/web/security-operations-leaves-ui.md` - shipped verification note for mission alerts/activity surfacing, unknowns route repair, notifications ownership, and legacy security alias cutover. - `docs/features/checked/web/platform-setup-canonical-route-preservation-ui.md` - shipped verification note for preserved `/ops/platform-setup/*` URLs during the shared setup/topology cutover. - `docs/features/checked/web/release-promotions-cutover-ui.md` - shipped verification note for canonical release promotions routing, alias cutover, release-context wizard handoff, and end-to-end request submission. +- `docs/features/checked/web/evidence-capsules-canonical-cutover-ui.md` - shipped verification note for canonical Evidence-owned capsule routes, `/evidence-packs*` bookmark repair, and AI/release context handoffs. - `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/offline-operations/README.md` - detailed owner-shell contract for Offline Kit, Feeds & Airgap, Evidence handoffs, and stale alias policy. @@ -44,6 +45,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - `docs/modules/ui/security-operations-leaves/README.md` - canonical owner contract for mission alerts/activity, security unknowns, notifications, and stale `/analyze`/`/notify` handoffs. - `docs/modules/ui/platform-setup-canonical-route-preservation/README.md` - preserved route contract for canonical `/ops/platform-setup/*` leaves during the shared setup/topology cutover. - `docs/modules/ui/release-promotions-cutover/README.md` - canonical promotions owner contract, alias rules, and release-context handoff for the Releases shell. +- `docs/modules/ui/evidence-capsules-canonical-cutover/README.md` - canonical Evidence owner contract for Decision Capsule list/detail browsing, legacy bookmark aliases, and related-run handoffs. - `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier. - `docs/modules/ui/workflow-visualization-replay/README.md` - detailed run-detail graph, timeline, replay, and evidence UX dossier. - `docs/modules/ui/contextual-actions-patterns/README.md` - shared placement contract for stray actions, pages, drawers, and tabs. diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index 699c95088..bd3de816d 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -347,6 +347,18 @@ export const routes: Routes = [ { path: '**', redirectTo: '/evidence/overview' }, ], }, + { + path: 'evidence-packs', + children: [ + { path: '', redirectTo: preserveAppRedirect('/evidence/capsules'), pathMatch: 'full' }, + { + path: ':capsuleId', + redirectTo: preserveAppRedirect('/evidence/capsules/:capsuleId'), + pathMatch: 'full', + }, + { path: '**', redirectTo: preserveAppRedirect('/evidence/capsules') }, + ], + }, { path: 'security-risk', children: [ diff --git a/src/Web/StellaOps.Web/src/app/features/ai-runs/ai-run-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/ai-runs/ai-run-viewer.component.ts index 979e3f9c5..a72d9736e 100644 --- a/src/Web/StellaOps.Web/src/app/features/ai-runs/ai-run-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/ai-runs/ai-run-viewer.component.ts @@ -35,6 +35,7 @@ import { AttestationContent, } from '../../core/api/ai-runs.models'; import { AI_RUNS_API } from '../../core/api/ai-runs.client'; +import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state'; @Component({ selector: 'stellaops-ai-run-viewer', @@ -912,7 +913,14 @@ export class AiRunViewerComponent implements OnInit, OnChanges { onNavigateToEvidencePack(packId: string): void { this.navigateToEvidencePack.emit(packId); - this.router.navigate(['/evidence-packs', packId]); + const currentRunId = this.runId ?? this.run()?.runId ?? ''; + void this.router.navigate(['/evidence/capsules', packId], { + queryParams: currentRunId + ? { + returnTo: buildContextReturnTo(this.router, ['/ops/operations/ai-runs', currentRunId]), + } + : undefined, + }); } formatEventType(type: string): string { diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts index 81f19457e..f000e011f 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts @@ -31,7 +31,10 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s
-

Evidence Packs

+
+

Decision Capsules

+

Browse signed evidence packs that explain release, policy, and operator decisions.

+
-

Loading evidence packs...

+

Loading decision capsules...

} @else if (error()) { -

No evidence packs found

+

No decision capsules found

} @else {
@@ -157,6 +160,12 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s margin: 0; } + .list-subtitle { + margin: 0.25rem 0 0; + font-size: 0.8rem; + color: var(--color-text-secondary); + } + .filters { display: flex; align-items: center; @@ -393,7 +402,7 @@ export class EvidencePackListComponent implements OnInit { this.loading.set(false); }, error: (err) => { - this.error.set(err.message || 'Failed to load evidence packs'); + this.error.set(err.message || 'Failed to load decision capsules'); this.loading.set(false); }, }); @@ -411,6 +420,8 @@ export class EvidencePackListComponent implements OnInit { onSelect(pack: EvidencePackSummary): void { this.packSelected.emit(pack); - this.router.navigate(['/evidence-packs', pack.packId]); + void this.router.navigate(['/evidence/capsules', pack.packId], { + queryParams: { returnTo: this.router.url }, + }); } } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-viewer.component.ts index f0ab8c31a..72a405118 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-viewer.component.ts @@ -11,6 +11,7 @@ import { Input, Output, EventEmitter, + DestroyRef, inject, signal, computed, @@ -18,6 +19,7 @@ import { OnInit, SimpleChanges, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { @@ -30,6 +32,7 @@ import { } from '../../core/api/evidence-pack.models'; import { EVIDENCE_PACK_API } from '../../core/api/evidence-pack.client'; import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component'; +import { buildContextReturnTo, readContextRouteParam } from '../../shared/ui/context-route-state/context-route-state'; @Component({ selector: 'stellaops-evidence-pack-viewer', @@ -52,7 +55,10 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s
-

Evidence Pack

+ +

Decision Capsule

{{ pack()!.packId }}
@@ -379,9 +385,24 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s .header-left { display: flex; align-items: center; + flex-wrap: wrap; gap: 0.75rem; } + .back-link { + border: none; + padding: 0; + background: none; + color: var(--color-brand-primary); + cursor: pointer; + font-size: 0.85rem; + font-weight: var(--font-weight-medium); + } + + .back-link:hover { + text-decoration: underline; + } + .pack-title { font-size: 1.125rem; font-weight: var(--font-weight-semibold); @@ -750,6 +771,7 @@ export class EvidencePackViewerComponent implements OnInit, OnChanges { private readonly api = inject(EVIDENCE_PACK_API); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); @Input() packId?: string; @Input() initialPack?: EvidencePack; @@ -765,18 +787,23 @@ export class EvidencePackViewerComponent implements OnInit, OnChanges { readonly signing = signal(false); readonly verifying = signal(false); readonly showExportMenu = signal(false); + readonly returnTo = signal(null); readonly isSigned = computed(() => this.signedPack() !== null); + readonly backLabel = computed(() => (this.returnTo() ? 'Back to Previous Context' : 'Back to Decision Capsules')); ngOnInit(): void { - // Read packId from route params if not provided via Input - this.route.paramMap.subscribe((params) => { - const routePackId = params.get('packId'); - if (routePackId && !this.packId) { + this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { + const routePackId = params.get('capsuleId') ?? params.get('packId'); + if (routePackId && routePackId !== this.packId) { this.packId = routePackId; this.loadPack(); } }); + + this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { + this.returnTo.set(readContextRouteParam(params, 'returnTo')); + }); } ngOnChanges(changes: SimpleChanges): void { @@ -799,7 +826,7 @@ export class EvidencePackViewerComponent implements OnInit, OnChanges { this.loading.set(false); }, error: (err) => { - this.error.set(err.message || 'Failed to load evidence pack'); + this.error.set(err.message || 'Failed to load decision capsule'); this.loading.set(false); }, }); @@ -870,7 +897,21 @@ export class EvidencePackViewerComponent implements OnInit, OnChanges { onNavigateToRun(runId: string): void { this.navigateToRun.emit(runId); - this.router.navigate(['/ai-runs', runId]); + void this.router.navigate(this.resolveRunCommands(runId), { + queryParams: { + returnTo: this.capsuleReturnTo(), + }, + }); + } + + goBack(): void { + const returnTo = this.returnTo(); + if (returnTo) { + void this.router.navigateByUrl(returnTo); + return; + } + + void this.router.navigate(['/evidence/capsules']); } private downloadBlob(blob: Blob, filename: string): void { @@ -897,4 +938,39 @@ export class EvidencePackViewerComponent implements OnInit, OnChanges { return 'json'; } } + + private resolveRunCommands(runId: string): readonly string[] { + if (this.isAiRunContext()) { + return ['/ops/operations/ai-runs', runId]; + } + + return ['/releases/runs', runId, 'evidence']; + } + + private capsuleReturnTo(): string { + const packId = this.pack()?.packId ?? this.packId; + if (!packId) { + return this.router.url; + } + + return buildContextReturnTo( + this.router, + ['/evidence/capsules', packId], + this.returnTo() ? { returnTo: this.returnTo() } : undefined, + ); + } + + private isAiRunContext(): boolean { + const context = this.pack()?.context; + if (!context) { + return false; + } + + if ((context.conversationId ?? '').trim().length > 0) { + return true; + } + + const generator = (context.generatedBy ?? '').toLowerCase(); + return generator.includes('advisoryai') || generator.includes('advisory ai') || generator.includes('ai run'); + } } diff --git a/src/Web/StellaOps.Web/src/tests/evidence/evidence-capsules-cutover.spec.ts b/src/Web/StellaOps.Web/src/tests/evidence/evidence-capsules-cutover.spec.ts new file mode 100644 index 000000000..a8b5fa372 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/evidence/evidence-capsules-cutover.spec.ts @@ -0,0 +1,346 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Route, Router, convertToParamMap, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { routes } from '../../app/app.routes'; +import { AI_RUNS_API, type AiRunsApi } from '../../app/core/api/ai-runs.client'; +import { EVIDENCE_PACK_API, type EvidencePackApi } from '../../app/core/api/evidence-pack.client'; +import type { AiRun } from '../../app/core/api/ai-runs.models'; +import type { EvidencePack, EvidencePackSummary } from '../../app/core/api/evidence-pack.models'; +import { AiRunViewerComponent } from '../../app/features/ai-runs/ai-run-viewer.component'; +import { EvidencePackListComponent } from '../../app/features/evidence-pack/evidence-pack-list.component'; +import { EvidencePackViewerComponent } from '../../app/features/evidence-pack/evidence-pack-viewer.component'; +import { EVIDENCE_ROUTES } from '../../app/routes/evidence.routes'; +import { buildContextReturnTo } from '../../app/shared/ui/context-route-state/context-route-state'; + +function resolveRedirect( + route: Route | undefined, + params: Record = {}, + queryParams: Record = { scope: 'release' }, +): string | undefined { + const redirect = route?.redirectTo; + if (typeof redirect === 'string') { + return redirect; + } + + if (typeof redirect !== 'function') { + return undefined; + } + + return TestBed.runInInjectionContext(() => { + const router = TestBed.inject(Router); + const target = redirect({ + params, + queryParams, + fragment: 'capsule', + } as never) as unknown; + + return typeof target === 'string' ? target : router.serializeUrl(target as never); + }); +} + +const releaseCapsule: EvidencePack = { + packId: 'cap-rel-001', + version: '1.0.0', + createdAt: '2026-03-08T10:00:00Z', + tenantId: 'tenant-default', + subject: { + type: 'Finding', + findingId: 'finding-001', + cveId: 'CVE-2026-1111', + component: 'pkg:oci/api-gateway@2.1.0', + }, + claims: [], + evidence: [], + context: { + runId: 'run-rel-001', + generatedBy: 'Release Orchestrator', + }, +}; + +const aiCapsule: EvidencePack = { + packId: 'cap-ai-001', + version: '1.0.0', + createdAt: '2026-03-08T10:05:00Z', + tenantId: 'tenant-default', + subject: { + type: 'Cve', + cveId: 'CVE-2026-2222', + component: 'pkg:npm/example@1.2.3', + }, + claims: [], + evidence: [], + context: { + runId: 'run-ai-001', + conversationId: 'conv-001', + generatedBy: 'AdvisoryAI v2.1', + }, +}; + +describe('evidence capsules cutover contract', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideRouter([])], + }); + }); + + it('keeps canonical capsules mounted under the Evidence shell', () => { + const paths = EVIDENCE_ROUTES.map((route) => route.path); + + expect(paths).toContain('capsules'); + expect(paths).toContain('capsules/:capsuleId'); + }); + + it('retargets legacy evidence-pack bookmarks to canonical capsule routes', () => { + const evidencePacks = routes.find((route) => route.path === 'evidence-packs'); + const children = evidencePacks?.children ?? []; + + expect(resolveRedirect(children.find((route) => route.path === ''))).toBe( + '/evidence/capsules?scope=release#capsule', + ); + expect(resolveRedirect(children.find((route) => route.path === ':capsuleId'), { capsuleId: 'cap-rel-001' })).toBe( + '/evidence/capsules/cap-rel-001?scope=release#capsule', + ); + }); +}); + +describe('EvidencePackListComponent canonical navigation', () => { + it('opens capsule detail on the canonical evidence route with a return-to link', async () => { + const evidenceApi = jasmine.createSpyObj('EvidencePackApi', [ + 'list', + 'listByRun', + 'get', + 'create', + 'sign', + 'verify', + 'export', + ]) as jasmine.SpyObj; + evidenceApi.list.and.returnValue(of({ count: 0, packs: [] })); + evidenceApi.listByRun.and.returnValue(of({ count: 0, packs: [] })); + + await TestBed.configureTestingModule({ + imports: [EvidencePackListComponent], + providers: [ + provideRouter([]), + { provide: EVIDENCE_PACK_API, useValue: evidenceApi }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(EvidencePackListComponent); + const component = fixture.componentInstance; + const router = TestBed.inject(Router); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + Object.defineProperty(router, 'url', { + configurable: true, + get: () => '/evidence/capsules?scope=release&runId=run-rel-001', + }); + + const pack: EvidencePackSummary = { + packId: 'cap-rel-001', + tenantId: 'tenant-default', + createdAt: '2026-03-08T10:00:00Z', + subjectType: 'Finding', + cveId: 'CVE-2026-1111', + claimCount: 4, + evidenceCount: 7, + }; + + component.onSelect(pack); + + expect(navigateSpy).toHaveBeenCalledWith(['/evidence/capsules', 'cap-rel-001'], { + queryParams: { returnTo: '/evidence/capsules?scope=release&runId=run-rel-001' }, + }); + }); +}); + +describe('EvidencePackViewerComponent capsule routing and handoffs', () => { + it('hydrates from the canonical capsule param and opens the release run evidence tab with a return-to link', async () => { + const evidenceApi = jasmine.createSpyObj('EvidencePackApi', [ + 'get', + 'list', + 'listByRun', + 'create', + 'sign', + 'verify', + 'export', + ]) as jasmine.SpyObj; + evidenceApi.get.and.returnValue(of(releaseCapsule)); + + await TestBed.configureTestingModule({ + imports: [EvidencePackViewerComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + paramMap: of(convertToParamMap({ capsuleId: 'cap-rel-001' })), + queryParamMap: of(convertToParamMap({ returnTo: '/evidence/capsules?scope=release' })), + }, + }, + { provide: EVIDENCE_PACK_API, useValue: evidenceApi }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(EvidencePackViewerComponent); + const component = fixture.componentInstance; + const router = TestBed.inject(Router); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + fixture.detectChanges(); + + expect(component.packId).toBe('cap-rel-001'); + expect(component.returnTo()).toBe('/evidence/capsules?scope=release'); + expect(component.backLabel()).toBe('Back to Previous Context'); + + component.onNavigateToRun('run-rel-001'); + + expect(navigateSpy).toHaveBeenCalledWith(['/releases/runs', 'run-rel-001', 'evidence'], { + queryParams: { + returnTo: buildContextReturnTo( + router, + ['/evidence/capsules', 'cap-rel-001'], + { returnTo: '/evidence/capsules?scope=release' }, + ), + }, + }); + }); + + it('returns to the originating context when a return-to query is present', async () => { + const evidenceApi = jasmine.createSpyObj('EvidencePackApi', [ + 'get', + 'list', + 'listByRun', + 'create', + 'sign', + 'verify', + 'export', + ]) as jasmine.SpyObj; + evidenceApi.get.and.returnValue(of(releaseCapsule)); + + await TestBed.configureTestingModule({ + imports: [EvidencePackViewerComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + paramMap: of(convertToParamMap({ capsuleId: 'cap-rel-001' })), + queryParamMap: of(convertToParamMap({ returnTo: '/ops/operations/ai-runs/run-ai-001' })), + }, + }, + { provide: EVIDENCE_PACK_API, useValue: evidenceApi }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(EvidencePackViewerComponent); + const component = fixture.componentInstance; + const router = TestBed.inject(Router); + const navigateByUrlSpy = spyOn(router, 'navigateByUrl').and.returnValue(Promise.resolve(true)); + + fixture.detectChanges(); + component.goBack(); + + expect(navigateByUrlSpy).toHaveBeenCalledWith('/ops/operations/ai-runs/run-ai-001'); + }); + + it('sends AI-generated capsules back to the live AI Runs shell', async () => { + const evidenceApi = jasmine.createSpyObj('EvidencePackApi', [ + 'get', + 'list', + 'listByRun', + 'create', + 'sign', + 'verify', + 'export', + ]) as jasmine.SpyObj; + evidenceApi.get.and.returnValue(of(aiCapsule)); + + await TestBed.configureTestingModule({ + imports: [EvidencePackViewerComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + paramMap: of(convertToParamMap({ capsuleId: 'cap-ai-001' })), + queryParamMap: of(convertToParamMap({})), + }, + }, + { provide: EVIDENCE_PACK_API, useValue: evidenceApi }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(EvidencePackViewerComponent); + const component = fixture.componentInstance; + const router = TestBed.inject(Router); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + fixture.detectChanges(); + component.onNavigateToRun('run-ai-001'); + + expect(navigateSpy).toHaveBeenCalledWith(['/ops/operations/ai-runs', 'run-ai-001'], { + queryParams: { + returnTo: buildContextReturnTo(router, ['/evidence/capsules', 'cap-ai-001']), + }, + }); + }); +}); + +describe('AiRunViewerComponent capsule handoff', () => { + it('deep-links evidence-pack events into canonical capsule detail routes', async () => { + const aiRunsApi = jasmine.createSpyObj('AiRunsApi', [ + 'get', + 'list', + 'getTimeline', + 'getArtifacts', + 'create', + 'addTurn', + 'proposeAction', + 'submitApproval', + 'complete', + 'createAttestation', + 'cancel', + ]) as jasmine.SpyObj; + const aiRun: AiRun = { + runId: 'run-ai-001', + tenantId: 'tenant-default', + userId: 'operator@example.com', + status: 'complete', + createdAt: '2026-03-08T10:00:00Z', + updatedAt: '2026-03-08T10:05:00Z', + completedAt: '2026-03-08T10:05:00Z', + timeline: [], + artifacts: [], + conversationId: 'conv-001', + }; + aiRunsApi.get.and.returnValue(of(aiRun)); + + await TestBed.configureTestingModule({ + imports: [AiRunViewerComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + paramMap: of(convertToParamMap({ runId: 'run-ai-001' })), + }, + }, + { provide: AI_RUNS_API, useValue: aiRunsApi }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(AiRunViewerComponent); + const component = fixture.componentInstance; + const router = TestBed.inject(Router); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + fixture.detectChanges(); + component.onNavigateToEvidencePack('cap-ai-001'); + + expect(navigateSpy).toHaveBeenCalledWith(['/evidence/capsules', 'cap-ai-001'], { + queryParams: { + returnTo: buildContextReturnTo(router, ['/ops/operations/ai-runs', 'run-ai-001']), + }, + }); + }); +}); diff --git a/src/Web/StellaOps.Web/tests/e2e/evidence-capsules-cutover.spec.ts b/src/Web/StellaOps.Web/tests/e2e/evidence-capsules-cutover.spec.ts new file mode 100644 index 000000000..7b93ee479 --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/evidence-capsules-cutover.spec.ts @@ -0,0 +1,477 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +import type { StubAuthSession } from '../../src/app/testing/auth-fixtures'; + +const operatorSession: StubAuthSession = { + subjectId: 'evidence-capsules-e2e-user', + tenant: 'tenant-default', + scopes: [ + 'admin', + 'ui.read', + 'ui.admin', + 'release:read', + 'policy:read', + 'policy:audit', + 'signer:read', + ], +}; + +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 aiRun = { + runId: 'run-ai-001', + tenantId: operatorSession.tenant, + userId: 'operator@example.com', + conversationId: 'conv-001', + status: 'complete', + createdAt: '2026-03-08T10:00:00Z', + updatedAt: '2026-03-08T10:05:00Z', + completedAt: '2026-03-08T10:05:00Z', + timeline: [ + { + eventId: 'event-001', + type: 'evidence_pack_created', + timestamp: '2026-03-08T10:02:00Z', + content: { + kind: 'evidence_pack', + packId: 'cap-ai-001', + claimCount: 3, + evidenceCount: 5, + contentDigest: 'sha256:cap-ai-001', + }, + }, + ], + artifacts: [], +}; + +const aiCapsule = { + packId: 'cap-ai-001', + version: '1.0.0', + createdAt: '2026-03-08T10:02:30Z', + tenantId: operatorSession.tenant, + subject: { + type: 'Cve', + cveId: 'CVE-2026-2222', + component: 'pkg:npm/example@1.2.3', + }, + claims: [ + { + claimId: 'claim-ai-001', + text: 'The vulnerability is not exploitable in this conversation scope.', + type: 'VulnerabilityStatus', + status: 'not_affected', + confidence: 0.91, + evidenceIds: ['ev-ai-001'], + source: 'ai', + }, + ], + evidence: [ + { + evidenceId: 'ev-ai-001', + type: 'Attestation', + uri: 'stella://attestor/cap-ai-001', + digest: 'sha256:ev-ai-001', + collectedAt: '2026-03-08T10:02:10Z', + snapshot: { + type: 'attestation', + data: { signed: true }, + }, + }, + ], + context: { + runId: 'run-ai-001', + conversationId: 'conv-001', + generatedBy: 'AdvisoryAI v2.1', + }, +}; + +const releaseCapsule = { + packId: 'cap-rel-001', + version: '1.0.0', + createdAt: '2026-03-08T10:03:00Z', + tenantId: operatorSession.tenant, + subject: { + type: 'Finding', + findingId: 'finding-rel-001', + cveId: 'CVE-2026-1111', + component: 'pkg:oci/payments@4.2.0', + }, + claims: [ + { + claimId: 'claim-rel-001', + text: 'Release evidence is fully signed and replay matched.', + type: 'Compliance', + status: 'verified', + confidence: 0.98, + evidenceIds: ['ev-rel-001'], + source: 'system', + }, + ], + evidence: [ + { + evidenceId: 'ev-rel-001', + type: 'Policy', + uri: 'stella://policy/run-rel-001', + digest: 'sha256:ev-rel-001', + collectedAt: '2026-03-08T10:02:45Z', + snapshot: { + type: 'policy', + data: { verdict: 'pass' }, + }, + }, + ], + context: { + runId: 'run-rel-001', + generatedBy: 'Release Orchestrator', + }, +}; + +const releaseRunDetail = { + runId: 'run-rel-001', + releaseId: 'rel-001', + releaseName: 'Payments API', + releaseSlug: 'payments-api', + releaseType: 'standard', + releaseVersionId: 'ver-001', + releaseVersionNumber: 42, + releaseVersionDigest: 'sha256:release-001', + lane: 'standard', + status: 'running', + outcome: 'in_progress', + targetEnvironment: 'prod', + targetRegion: 'eu-west', + scopeSummary: 'stage -> prod', + requestedAt: '2026-03-08T09:58:00Z', + updatedAt: '2026-03-08T10:04:00Z', + needsApproval: false, + blockedByDataIntegrity: false, + correlationKey: 'corr-rel-001', + statusRow: { + runStatus: 'running', + gateStatus: 'passed', + approvalStatus: 'not-required', + dataTrustStatus: 'healthy', + }, +}; + +const releaseRunEvidence = { + runId: 'run-rel-001', + replayDeterminismVerdict: 'match', + replayMismatch: false, + signatureStatus: 'verified', +}; + +const releaseRunTimeline = { + runId: 'run-rel-001', + events: [ + { + eventId: 'timeline-001', + eventClass: 'scan_completed', + phase: 'ingest', + status: 'completed', + occurredAt: '2026-03-08T09:59:00Z', + message: 'Ingest and scan completed for Payments API release.', + }, + { + eventId: 'timeline-002', + eventClass: 'gate_passed', + phase: 'gate', + status: 'passed', + occurredAt: '2026-03-08T10:00:30Z', + message: 'Policy gate passed without blockers.', + }, + { + eventId: 'timeline-003', + eventClass: 'evidence_verified', + phase: 'evidence', + status: 'completed', + occurredAt: '2026-03-08T10:02:45Z', + message: 'Evidence bundle signatures verified.', + }, + { + eventId: 'timeline-004', + eventClass: 'deployment_running', + phase: 'deployment', + status: 'running', + occurredAt: '2026-03-08T10:04:00Z', + message: 'Production deployment is in progress.', + }, + ], +}; + +const releaseRunGateDecision = { + runId: 'run-rel-001', + verdict: 'passed', + blockers: [], + riskBudgetDelta: 0, +}; + +const releaseRunApprovals = { + runId: 'run-rel-001', + checkpoints: [], +}; + +const releaseRunDeployments = { + runId: 'run-rel-001', + targets: [ + { + targetId: 'target-001', + targetName: 'payments-prod-eu-west', + environment: 'prod', + region: 'eu-west', + status: 'running', + }, + ], +}; + +const releaseRunSecurityInputs = { + runId: 'run-rel-001', + reachabilityCoveragePercent: 97, + feedFreshnessStatus: 'fresh', + vexStatementsApplied: 2, + exceptionsApplied: 0, +}; + +const releaseRunReplay = { + runId: 'run-rel-001', + verdict: 'match', +}; + +const releaseRunAudit = { + runId: 'run-rel-001', + entries: [ + { + auditId: 'audit-001', + action: 'evidence_verified', + actorId: 'release-orchestrator', + occurredAt: '2026-03-08T10:02:45Z', + correlationKey: 'corr-rel-001', + }, + ], +}; + +async function fulfillJson(route: Route, body: unknown, status = 200): Promise { + await route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(body), + }); +} + +async function setupHarness(page: Page): Promise { + await page.addInitScript((session) => { + (window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session; + }, operatorSession); + + 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('**/console/branding**', (route) => + fulfillJson(route, { + tenantId: operatorSession.tenant, + appName: 'Stella Ops', + logoUrl: null, + cssVariables: {}, + }), + ); + await page.route('**/console/profile**', (route) => + fulfillJson(route, { + subjectId: operatorSession.subjectId, + username: 'evidence-capsules-e2e', + displayName: 'Evidence Capsules E2E', + tenant: operatorSession.tenant, + roles: ['operator'], + scopes: operatorSession.scopes, + }), + ); + await page.route('**/console/token/introspect**', (route) => + fulfillJson(route, { + active: true, + tenant: operatorSession.tenant, + subject: operatorSession.subjectId, + scopes: operatorSession.scopes, + }), + ); + await page.route('**/authority/console/tenants**', (route) => + fulfillJson(route, { + tenants: [ + { + tenantId: operatorSession.tenant, + displayName: 'Default Tenant', + isDefault: true, + isActive: true, + }, + ], + }), + ); + await page.route('**/console/tenants**', (route) => + fulfillJson(route, { + tenants: [ + { + id: operatorSession.tenant, + displayName: 'Default Tenant', + status: 'active', + isolationMode: 'shared', + defaultRoles: ['admin'], + }, + ], + }), + ); + 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: 'Production', + sortOrder: 1, + enabled: true, + }, + ]), + ); + await page.route('**/api/v2/context/preferences**', (route) => + fulfillJson(route, { + tenantId: operatorSession.tenant, + actorId: operatorSession.subjectId, + regions: ['eu-west'], + environments: ['prod'], + timeWindow: '24h', + stage: 'all', + updatedAt: '2026-03-08T09:30:00Z', + updatedBy: operatorSession.subjectId, + }), + ); + + await page.route('**/v1/runs/run-ai-001**', (route) => fulfillJson(route, aiRun)); + await page.route('**/v1/evidence-packs/cap-ai-001**', (route) => fulfillJson(route, aiCapsule)); + await page.route('**/v1/evidence-packs/cap-rel-001**', (route) => fulfillJson(route, releaseCapsule)); + + await page.route('**/api/**', async (route) => { + const requestUrl = route.request().url(); + if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/timeline')) { + return fulfillJson(route, releaseRunTimeline); + } + if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/gate-decision')) { + return fulfillJson(route, releaseRunGateDecision); + } + if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/approvals')) { + return fulfillJson(route, releaseRunApprovals); + } + if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/deployments')) { + return fulfillJson(route, releaseRunDeployments); + } + if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/security-inputs')) { + return fulfillJson(route, releaseRunSecurityInputs); + } + if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/evidence')) { + return fulfillJson(route, releaseRunEvidence); + } + if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/replay')) { + return fulfillJson(route, releaseRunReplay); + } + if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/audit')) { + return fulfillJson(route, releaseRunAudit); + } + if (requestUrl.includes('/api/v2/releases/runs/run-rel-001')) { + return fulfillJson(route, releaseRunDetail); + } + if (requestUrl.includes('/api/v1/workflows/run-rel-001')) { + return route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'not found' }), + }); + } + + return fulfillJson(route, {}); + }); + await page.route('**/gateway/**', (route) => { + const requestUrl = route.request().url(); + if (requestUrl.includes('/v1/runs/run-ai-001')) { + return fulfillJson(route, aiRun); + } + if (requestUrl.includes('/v1/evidence-packs/cap-ai-001')) { + return fulfillJson(route, aiCapsule); + } + if (requestUrl.includes('/v1/evidence-packs/cap-rel-001')) { + return fulfillJson(route, releaseCapsule); + } + + return fulfillJson(route, {}); + }); + await page.route('**/policy/**', (route) => fulfillJson(route, {})); + await page.route('**/scanner/**', (route) => fulfillJson(route, {})); + await page.route('**/concelier/**', (route) => fulfillJson(route, {})); + await page.route('**/attestor/**', (route) => fulfillJson(route, {})); +} + +test.beforeEach(async ({ page }) => { + await setupHarness(page); +}); + +test('AI runs deep-link into canonical decision capsules and return to the live AI run context', async ({ page }) => { + await page.goto('/ops/operations/ai-runs/run-ai-001', { waitUntil: 'networkidle' }); + + await expect(page.getByRole('heading', { name: 'AI Run' })).toBeVisible(); + await page.getByRole('button', { name: 'cap-ai-001' }).click(); + + await expect(page).toHaveURL(/\/evidence\/capsules\/cap-ai-001\?returnTo=/); + await expect(page.getByRole('heading', { name: 'Decision Capsule' })).toBeVisible(); + + await page.getByRole('button', { name: /Back to Previous Context/i }).click(); + + await expect(page).toHaveURL(/\/ops\/operations\/ai-runs\/run-ai-001$/); + await expect(page.getByRole('heading', { name: 'AI Run' })).toBeVisible(); +}); + +test('legacy evidence-pack bookmarks land on canonical capsules and related runs open the live release workspace', async ({ page }) => { + await page.goto('/evidence-packs/cap-rel-001?scope=release', { waitUntil: 'networkidle' }); + + await expect(page).toHaveURL(/\/evidence\/capsules\/cap-rel-001\?scope=release$/); + await expect(page.getByRole('heading', { name: 'Decision Capsule' })).toBeVisible(); + + await page.getByRole('button', { name: 'run-rel-001' }).click(); + + await expect(page).toHaveURL(/\/releases\/runs\/run-rel-001\/evidence\?returnTo=/); + await expect(page.getByRole('heading', { name: 'Payments API' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Determinism', exact: true })).toBeVisible(); +});