fix(central-ui): Flow Debugger outcome refresh merges rows by EventId instead of replacing the graph

Found in a browser Live run on the rig: the run drew API, DB and Notify nodes from
its inline rows; the 5 s pending-refresh re-read the tree through Replay before the
site's cached-call telemetry (12 s) had reached central, swapped the graph wholesale,
and the resolved DB node vanished (7 -> 6 steps) until a manual refresh. Polling had
already stopped because nothing was Pending.

MergeRefreshedGraph now unions the re-read rows into the run's known rows by EventId
(fresh copy wins), stores the union back on _raw and rebuilds through
FlowGraphBuilder, so a refresh only ever adds nodes. The union is exact: the inline
rows are the same AuditEvent objects the site writer persists (FlowTraceCollector).

Tests: new PendingRefresh_KeepsStepsTheLiveRunAlreadyHad_WhenReplayLagsBehindTelemetry
(fails on the old code). Two existing fixtures were internally inconsistent and only
passed under the old replace semantics: ParkedThenResolved built its Resolved outcome
with the Parked raw rows, and OutboundOutcome minted fresh event/correlation ids per
call, so no row could ever match across live and replay. Both now share rows per run.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 11:55:52 -04:00
parent 2b80e1d8eb
commit 7caf56f332
4 changed files with 126 additions and 20 deletions
@@ -3,6 +3,7 @@ using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
@@ -510,19 +511,36 @@ public partial class FlowDebugger : ComponentBase, IDisposable
}
/// <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.
/// Folds the re-read tree INTO the run's known events (union by event id, re-read rows win) and
/// rebuilds the graph from the union, keeping the selection by event id. A refresh therefore only
/// ever adds rows: a Live run carries its site rows inline, while the same rows reach central's
/// audit store by telemetry seconds later — a Replay read taken in between lacks them, and
/// replacing the graph wholesale made an already-drawn node (a resolved cached DB write, on the
/// rig 2026-09-06) vanish until the next manual refresh. 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)
if (!outcome.Success || outcome.Graph == null || _graph == null || _raw == null)
return;
var selectedId = _selected?.EventId;
_graph = outcome.Graph;
var merged = MergeEvents(_raw.Events, outcome.Raw?.Events);
var truncated = _raw.Truncated || outcome.Raw?.Truncated == true;
_raw = _raw with { Events = merged, Truncated = truncated };
_graph = FlowGraphBuilder.Build(merged, truncated);
_selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected;
}
/// <summary>Union of two audit-row lists by <see cref="AuditEvent.EventId"/>; a row present in both takes the fresh copy.</summary>
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
{
var byId = new Dictionary<Guid, AuditEvent>();
foreach (var e in known ?? []) byId[e.EventId] = e;
foreach (var e in fresh ?? []) byId[e.EventId] = e;
return byId.Values.ToList();
}
private void CancelPendingRefresh(bool keepWatch = false)
{
if (!keepWatch)