Files
ScadaBridge/docs/plans/2026-09-06-flow-debugger-followups.md
T

437 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Flow Debugger Follow-ups Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Close the two loose ends left by the outbound-calls slice (`docs/plans/2026-09-06-flow-debugger-outbound-calls.md`): (1) diagnose the login-disabled (`DisableLogin`) interop stall on the Flow Debugger page and prove a browser Live run with a real, repeatable Playwright test; (2) deliver deferred register row 111 — `?correlationId=` deep links from an External-lane node to the Site Calls / Notification report pages, and Retry / Discard directly on a Parked node.
**Architecture:** The graph already folds an operation's audit rows by `CorrelationId`; the step just does not *carry* it. `FlowStep` gains an additive `CorrelationId`, the two report pages gain an additive per-id query filter (Commons request → actor → Commons filter → repository, all optional and default-null) and a `?correlationId=` seed that auto-opens the row's detail modal, and `FlowStepDetail` links to them. Retry/Discard on the node reuse the exact relay the report pages use (`CommunicationService.RetrySiteCallAsync` / `RetryNotificationAsync` and the Discard twins) behind two new `IFlowDebugService` methods so the page stays unit-testable; the page applies the same site-scope re-check + confirm dialog as Site Calls and then re-polls Replay until the acted node leaves `Parked`. The browser Live run is verified by a new Playwright test that logs in through the fixture's existing `/auth/login` helper and runs an ephemeral method it creates itself through the CLI, so no rig seed data and no `DisableLogin` override are needed for UI checks going forward.
**Tech Stack:** .NET 10, Blazor Server (bUnit tests), Akka.NET actors (Site Call Audit, Notification Outbox), EF Core + raw SQL (`SiteCallAuditRepository`), NSubstitute/xunit, Playwright (Docker `scadabridge-playwright` → `http://scadabridge-traefik`), docker rig (`docker/`).
**Decisions (D1–D7):**
- **D1** `FlowStep.CorrelationId` (`Guid?`) is a trailing optional record parameter, set by `FlowStepProjector.Project` from `AuditEvent.CorrelationId` for every step (outbound or not — it is the audit row's value, harmless elsewhere). No wire change: `FlowStep` is a central projection.
- **D2** Deep-link query key is `correlationId` on BOTH pages (`/site-calls/report?correlationId=<guid>`, `/notifications/report?correlationId=<id>`), matching the Audit Log page's existing `?correlationId=` convention. The page seeds a read-only "Operation" / "Notification" pill filter, includes it in the query, and auto-opens the detail modal when exactly one row comes back. Clearing filters drops it.
- **D3** The per-id filters are additive optional parameters: `SiteCallQueryRequest.TrackedOperationIdFilter` (`Guid?`), `SiteCallQueryFilter.TrackedOperationId` (`Guid?`), `NotificationOutboxQueryRequest.NotificationIdFilter` (`string?`), `NotificationOutboxFilter.NotificationId` (`string?`). Exact match; null = no filter.
- **D4** Node actions live in `IFlowDebugService.RetryOutboundAsync(FlowStep, ct)` / `DiscardOutboundAsync(FlowStep, ct)` returning `FlowOutboundActionResult(bool Success, string Message)`. Kind routing: `Notify` → notification outbox relay (needs `CorrelationId` as the `NotificationId` string, `"D"` format); `ApiCall`/`DbCall` → site-call relay (needs `CorrelationId` + `Step.SiteId` as `SourceSite`). A step with no `CorrelationId`, no `SiteId` (site kinds) or a non-Parked outcome is refused with a message, never relayed.
- **D5** The page gates the buttons on: Deployer role (page policy already), step `Outcome == Parked`, `CorrelationId != null`, and the site-scope re-check (`SiteScopeService.PermittedSiteIdsAsync` against the step's `SiteId`, exactly like `SiteCallsReport.IsRowSiteAllowedAsync`). Confirm dialog wording copies the report pages (Discard = danger + "Discard" label).
- **D6** After a successful relay the page runs the existing bounded refresh loop with a widened predicate: keep polling while any step is `Pending` **or** the acted step is still `Parked`; same 5 s / 2 min bounds. A refused/failed relay does not poll.
- **D7** The Playwright test creates its own inbound method (`zztest-flow-<stamp>`, script `return new { ok = true, echo = Input.Text };`) via `CliRunner`, deletes it on dispose, is `SkippableFact` on cluster unavailability, and asserts: method persists across a page reload (the localStorage path that stalled), a Live run reaches the `Completed` state chip with ≥ 2 steps, and the Response node is selectable. It runs against the rig image built from this branch.
**Task 0 outcome shapes the rest:** if the stall turns out to be a page bug, its fix is committed in Task 0 and Task 9 guards it. If it is an artefact of the login-disabled mode or of the Chrome extension, Task 0 records the finding (memory + `docker/README.md`) and changes no product code.
---
### Task 0: Diagnose the login-disabled interop stall (investigation)
**Classification:** high-risk (touches rig state; conclusion drives product code or not)
**Estimated implement time:** ~5 min of hands-on, plus rig recreate waits
**Parallelizable with:** none
**Files:**
- Read: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs:207-260` (restore path), `:636-680` (persist)
- Read: `src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs:110-140` (DisableLogin scheme swap)
- Read: `src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs`
- Read: `docker/docker-compose.yml:75-140` (central-a service)
- Create (scratchpad, NOT committed): `<scratchpad>/disable-login.override.yml`
- Modify (only if a product defect is found): `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs` + `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs`
- Modify: memory file `delmia-rig-standin-state.md` (replace the "Cause not diagnosed" sentence with the finding)
Use the `superpowers:systematic-debugging` skill for this task (reproduce → observe → hypothesis → confirm), and the Chrome tools with `read_console_messages` + `read_network_requests` for observation.
**Step 1: Recreate central-a with login disabled**
```yaml
# <scratchpad>/disable-login.override.yml
services:
central-a:
environment:
ScadaBridge__Security__Auth__DisableLogin: "true"
```
Run: `docker compose -f docker/docker-compose.yml -f <scratchpad>/disable-login.override.yml up -d --force-recreate --no-deps central-a`
Expected: `scadabridge-central-a` recreated; `curl -s http://localhost:9001/health/ready` returns 200 within ~60 s; central-a's log carries the `AUTH DISABLED` warning.
**Step 2: Reproduce, bare page**
Open `http://localhost:9001/deployment/flow-debugger` (direct port, NOT Traefik — Traefik may route the SignalR negotiate to central-b, which still requires login). Wait 5 s. Then:
- `read_console_messages` (pattern `blazor|circuit|WebSocket|error|Error`) and `read_network_requests` (look at `/_blazor?id=`, `/_blazor/negotiate`, status codes).
- Click the *Value change* / *Method* entry toggle. Does the UI respond?
Record: circuit connected or not (a `101` on `_blazor` websocket, or a failed negotiate), any JS error, any `circuit disconnected` / reconnect modal.
**Step 3: Reproduce, `?executionId=` page**
Open `http://localhost:9001/deployment/flow-debugger?executionId=<a root id from a previous run — the CLI: scadabridge --url http://localhost:9001 audit query --channel ScriptTrace --limit 1>`. Confirm the graph renders, click a node (does detail update?), then switch entry to *Method* and pick a method (this hits `PersistAsync`). Capture console + network again after each step.
**Step 4: Discriminate the hypotheses**
| Observation | Conclusion | Action |
|---|---|---|
| `/_blazor/negotiate` or the websocket returns 401/403/redirect, no `101` | Circuit never connects under the auto-login scheme (prerendered HTML only — "inert from load"). Product defect in `AutoLoginAuthenticationHandler` / hub auth. | Fix in Security (handler must succeed for the SignalR hub request too) + a Security test; re-run Steps 2–3. |
| Circuit connects, then a `JSException` / `Microsoft.JSInterop` timeout or `Error: Could not find 'localStorage.getItem'` in console | Interop identifier resolution failing in this Chrome build. | Wrap the page's four getItem/setItem sites in a tiny `wwwroot/js/flow-debugger.js` (`window.scadaFlowStorage = {get,set}`) and call those instead; bUnit `JSInterop.Mode = Loose` stays valid. Add a page test that the restore path calls the new identifiers. |
| Circuit connects, the call returns, but the page stops re-rendering (no error) | Exception swallowed on the restore path leaving a broken `OnAfterRenderAsync` chain (e.g. `LoadInstancesAsync` throwing on a site outside scope, with `StateHasChanged` never reached) | Move `StateHasChanged()` into a `finally`; log the caught exception at Debug; add a page test where a restored site id is not in `_sites`. |
| Everything works in the plain browser session | The 2026-09-06 stall was a Chrome-extension automation artefact (e.g. the extension's click landed before the circuit reconnected after the override recreate) | No product change. Record in memory + `docker/README.md` that login-disabled UI checks work via direct port `9001`, and that Playwright (Task 9) is the supported route. |
**Step 5: Apply the fix (if any) with a test first**, run `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter FullyQualifiedName~FlowDebuggerPageTests` → PASS, commit:
```bash
git commit -am "fix(central-ui): Flow Debugger <finding> under login-disabled mode"
```
**Step 6: Restore central-a**
Run: `docker compose -f docker/docker-compose.yml up -d --force-recreate --no-deps central-a`
Expected: `curl -s http://localhost:9001/health/ready` → 200; `curl -s -o /dev/null -w '%{http_code}' http://localhost:9001/deployment/flow-debugger` → 302 (login required again).
**Step 7: Record the finding** in the memory file (replace the "Cause not diagnosed" clause) and, if no code changed, in `docker/README.md` under the Flow Debugger demo section. Commit docs only if `docker/README.md` changed.
---
### Task 1: `FlowStep.CorrelationId` + CLI column
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 2, Task 3
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs:84-103`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs:17-48`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs:120-150` (+ the doc comment at :21)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (flow-debug table columns)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorTests.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs`
**Step 1: Failing tests**
In `FlowStepProjectorTests.cs`:
```csharp
[Fact]
public void Project_CarriesTheAuditRowsCorrelationId()
{
var corr = Guid.NewGuid();
var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
occurredAtUtc: T0, target: "Delmia.Ack", executionId: Guid.NewGuid(), correlationId: corr, sourceSiteId: "site-a");
Assert.Equal(corr, FlowStepProjector.Project(evt).CorrelationId);
}
[Fact]
public void Project_NullCorrelation_LeavesCorrelationIdNull()
{
var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered,
occurredAtUtc: T0, target: "Delmia.Ack", executionId: Guid.NewGuid(), sourceSiteId: "site-a");
Assert.Null(FlowStepProjector.Project(evt).CorrelationId);
}
```
(Check the factory's parameter name for the correlation id — `grep -n "correlationId" tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs` shows how existing fold tests pass it.)
In `FlowGraphBuilderTests.cs` add to the existing folded-cached-call test an assertion that the folded step's `CorrelationId` equals the group key.
In `FlowDebugCommandsTests.cs` (the existing "folds lifecycle rows" table test): assert the row dictionary has `["correlation"] == corr.ToString("D")` for the folded row and `null` for a sync row.
**Step 2: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~FlowStepProjectorTests|FullyQualifiedName~FlowGraphBuilderTests"` → FAIL (no `CorrelationId` member).
**Step 3: Implement**
`FlowStep.cs`: append `Guid? CorrelationId = null` after `Lifecycle`, with a `<param>` doc: "The audit row's `CorrelationId` — the `TrackedOperationId` of a cached call or the `NotificationId` of a notification, the key the report pages accept as `?correlationId=`; null for sync one-shots and trace rows."
`FlowStepProjector.Project`: add `CorrelationId: evt.CorrelationId` to the constructor call. (`Fold` uses `first with { … }`, so the folded step inherits it — no builder change.)
`FlowDebugTableProjection.ProjectSteps`: add `["correlation"] = step.CorrelationId?.ToString("D")` after `lifecycle`; update the column list in the class doc comment and README (`occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error`).
**Step 4: Run** the three test projects' filtered tests → PASS. Also `dotnet build ZB.MOM.WW.ScadaBridge.slnx` (positional `FlowStep` constructions elsewhere must still compile — the parameter is trailing-optional so they do).
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging src/ZB.MOM.WW.ScadaBridge.CLI tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs
git commit -m "feat(flow-debugger): FlowStep carries the audit CorrelationId; CLI table adds a correlation column"
```
---
### Task 2: Site Calls query — filter by TrackedOperationId
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 3
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs:25-37`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallQueryFilter.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs:819-835`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs:305-345`
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs` (mirror `SiteCallQueryRequest_FilterBySourceNode_ReturnsMatchingSummaries` at :234)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs` (mirror `QueryAsync_FilterBySourceSite_ReturnsMatchingRows` at :419)
**Step 1: Failing tests**
Actor test `SiteCallQueryRequest_FilterByTrackedOperationId_ReturnsOnlyThatRow`: seed two rows via the same helper the SourceNode test uses, send a `SiteCallQueryRequest(..., TrackedOperationIdFilter: rowA.TrackedOperationId)` (named argument), assert exactly one summary with that id.
Repository test `QueryAsync_FilterByTrackedOperationId_ReturnsExactlyThatRow`: insert two rows, `repo.QueryAsync(new SiteCallQueryFilter(TrackedOperationId: idA), paging)`, assert single row `idA`. (`SkippableFact` + `Skip.IfNot(_fixture.Available, …)` like its neighbours — the MSSQL fixture is the rig's `scadabridge-mssql`, up now.)
**Step 2: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests --filter FullyQualifiedName~FilterByTrackedOperationId` → FAIL (compile: no such parameter).
**Step 3: Implement**
- `SiteCallQueryRequest`: append `Guid? TrackedOperationIdFilter = null` after `SourceNodeFilter`, doc: "Exact-match on one operation (the `?correlationId=` deep link from the Flow Debugger / Audit Log); null = no filter."
- `SiteCallQueryFilter`: append `Guid? TrackedOperationId = null`.
- Actor `QueryAsync`: `TrackedOperationId: request.TrackedOperationIdFilter`.
- Repository SQL: add `AND (@TrackedOperationId IS NULL OR TrackedOperationId = @TrackedOperationId)` after the `@Target` line; add `Param("@TrackedOperationId", SqlDbType.UniqueIdentifier, filter.TrackedOperationId)` to the parameter array (check `Param`'s null handling — it already maps null → `DBNull` for the other optional filters). Note the RECOMPILE comment already covers optional-parameter pruning; `TrackedOperationId` is the PK so the seek is free.
**Step 4: Run** both filtered test sets → PASS.
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallQueryFilter.cs src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs
git commit -m "feat(site-call-audit): additive TrackedOperationId query filter for ?correlationId= deep links"
```
---
### Task 3: Notification Outbox query — filter by NotificationId
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Notification/NotificationOutboxQueries.cs:9-21`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Notifications/NotificationOutboxFilter.cs:26-36`
- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs:930-944`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs:283-320`
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs` (mirror the `Arg.Is<NotificationOutboxFilter>` assertion at :99)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs`
**Step 1: Failing tests** — actor: request with `NotificationIdFilter: "abc"` → repository receives a filter with `NotificationId == "abc"`. Repository: two rows, filter by one `NotificationId`, exactly that row returned and `TotalCount == 1`.
**Step 2: Run** → FAIL (compile).
**Step 3: Implement** — `NotificationOutboxQueryRequest`: `string? NotificationIdFilter = null` trailing; `NotificationOutboxFilter`: `string? NotificationId = null` trailing; actor maps `NotificationId: request.NotificationIdFilter`; repository: `if (!string.IsNullOrEmpty(filter.NotificationId)) query = query.Where(n => n.NotificationId == filter.NotificationId);` placed before the `SourceSiteId` clause.
**Step 4: Run** → PASS.
**Step 5: Commit** `feat(notification-outbox): additive NotificationId query filter for ?correlationId= deep links`
---
### Task 4: Site Calls page accepts `?correlationId=`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5, Task 6, Task 7
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:96-104, 165-200, 250-270, 468-490`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor:26-100` (filter card)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs` (mirror the `?status=parked` test at :47)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs` (the scripted actor at :77 — it records the received `SiteCallQueryRequest`; assert `TrackedOperationIdFilter`)
**Step 1: Failing tests**
- `SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilterAndAutoOpensDetail`: navigate to `/site-calls/report?correlationId=<guid>`, render; assert the query request the scripted actor received has `TrackedOperationIdFilter == guid`; assert the pill `[data-test='site-calls-filter-operation']` shows the short id; with the scripted actor returning exactly one summary with that id, assert the detail modal (`[data-test='site-call-detail']` — reuse whatever selector the existing detail tests use) is open.
- `SiteCallsReport_CorrelationIdQuery_Malformed_IsIgnored`: `?correlationId=nope` → request has null filter, no pill.
- `ClearFilters_DropsTheOperationFilter`.
**Step 2: Run** → FAIL.
**Step 3: Implement**
- Field `private Guid? _operationIdFilter;`
- `ApplyQueryStringFilters`: `if (query.TryGetValue("correlationId", out var corr) && Guid.TryParse(corr.ToString(), out var op)) _operationIdFilter = op;`
- `FetchPage`: pass `TrackedOperationIdFilter: _operationIdFilter` into `SiteCallQueryRequest`.
- After the first successful fetch in `OnInitializedAsync` (after `RefreshAll()`): `if (_operationIdFilter is { } op && _siteCalls?.Count == 1 && _siteCalls[0].TrackedOperationId == op) await ShowDetail(_siteCalls[0]);`
- Filter card: when `_operationIdFilter` is set render a `badge` pill `data-test="site-calls-filter-operation"` with `Operation <short id>` and an `×` button that clears it and re-runs `Search()`.
- `ClearFilters`: `_operationIdFilter = null;` and `HasActiveFilters` includes it.
**Step 4: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~SiteCallsReport|FullyQualifiedName~QueryStringDrillIn"` → PASS.
**Step 5: Commit** `feat(central-ui): Site Calls page accepts ?correlationId= and opens the operation's detail`
---
### Task 5: Notification report page accepts `?correlationId=`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4, Task 6, Task 7
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor` (filters :20-100, `@code` :400-520, `OnInitializedAsync` :442)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs`
- Test: the existing NotificationReport page test file (`grep -rln "NotificationReport" tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests` — use the one that scripts `QueryNotificationOutboxAsync`)
**Step 1–5:** Same shape as Task 4 with `_notificationIdFilter` (`string?`, accepted as-is when non-blank, trimmed, max 64 chars), `NotificationIdFilter:` on `NotificationOutboxQueryRequest`, pill `data-test="notif-filter-notification"`, auto-open `ShowDetail` when the single returned row's `NotificationId` matches. Page has no `ApplyQueryStringFilters` yet — add one (inject `NavigationManager` if missing; `QueryHelpers.ParseQuery` as in Site Calls).
Commit: `feat(central-ui): Notification report accepts ?correlationId= and opens the notification's detail`
---
### Task 6: `FlowStepDetail` — report deep links + Retry/Discard controls
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4, Task 5, Task 7
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor` (Lifecycle parked hint :106-110; drill-in row :138-152; `@code` :158-215)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs`
**Step 1: Failing tests**
- `OutboundStepWithCorrelation_LinksToTheReportPage`: ApiCall step with `CorrelationId = c` and a cached lifecycle → `a[data-test='open-report']` href `/site-calls/report?correlationId=<c>` text "Open in Site Calls"; Notify step → `/notifications/report?correlationId=<c>`, text "Open in Notification report". Sync ApiCall (null correlation) → no link.
- `ParkedStep_WithCallbacks_ShowsRetryAndDiscard`: Parked step + `OnRetry`/`OnDiscard` callbacks set → buttons `flow-step-retry` / `flow-step-discard` present; click Retry → callback receives the step. `ActionInProgress = true` → both disabled.
- `ParkedStep_WithoutCallbacks_ShowsOnlyTheHintLink`: no buttons; the parked hint now contains the report link.
- `OkStep_NeverShowsActions`.
**Step 2: Run** → FAIL.
**Step 3: Implement**
Parameters: `[Parameter] public EventCallback<FlowStep> OnRetry { get; set; }`, `OnDiscard`, `[Parameter] public bool ActionInProgress { get; set; }`.
Helpers:
```csharp
private string? ReportHref => Step is { CorrelationId: { } c } s && IsOutbound && s.Lifecycle is { Count: > 0 }
? s.Kind == FlowStepKind.Notify
? $"/notifications/report?correlationId={c:D}"
: $"/site-calls/report?correlationId={c:D}"
: null;
private string ReportLabel => Step?.Kind == FlowStepKind.Notify ? "Open in Notification report" : "Open in Site Calls";
private bool ShowActions => Step is { Outcome: FlowStepOutcome.Parked, CorrelationId: not null } && IsOutbound;
```
Markup: in the drill-in button row add `@if (ReportHref is { } rh) { <a class="btn btn-outline-secondary btn-sm" data-test="open-report" href="@rh">@ReportLabel</a> }`; below it, `@if (ShowActions && (OnRetry.HasDelegate || OnDiscard.HasDelegate))` render `button.btn-outline-primary.btn-sm[data-test=flow-step-retry]` "Retry" and `button.btn-outline-danger.btn-sm[data-test=flow-step-discard]` "Discard", each `disabled="@ActionInProgress"` and `@onclick="() => OnRetry.InvokeAsync(Step!)"`. Parked hint text becomes: `Parked — retry or discard here, or <a href="@ReportHref">open the report page</a>.` (keep `data-test="flow-step-parked-hint"`).
**Step 4: Run** `--filter FullyQualifiedName~FlowStepDetailTests` → PASS.
**Step 5: Commit** `feat(central-ui): Flow Debugger step detail links to the report page and exposes Retry/Discard on a parked node`
---
### Task 7: `IFlowDebugService.RetryOutboundAsync` / `DiscardOutboundAsync`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4, Task 5, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs:43-62` (ctor gains `CommunicationService comms`) + new methods
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs` (construct `CommunicationService` the way `SiteCallsReportPageTests.cs:77-82` does — `new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger…)` + `SetSiteCallAudit(probe)` / the notification-outbox setter; the existing `ScriptedSiteCallAuditActor` pattern there shows how to script a reply)
**Step 1: Failing tests**
- `RetryOutbound_ApiCallParked_RelaysRetrySiteCallWithCorrelationAndSite`: step Kind ApiCall, Parked, `CorrelationId = c`, `SiteId = "site-a"` → the site-call-audit probe receives `RetrySiteCallRequest` with `TrackedOperationId == c`, `SourceSite == "site-a"`, `RequestedBy == "deployer"`; scripted reply `Applied` → result `Success == true`, message contains "relayed".
- `RetryOutbound_Notify_RelaysRetryNotification`: Notify step → `RetryNotificationRequest.NotificationId == c.ToString("D")`.
- `DiscardOutbound_DbCall_RelaysDiscardSiteCall`.
- `RetryOutbound_NotParked_RefusesWithoutRelay` (Outcome Ok → `Success false`, probe receives nothing).
- `RetryOutbound_NoCorrelation_Refuses`; `RetryOutbound_SiteKindWithoutSiteId_Refuses`.
- `RetryOutbound_SiteUnreachable_MapsToFailureMessage` (scripted `SiteUnreachable` → Success false, message contains "unreachable").
**Step 2: Run** → FAIL.
**Step 3: Implement**
```csharp
/// <summary>Outcome of a Retry / Discard relayed from a Flow Debugger node.</summary>
public sealed record FlowOutboundActionResult(bool Success, string Message);
Task<FlowOutboundActionResult> RetryOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
Task<FlowOutboundActionResult> DiscardOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
```
`FlowDebugService`: private `RelayAsync(FlowStep step, bool retry, ct)`:
1. Guards (D4) → `new(false, "…")`.
2. `requestedBy = await _auth.GetCurrentUsernameAsync()` (the extension in `CentralUI/Auth/ClaimsPrincipalExtensions.cs:43`).
3. Notify → `_comms.RetryNotificationAsync(new RetryNotificationRequest(Guid.NewGuid().ToString("N"), c.ToString("D"), requestedBy))` (or Discard twin) → `new(r.Success, r.Success ? $"Notification {Short(c)} re-queued." : r.ErrorMessage ?? "Retry failed.")` (Discard: "discarded").
4. ApiCall/DbCall → `_comms.RetrySiteCallAsync(new RetrySiteCallRequest(…, c, step.SiteId!, requestedBy))` → map `SiteCallRelayOutcome` with the same wording as `SiteCallsReport.ShowRelayOutcome` (`Applied` → success "Retry of {short} relayed to {site}."; `NotParked` → success:false "…no longer parked."; `SiteUnreachable` / `OperationFailed && !SiteReachable` → "Site unreachable — …"; else `ErrorMessage ?? "The site could not apply the action."`).
5. Catch → `new(false, $"Retry failed: {ex.Message}")`, log Warning.
Register nothing new — `CommunicationService` is already a singleton in DI; add it to the ctor.
**Step 4: Run** `--filter FullyQualifiedName~FlowDebugServiceTests` → PASS; `dotnet build` (the page test's `Substitute.For<IFlowDebugService>()` still compiles).
**Step 5: Commit** `feat(central-ui): FlowDebugService relays Retry/Discard for a parked outbound step`
---
### Task 8: Flow Debugger page — wire actions, confirm, scope check, re-poll
**Classification:** high-risk (async refresh loop + authorization gate)
**Estimated implement time:** ~5 min
**Parallelizable with:** none (after 6 + 7)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor` (:228-236 detail panel; add `<ToastNotification @ref="_toast" />` near the top of the page container)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs` (:70-76 injects; :417-465 refresh loop; new handlers)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs` (`RegisterServices` :73 — add `Services.AddSingleton<IDialogService>(_dialog)` with `_dialog = Substitute.For<IDialogService>()` defaulting `ConfirmAsync(...)` to `true`)
**Step 1: Failing tests**
- `ParkedNode_Retry_ConfirmsRelaysAndRepollsUntilNotParked`: Live run outcome whose graph has a Parked ApiCall step with `CorrelationId` + `SiteId "site-a"`; select it (`OnSelect` via clicking the node `g[data-test='flow-node']` matching the step), click `flow-step-retry`; assert `_dialog.Received().ConfirmAsync("Retry cached call", Arg.Any<string>(), …)`, `_flow.Received().RetryOutboundAsync(Arg.Is<FlowStep>(s => s.EventId == parked.EventId), …)`; `_flow.ReplayAsync` scripted to return the same graph once then a graph where the step is `Ok` → `Delay` seam short-circuited → assert `ReplayAsync` called ≥ 2 times and the node pill text becomes `ok`; toast text contains "relayed".
- `ParkedNode_Discard_UsesDangerConfirm_AndDoesNotPollWhenRefused`: `_flow.DiscardOutboundAsync` returns `Success false` → `ReplayAsync` never called after the click; toast shows the message.
- `ParkedNode_ConfirmDeclined_NeverRelays`.
- `ParkedNode_OutOfScopeSite_RefusedBeforeConfirm`: `SiteScopeService` stub with a permitted set that excludes site 1 → no `ConfirmAsync`, no relay, toast "not permitted".
**Step 2: Run** → FAIL.
**Step 3: Implement**
- Inject `IDialogService Dialog`; field `private ToastNotification _toast = default!; private bool _actionInProgress; private Guid? _watchedStepId;`
- `FlowStepDetail … OnRetry="OnRetryStepAsync" OnDiscard="OnDiscardStepAsync" ActionInProgress="_actionInProgress"`.
- `OnRetryStepAsync(FlowStep step)` / `OnDiscardStepAsync`: shared `ActOnStepAsync(step, retry: bool)`:
1. `if (!await IsStepSiteAllowedAsync(step)) { _toast.ShowError("You are not permitted to act on cached calls for this site."); return; }` — `IsStepSiteAllowedAsync`: Notify or null `SiteId` → true; else `await SiteScope.IsSystemWideAsync()` || `_sites.FirstOrDefault(s => s.SiteIdentifier == step.SiteId)` is null || permitted ids contain its `Id` (mirror `SiteCallsReport.IsRowSiteAllowedAsync`).
2. Confirm: Retry → `Dialog.ConfirmAsync(step.Kind == Notify ? "Retry notification" : "Retry cached call", $"…{Short(c)} (\"{step.Title}\")…?")`; Discard → `danger: true, confirmLabel: "Discard"`, message ends "This cannot be undone.". Declined → return.
3. `_actionInProgress = true;` `var r = retry ? await FlowDebug.RetryOutboundAsync(step) : await FlowDebug.DiscardOutboundAsync(step);` `if (r.Success) _toast.ShowSuccess(r.Message); else _toast.ShowError(r.Message);` finally `_actionInProgress = false`.
4. On success: `_watchedStepId = step.EventId; StartRefreshLoop();`
- Refactor `StartPendingRefresh` → `StartRefreshLoop()` allowed in BOTH modes when `_raw != null && _graph != null && ShouldKeepPolling`; `ShouldKeepPolling => PendingCount > 0 || (_watchedStepId is { } w && _graph.Steps.Any(s => s.EventId == w && s.Outcome == FlowStepOutcome.Parked))`. Loop exits when `!ShouldKeepPolling`; clear `_watchedStepId` on exit, on `SetMode`, on a new run/replay. Keep the existing "Replay mode never auto-polls after a plain replay" behaviour: the loop start after `LoadReplayAsync` stays absent — only Live runs and node actions start it (test `ReplayMode_NeverPolls_ButRefreshButtonReplaysOnce` must still pass).
- Status strip: show `outcomes still arriving — refreshing` for the action case too (already keyed on `_refreshing`).
**Step 4: Run** `--filter FullyQualifiedName~FlowDebuggerPageTests` → PASS, then the whole CentralUI test project → PASS.
**Step 5: Commit** `feat(central-ui): Retry/Discard a parked outbound node from the Flow Debugger with scope check, confirm and re-poll`
---
### Task 9: Playwright — browser Live run + localStorage restore (rig)
**Classification:** standard
**Estimated implement time:** ~5 min of code, plus a rig image rebuild (~6–8 min) and the test run
**Parallelizable with:** Task 10
**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerFixture.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs`
- Read: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs`, `Cluster/CliRunner.cs`, `Cluster/CliRunner.Helpers.cs`, `SiteCalls/SiteCallsPageTests.cs:60-120` (bind-commit idiom)
- Read: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor` (data-test hooks: `flow-method-select`, `flow-input-<Name>`, `flow-run`, `flow-state`, `flow-step-count`, `flow-node`)
**Step 1: Fixture** — `FlowDebuggerFixture : IAsyncLifetime`: `Available` gate via `ClusterAvailability.IsAvailableAsync()`; `MethodName = CliRunner.UniqueName("zztest-flow")`; create via `CliRunner.RunJsonAsync("api-method", "create", "--name", MethodName, "--script", "return new { ok = true, echo = Input.Text };", "--parameters", "[{\"name\":\"Text\",\"type\":\"String\"}]")` (check the exact `--parameters` JSON shape in `CLI/README.md` §`api-method create` and how `RunJsonAsync` is used in `DeploymentFixture`), store `MethodId`; dispose → `api-method delete --id`. Best-effort, swallow errors like the deployment fixture.
**Step 2: Tests** (`[Collection("Playwright")]`, `IClassFixture<FlowDebuggerFixture>`, `SkippableFact` + `Skip.IfNot(fixture.Available, …)`):
- `LiveRun_InBrowser_CompletesAndDrawsTheGraph`: `NewAuthenticatedPageAsync()`, goto `/deployment/flow-debugger`, select the method by its id in `select[data-test='flow-method-select']` (`SelectOptionAsync`), wait for `input[data-test='flow-input-Text']`, fill `hello` and commit with the blur idiom, click `button[data-test='flow-run']`, wait until `[data-test='flow-state']` text is `Completed` (30 s), assert `flow-step-count` ≥ 2, click the last `g[data-test='flow-node']` and assert `[data-test='flow-step-title']` is visible.
- `MethodChoice_SurvivesReload_ViaLocalStorage`: select the method, `page.ReloadAsync()`, wait for `select[data-test='flow-method-select']` to have the method id as its value (this is exactly the `OnAfterRenderAsync` restore path). Then the console must contain no `Microsoft.JSInterop` error: attach `page.Console` and assert no message with type `error` mentioning `JSInterop`.
**Step 3: Rebuild the rig from the worktree** (the `deploy.sh` worktree refusal — register row 68 — is bypassed exactly as on 2026-09-06): `bash docker/build.sh` from the worktree, then from the MAIN checkout `docker compose -f /Users/dohertj2/Desktop/ScadaBridge/docker/docker-compose.yml up -d --force-recreate` (all 8 nodes; the bootstrap guard is on, both-together recreate is the supported path). Wait for `/health/ready` on 9001 and 9002.
**Step 4: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter FullyQualifiedName~FlowDebuggerTests` → 2 passed, 0 skipped. Also re-run `FullyQualifiedName~SiteCallsPageTests` (Task 4 touched that page) → PASS.
**Step 5: Manual rig proof of the deep link + Retry (no new test):** `FLOW_ONLY=1 bash docker/verify-delmia-notifier.sh` still reports `H` and `I` YES (needs a fresh `DELMIA_API_KEY` — see memory `delmia-rig-standin-state`). Then `scadabridge --url http://localhost:9001 flow-debug replay --execution-id <root from step I>` shows the `correlation` column populated on the folded rows.
**Step 6: Commit** `test(central-ui): Playwright Flow Debugger live run + localStorage restore against the rig`
---
### Task 10: Docs, register, memory
**Classification:** trivial
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 9
**Files:**
- Modify: `docs/requirements/Component-CentralUI.md` (Flow Debugger External-lane bullet: deep links + node actions; Site Calls / Notification report pages: `?correlationId=`)
- Modify: `docs/requirements/Component-SiteCallAudit.md` (query filters — add `TrackedOperationIdFilter`)
- Modify: `docs/requirements/Component-NotificationOutbox.md` (query filters — add `NotificationIdFilter`)
- Modify: `docs/plans/2026-07-08-deferred-work-register.md` row 111 → resolved 2026-09-06 (this plan)
- Modify: `docs/plans/2026-09-05-flow-debugger-design.md` status line (v1.2: node actions + deep links)
- Modify: `CLAUDE.md` Flow Debugger bullet (one sentence: "v1.2 (2026-09-06): a Parked External node offers Retry/Discard (same relay + scope check + confirm as the report pages, then re-polls Replay) and every folded node deep-links to `/site-calls/report?correlationId=` / `/notifications/report?correlationId=`; UI checks run through Playwright (`FlowDebuggerTests`), not the login-disabled override.")
- Modify: `docker/README.md` (verification note from Task 0 if not already committed there)
- Modify: memory `delmia-rig-standin-state.md` (Task 0 finding; Playwright is the UI-check route)
**Steps:** edit, `git diff --stat`, commit `docs: Flow Debugger v1.2 — node Retry/Discard, ?correlationId= deep links, login-disabled finding`.
---
## Execution notes
- Worktree: `.claude/worktrees/flow-followups` on branch `feat/flow-debugger-followups` (the previous slice used the same layout).
- Order: 0 → (1 ‖ 2 ‖ 3) → (4 ‖ 5 ‖ 6 ‖ 7) → 8 → (9 ‖ 10). Task 9 needs the rig rebuilt from this branch; Task 0 recreates central-a twice (override on, then off) — do not overlap Task 0 with Task 9's rebuild.
- Rig facts to remember: direct ports 9001/9002 bypass Traefik; the Playwright container reaches the UI as `http://scadabridge-traefik` and logs in with the LDAP test user `multi-role`; `LoginTests` pass against the live rig today (verified 2026-09-06), so a Playwright run is a valid gate.
- Finish with `superpowers-extended-cc:finishing-a-development-branch`; the user chose "merge locally" last time — ask again.