feat(central-ui): Flow Debugger refreshes pending outbound outcomes after a Live run

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
This commit is contained in:
Joseph Doherty
2026-09-06 02:03:16 -04:00
parent 8f6f7ed92b
commit c3b4068de2
4 changed files with 232 additions and 6 deletions
@@ -88,9 +88,9 @@
<label class="form-label form-label-sm">Mode</label>
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Mode">
<button type="button" class="btn @(_mode == FlowMode.Live ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-mode-live" disabled="@_running" @onclick="() => _mode = FlowMode.Live">Live</button>
data-test="flow-mode-live" disabled="@_running" @onclick="() => SetMode(FlowMode.Live)">Live</button>
<button type="button" class="btn @(_mode == FlowMode.Replay ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-mode-replay" disabled="@_running" @onclick="() => _mode = FlowMode.Replay">Replay</button>
data-test="flow-mode-replay" disabled="@_running" @onclick="() => SetMode(FlowMode.Replay)">Replay</button>
</div>
</div>
@@ -144,6 +144,19 @@
<span><span class="fd-label">Steps</span><span class="fd-mono" data-test="flow-step-count">@_graph.Steps.Count</span></span>
<span><span class="fd-label">Boundaries</span><span class="fd-mono">@_graph.Edges.Count(e => e.CrossesBoundary)</span></span>
<span><span class="fd-label">Writes</span><span class="fd-mono">@_graph.Steps.Count(s => s.Kind == FlowStepKind.AttributeWrite)</span></span>
@if (PendingCount > 0)
{
<span><span class="fd-label">Pending</span><span class="fd-mono" data-test="flow-pending-count">@PendingCount</span></span>
}
}
@if (_raw != null)
{
<button type="button" class="btn btn-outline-secondary btn-sm py-0" data-test="flow-refresh"
disabled="@(!CanRefresh)" @onclick="RefreshOutcomesAsync">Refresh outcomes</button>
}
@if (_refreshing)
{
<span class="text-muted small" data-test="flow-pending-note">outcomes still arriving — refreshing</span>
}
@if (_raw != null)
{
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// <c>Idle → Predicted → Running → Completed | Failed | TimedOut</c>. The server's
/// <c>STANDBY_NODE</c> refusal renders as an inline alert with the message verbatim.
/// </summary>
public partial class FlowDebugger : ComponentBase
public partial class FlowDebugger : ComponentBase, IDisposable
{
/// <summary>Entry point kinds.</summary>
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;
/// <summary>Delay seam for the pending-refresh loop (tests inject an immediate delay).</summary>
internal Func<TimeSpan, CancellationToken, Task> 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();
}
/// <summary>Begins polling when a completed Live run left outbound work in flight. Never in Replay mode.</summary>
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) { }
}
}
}
/// <summary>Manual refresh — one Replay read of the run's tree; works in both modes.</summary>
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;
}
}
/// <summary>
/// Replaces the graph with the re-read tree and keeps the selection by event id. The run's own
/// <c>_raw</c> (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.
/// </summary>
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) { }
}
/// <inheritdoc/>
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;