docs(plans): Flow Debugger follow-ups — login-disabled stall diagnosis, ?correlationId= deep links, node Retry/Discard (register row 111)

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 10:48:03 -04:00
parent a3f8eb8bd4
commit 5394872040
2 changed files with 453 additions and 0 deletions
@@ -0,0 +1,436 @@
# 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.
@@ -0,0 +1,17 @@
{
"planPath": "docs/plans/2026-09-06-flow-debugger-followups.md",
"tasks": [
{"id": 0, "subject": "Task 0: Diagnose the login-disabled interop stall (investigation)", "status": "pending"},
{"id": 1, "subject": "Task 1: FlowStep.CorrelationId + CLI column", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: Site Calls query — filter by TrackedOperationId", "status": "pending", "blockedBy": [0]},
{"id": 3, "subject": "Task 3: Notification Outbox query — filter by NotificationId", "status": "pending", "blockedBy": [0]},
{"id": 4, "subject": "Task 4: Site Calls page accepts ?correlationId=", "status": "pending", "blockedBy": [2]},
{"id": 5, "subject": "Task 5: Notification report page accepts ?correlationId=", "status": "pending", "blockedBy": [3]},
{"id": 6, "subject": "Task 6: FlowStepDetail — report deep links + Retry/Discard controls", "status": "pending", "blockedBy": [1]},
{"id": 7, "subject": "Task 7: IFlowDebugService.RetryOutboundAsync / DiscardOutboundAsync", "status": "pending", "blockedBy": [1]},
{"id": 8, "subject": "Task 8: Flow Debugger page — wire actions, confirm, scope check, re-poll", "status": "pending", "blockedBy": [6, 7]},
{"id": 9, "subject": "Task 9: Playwright — browser Live run + localStorage restore (rig)", "status": "pending", "blockedBy": [8, 4, 5]},
{"id": 10, "subject": "Task 10: Docs, register, memory", "status": "pending", "blockedBy": [8]}
],
"lastUpdated": "2026-09-06T00:00:00Z"
}