From 5394872040f5096e6196aa55fb7c06c1b25cc87e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 10:48:03 -0400 Subject: [PATCH] =?UTF-8?q?docs(plans):=20Flow=20Debugger=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20login-disabled=20stall=20diagnosis,=20=3Fcorrelatio?= =?UTF-8?q?nId=3D=20deep=20links,=20node=20Retry/Discard=20(register=20row?= =?UTF-8?q?=20111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- .../2026-09-06-flow-debugger-followups.md | 436 ++++++++++++++++++ ...9-06-flow-debugger-followups.md.tasks.json | 17 + 2 files changed, 453 insertions(+) create mode 100644 docs/plans/2026-09-06-flow-debugger-followups.md create mode 100644 docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json diff --git a/docs/plans/2026-09-06-flow-debugger-followups.md b/docs/plans/2026-09-06-flow-debugger-followups.md new file mode 100644 index 00000000..3f57d5cf --- /dev/null +++ b/docs/plans/2026-09-06-flow-debugger-followups.md @@ -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=`, `/notifications/report?correlationId=`), 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-`, 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): `/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 +# /disable-login.override.yml +services: + central-a: + environment: + ScadaBridge__Security__Auth__DisableLogin: "true" +``` + +Run: `docker compose -f docker/docker-compose.yml -f /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=`. 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 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 `` 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` 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=`, 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 ` 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=` text "Open in Site Calls"; Notify step → `/notifications/report?correlationId=`, 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 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) { @ReportLabel }`; 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 open the report page.` (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 +/// Outcome of a Retry / Discard relayed from a Flow Debugger node. +public sealed record FlowOutboundActionResult(bool Success, string Message); + +Task RetryOutboundAsync(FlowStep step, CancellationToken cancellationToken = default); +Task 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()` 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 `` 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(_dialog)` with `_dialog = Substitute.For()` 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(), …)`, `_flow.Received().RetryOutboundAsync(Arg.Is(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-`, `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`, `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 ` 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. diff --git a/docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json b/docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json new file mode 100644 index 00000000..8c655f2b --- /dev/null +++ b/docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json @@ -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" +}