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());
+ }
}