From c3b4068de2935c9a669b607df2c87d49172e4871 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 02:03:16 -0400 Subject: [PATCH] feat(central-ui): Flow Debugger refreshes pending outbound outcomes after a Live run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful Live run whose graph has Pending outbound steps the page re-reads the tree through the existing Replay command every 5 s for up to 2 min (page constants), merging by replacing the graph and keeping the selection by event id; never in Replay mode; cancelled by a new run, a mode switch or Dispose. Status strip gains a Pending count, a 'Refresh outcomes' button (both modes) and an 'outcomes still arriving' note while polling. The run's own timing and the Completed chip are left alone — pending outbound work is not a failed run. Plan 2026-09-06 D5. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- ...flow-debugger-outbound-calls.md.tasks.json | 6 +- .../Pages/Deployment/FlowDebugger.razor | 17 ++- .../Pages/Deployment/FlowDebugger.razor.cs | 123 +++++++++++++++++- .../Deployment/FlowDebuggerPageTests.cs | 92 +++++++++++++ 4 files changed, 232 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json index 29ad7deb..7a57ba3e 100644 --- a/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json +++ b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json @@ -68,7 +68,7 @@ { "id": 8, "subject": "Task 8: FlowStepDetail \u2014 Lifecycle tab and outbound fields", - "status": "in_progress", + "status": "completed", "blockedBy": [ 0, 2 @@ -77,7 +77,7 @@ { "id": 9, "subject": "Task 9: Flow Debugger page \u2014 pending refresh after a Live run", - "status": "pending", + "status": "in_progress", "blockedBy": [ 7, 8 @@ -96,5 +96,5 @@ ] } ], - "lastUpdated": "2026-09-06T05:57:47.424541+00:00" + "lastUpdated": "2026-09-06T05:59:55.586055+00:00" } \ No newline at end of file diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor index 9159ecdb..8027dcd7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor @@ -88,9 +88,9 @@
+ data-test="flow-mode-live" disabled="@_running" @onclick="() => SetMode(FlowMode.Live)">Live + data-test="flow-mode-replay" disabled="@_running" @onclick="() => SetMode(FlowMode.Replay)">Replay
@@ -144,6 +144,19 @@ Steps@_graph.Steps.Count Boundaries@_graph.Edges.Count(e => e.CrossesBoundary) Writes@_graph.Steps.Count(s => s.Kind == FlowStepKind.AttributeWrite) + @if (PendingCount > 0) + { + Pending@PendingCount + } + } + @if (_raw != null) + { + + } + @if (_refreshing) + { + outcomes still arriving — refreshing } @if (_raw != null) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs index 8c8c5ed0..fe5c8783 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs @@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; /// Idle → Predicted → Running → Completed | Failed | TimedOut. The server's /// STANDBY_NODE refusal renders as an inline alert with the message verbatim. /// -public partial class FlowDebugger : ComponentBase +public partial class FlowDebugger : ComponentBase, IDisposable { /// Entry point kinds. public enum EntryKind @@ -116,6 +116,21 @@ public partial class FlowDebugger : ComponentBase private bool _running; private bool _storageRestored; + // Outbound calls (plan 2026-09-06 D5): after a Live run whose graph still has Pending + // outbound steps, re-read the tree through the Replay command until every step is terminal + // or the window closes. Page constants, not options — nothing operational depends on them. + internal static readonly TimeSpan PendingRefreshInterval = TimeSpan.FromSeconds(5); + internal static readonly TimeSpan PendingRefreshWindow = TimeSpan.FromMinutes(2); + private CancellationTokenSource? _refreshCts; + private bool _refreshing; + + /// Delay seam for the pending-refresh loop (tests inject an immediate delay). + internal Func Delay { get; set; } = Task.Delay; + + private int PendingCount => _graph?.Steps.Count(s => s.Outcome == FlowStepOutcome.Pending) ?? 0; + + private bool CanRefresh => !_running && _raw is not null; + private bool CanRun => !_running && _mode == FlowMode.Live && ( _entry == EntryKind.Method ? _selectedMethodId > 0 : _selectedInstanceId > 0 && !string.IsNullOrWhiteSpace(_selectedAttribute)); @@ -350,9 +365,17 @@ public partial class FlowDebugger : ComponentBase } } + private void SetMode(FlowMode mode) + { + if (_mode == mode) return; + CancelPendingRefresh(); + _mode = mode; + } + private async Task RunAsync() { if (!CanRun) return; + CancelPendingRefresh(); _running = true; _state = FlowState.Running; _error = null; @@ -387,11 +410,108 @@ public partial class FlowDebugger : ComponentBase { _running = false; } + + StartPendingRefresh(); } + /// Begins polling when a completed Live run left outbound work in flight. Never in Replay mode. + private void StartPendingRefresh() + { + if (_mode != FlowMode.Live || _raw is null || _graph is null || PendingCount == 0) + return; + var cts = new CancellationTokenSource(); + _refreshCts = cts; + _ = RefreshPendingLoopAsync(_raw.RootExecutionId, cts); + } + + private async Task RefreshPendingLoopAsync(Guid root, CancellationTokenSource cts) + { + var ct = cts.Token; + _refreshing = true; + var deadline = DateTime.UtcNow + PendingRefreshWindow; + try + { + while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline) + { + await Delay(PendingRefreshInterval, ct); + var outcome = await FlowDebug.ReplayAsync(root, ct); + if (ct.IsCancellationRequested) return; + MergeRefreshedGraph(outcome); + await InvokeAsync(StateHasChanged); + if (PendingCount == 0) return; + } + } + catch (OperationCanceledException) + { + // New run, mode switch or dispose — the loop is simply abandoned. + } + catch (Exception) + { + // Best-effort: the manual Refresh button stays available. + } + finally + { + if (ReferenceEquals(_refreshCts, cts)) + { + _refreshing = false; + _refreshCts = null; + cts.Dispose(); + try { await InvokeAsync(StateHasChanged); } catch (ObjectDisposedException) { } + } + } + } + + /// Manual refresh — one Replay read of the run's tree; works in both modes. + private async Task RefreshOutcomesAsync() + { + if (!CanRefresh) return; + var root = _raw!.RootExecutionId; + _running = true; + try + { + MergeRefreshedGraph(await FlowDebug.ReplayAsync(root)); + } + catch (Exception ex) + { + _error = "Refresh failed: " + ex.Message; + _errorCode = "REFRESH_FAILED"; + } + finally + { + _running = false; + } + } + + /// + /// Replaces the graph with the re-read tree and keeps the selection by event id. The run's own + /// _raw (timing, root) and the state chip are deliberately left alone: pending outbound + /// work is not a failed run, and the run's Started/Total are the truthful numbers. + /// + private void MergeRefreshedGraph(FlowDebugOutcome outcome) + { + if (!outcome.Success || outcome.Graph == null || _graph == null) + return; + var selectedId = _selected?.EventId; + _graph = outcome.Graph; + _selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected; + } + + private void CancelPendingRefresh() + { + var cts = _refreshCts; + if (cts is null) return; + _refreshCts = null; + _refreshing = false; + try { cts.Cancel(); } catch (ObjectDisposedException) { } + } + + /// + public void Dispose() => CancelPendingRefresh(); + private async Task LoadReplayAsync() { if (!Guid.TryParse(_replayExecutionIdText, out var exec)) return; + CancelPendingRefresh(); _running = true; _state = FlowState.Running; _error = null; @@ -447,6 +567,7 @@ public partial class FlowDebugger : ComponentBase private void ResetResult() { + CancelPendingRefresh(); _graph = null; _raw = null; _selected = null; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs index fb278090..58d7f385 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs @@ -209,4 +209,96 @@ public class FlowDebuggerPageTests : BunitContext cut.WaitForElement("[data-test='flow-site-not-traced']"); } + + // ---- Outbound calls (2026-09-06 plan, Task 9): pending refresh after a Live run ---------- + + /// Inbound → route → site run → cached API call; the call is Pending unless . + private static FlowDebugOutcome OutboundOutcome(Guid root, bool resolved) + { + var child = Guid.NewGuid(); + var corr = Guid.NewGuid(); + var t0 = DateTime.UtcNow.AddSeconds(-10); + var events = new List + { + ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, + occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100, responseSummary: "{}"), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90, + extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root, + sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, + occurredAtUtc: t0.AddMilliseconds(3), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root, + sourceSiteId: "site-a", sourceInstanceId: "Sim01"), + }; + if (resolved) + events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered, + occurredAtUtc: t0.AddSeconds(4), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root, + sourceSiteId: "site-a", sourceInstanceId: "Sim01")); + + var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1)); + return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null); + } + + [Fact] + public void LiveRun_WithPendingOutbound_PollsReplayUntilTerminal() + { + RegisterServices(); + var root = Guid.NewGuid(); + _flow.RunMethodAsync(4, Arg.Any(), Arg.Any()).Returns(OutboundOutcome(root, resolved: false)); + _flow.ReplayAsync(root, Arg.Any()).Returns(OutboundOutcome(root, resolved: false), OutboundOutcome(root, resolved: true)); + var cut = Render(); + cut.Instance.Delay = (_, _) => Task.CompletedTask; + cut.Find("select[data-test='flow-method-select']").Change("4"); + cut.WaitForElement("input[data-test='flow-input-MachineCode']"); + + cut.Find("button[data-test='flow-run']").Click(); + + cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any())); + cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='flow-pending-count']"))); + Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()); + Assert.Empty(cut.FindAll("[data-test='flow-pending-note']")); + Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok"); + Assert.NotNull(cut.Find("button[data-test='flow-refresh']")); + } + + [Fact] + public void LiveRun_WithNoPendingStep_NeverPolls() + { + RegisterServices(); + var root = Guid.NewGuid(); + _flow.RunMethodAsync(4, Arg.Any(), Arg.Any()).Returns(OutboundOutcome(root, resolved: true)); + var cut = Render(); + cut.Instance.Delay = (_, _) => Task.CompletedTask; + cut.Find("select[data-test='flow-method-select']").Change("4"); + cut.WaitForElement("input[data-test='flow-input-MachineCode']"); + + cut.Find("button[data-test='flow-run']").Click(); + + cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim())); + _flow.DidNotReceive().ReplayAsync(Arg.Any(), Arg.Any()); + Assert.Empty(cut.FindAll("[data-test='flow-pending-count']")); + } + + [Fact] + public void ReplayMode_NeverPolls_ButRefreshButtonReplaysOnce() + { + RegisterServices(); + var root = Guid.NewGuid(); + _flow.ReplayAsync(root, Arg.Any()).Returns(OutboundOutcome(root, resolved: false)); + var nav = Services.GetRequiredService(); + nav.NavigateTo($"/deployment/flow-debugger?executionId={root}"); + var cut = Render(); + cut.Instance.Delay = (_, _) => Task.CompletedTask; + + cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim())); + Assert.Equal("1", cut.Find("[data-test='flow-pending-count']").TextContent.Trim()); + _flow.Received(1).ReplayAsync(root, Arg.Any()); + + cut.Find("button[data-test='flow-refresh']").Click(); + + cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any())); + Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()); + } }