Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
39 KiB
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 byFlowStepProjector.ProjectfromAuditEvent.CorrelationIdfor every step (outbound or not — it is the audit row's value, harmless elsewhere). No wire change:FlowStepis a central projection. - D2 Deep-link query key is
correlationIdon 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)returningFlowOutboundActionResult(bool Success, string Message). Kind routing:Notify→ notification outbox relay (needsCorrelationIdas theNotificationIdstring,"D"format);ApiCall/DbCall→ site-call relay (needsCorrelationId+Step.SiteIdasSourceSite). A step with noCorrelationId, noSiteId(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.PermittedSiteIdsAsyncagainst the step'sSiteId, exactly likeSiteCallsReport.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
Pendingor the acted step is stillParked; 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>, scriptreturn new { ok = true, echo = Input.Text };) viaCliRunner, deletes it on dispose, isSkippableFacton cluster unavailability, and asserts: method persists across a page reload (the localStorage path that stalled), a Live run reaches theCompletedstate 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
# <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(patternblazor|circuit|WebSocket|error|Error) andread_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
101on_blazorwebsocket, or a failed negotiate), any JS error, anycircuit 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:
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:
[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
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(mirrorSiteCallQueryRequest_FilterBySourceNode_ReturnsMatchingSummariesat :234) - Test:
tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs(mirrorQueryAsync_FilterBySourceSite_ReturnsMatchingRowsat :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: appendGuid? TrackedOperationIdFilter = nullafterSourceNodeFilter, doc: "Exact-match on one operation (the?correlationId=deep link from the Flow Debugger / Audit Log); null = no filter."SiteCallQueryFilter: appendGuid? TrackedOperationId = null.- Actor
QueryAsync:TrackedOperationId: request.TrackedOperationIdFilter. - Repository SQL: add
AND (@TrackedOperationId IS NULL OR TrackedOperationId = @TrackedOperationId)after the@Targetline; addParam("@TrackedOperationId", SqlDbType.UniqueIdentifier, filter.TrackedOperationId)to the parameter array (checkParam's null handling — it already maps null →DBNullfor the other optional filters). Note the RECOMPILE comment already covers optional-parameter pruning;TrackedOperationIdis the PK so the seek is free.
Step 4: Run both filtered test sets → PASS.
Step 5: Commit
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 theArg.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=parkedtest at :47) - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs(the scripted actor at :77 — it records the receivedSiteCallQueryRequest; assertTrackedOperationIdFilter)
Step 1: Failing tests
SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilterAndAutoOpensDetail: navigate to/site-calls/report?correlationId=<guid>, render; assert the query request the scripted actor received hasTrackedOperationIdFilter == 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: passTrackedOperationIdFilter: _operationIdFilterintoSiteCallQueryRequest.- After the first successful fetch in
OnInitializedAsync(afterRefreshAll()):if (_operationIdFilter is { } op && _siteCalls?.Count == 1 && _siteCalls[0].TrackedOperationId == op) await ShowDetail(_siteCalls[0]); - Filter card: when
_operationIdFilteris set render abadgepilldata-test="site-calls-filter-operation"withOperation <short id>and an×button that clears it and re-runsSearch(). ClearFilters:_operationIdFilter = null;andHasActiveFiltersincludes 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 scriptsQueryNotificationOutboxAsync)
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 withCorrelationId = cand 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/OnDiscardcallbacks set → buttonsflow-step-retry/flow-step-discardpresent; 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:
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 gainsCommunicationService comms) + new methods - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs(constructCommunicationServicethe waySiteCallsReportPageTests.cs:77-82does —new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger…)+SetSiteCallAudit(probe)/ the notification-outbox setter; the existingScriptedSiteCallAuditActorpattern 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 receivesRetrySiteCallRequestwithTrackedOperationId == c,SourceSite == "site-a",RequestedBy == "deployer"; scripted replyApplied→ resultSuccess == 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(scriptedSiteUnreachable→ Success false, message contains "unreachable").
Step 2: Run → FAIL.
Step 3: Implement
/// <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):
- Guards (D4) →
new(false, "…"). requestedBy = await _auth.GetCurrentUsernameAsync()(the extension inCentralUI/Auth/ClaimsPrincipalExtensions.cs:43).- 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"). - ApiCall/DbCall →
_comms.RetrySiteCallAsync(new RetrySiteCallRequest(…, c, step.SiteId!, requestedBy))→ mapSiteCallRelayOutcomewith the same wording asSiteCallsReport.ShowRelayOutcome(Applied→ success "Retry of {short} relayed to {site}.";NotParked→ success:false "…no longer parked.";SiteUnreachable/OperationFailed && !SiteReachable→ "Site unreachable — …"; elseErrorMessage ?? "The site could not apply the action."). - Catch →
new(false, $"Retry failed: {ex.Message}"), log Warning. Register nothing new —CommunicationServiceis 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 — addServices.AddSingleton<IDialogService>(_dialog)with_dialog = Substitute.For<IDialogService>()defaultingConfirmAsync(...)totrue)
Step 1: Failing tests
ParkedNode_Retry_ConfirmsRelaysAndRepollsUntilNotParked: Live run outcome whose graph has a Parked ApiCall step withCorrelationId+SiteId "site-a"; select it (OnSelectvia clicking the nodeg[data-test='flow-node']matching the step), clickflow-step-retry; assert_dialog.Received().ConfirmAsync("Retry cached call", Arg.Any<string>(), …),_flow.Received().RetryOutboundAsync(Arg.Is<FlowStep>(s => s.EventId == parked.EventId), …);_flow.ReplayAsyncscripted to return the same graph once then a graph where the step isOk→Delayseam short-circuited → assertReplayAsynccalled ≥ 2 times and the node pill text becomesok; toast text contains "relayed".ParkedNode_Discard_UsesDangerConfirm_AndDoesNotPollWhenRefused:_flow.DiscardOutboundAsyncreturnsSuccess false→ReplayAsyncnever called after the click; toast shows the message.ParkedNode_ConfirmDeclined_NeverRelays.ParkedNode_OutOfScopeSite_RefusedBeforeConfirm:SiteScopeServicestub with a permitted set that excludes site 1 → noConfirmAsync, no relay, toast "not permitted".
Step 2: Run → FAIL.
Step 3: Implement
- Inject
IDialogService Dialog; fieldprivate ToastNotification _toast = default!; private bool _actionInProgress; private Guid? _watchedStepId; FlowStepDetail … OnRetry="OnRetryStepAsync" OnDiscard="OnDiscardStepAsync" ActionInProgress="_actionInProgress".OnRetryStepAsync(FlowStep step)/OnDiscardStepAsync: sharedActOnStepAsync(step, retry: bool):if (!await IsStepSiteAllowedAsync(step)) { _toast.ShowError("You are not permitted to act on cached calls for this site."); return; }—IsStepSiteAllowedAsync: Notify or nullSiteId→ true; elseawait SiteScope.IsSystemWideAsync()||_sites.FirstOrDefault(s => s.SiteIdentifier == step.SiteId)is null || permitted ids contain itsId(mirrorSiteCallsReport.IsRowSiteAllowedAsync).- 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. _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.- 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_watchedStepIdon exit, onSetMode, on a new run/replay. Keep the existing "Replay mode never auto-polls after a plain replay" behaviour: the loop start afterLoadReplayAsyncstays absent — only Live runs and node actions start it (testReplayMode_NeverPolls_ButRefreshButtonReplaysOncemust still pass). - Status strip: show
outcomes still arriving — refreshingfor 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 inselect[data-test='flow-method-select'](SelectOptionAsync), wait forinput[data-test='flow-input-Text'], fillhelloand commit with the blur idiom, clickbutton[data-test='flow-run'], wait until[data-test='flow-state']text isCompleted(30 s), assertflow-step-count≥ 2, click the lastg[data-test='flow-node']and assert[data-test='flow-step-title']is visible.MethodChoice_SurvivesReload_ViaLocalStorage: select the method,page.ReloadAsync(), wait forselect[data-test='flow-method-select']to have the method id as its value (this is exactly theOnAfterRenderAsyncrestore path). Then the console must contain noMicrosoft.JSInteroperror: attachpage.Consoleand assert no message with typeerrormentioningJSInterop.
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 — addTrackedOperationIdFilter) - Modify:
docs/requirements/Component-NotificationOutbox.md(query filters — addNotificationIdFilter) - Modify:
docs/plans/2026-07-08-deferred-work-register.mdrow 111 → resolved 2026-09-06 (this plan) - Modify:
docs/plans/2026-09-05-flow-debugger-design.mdstatus line (v1.2: node actions + deep links) - Modify:
CLAUDE.mdFlow 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-followupson branchfeat/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-traefikand logs in with the LDAP test usermulti-role;LoginTestspass 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.