fix(central-ui): Flow Debugger spawn edges — real arrowhead + bolt badge; spawner picked by trigger attribute

The trigger-cascade (Spawn) edge used a lightning-bolt marker IN PLACE of the arrowhead. On the upward
Tags→Site hop the bolt sat on top of the riser and read as a broken arrow (rig screenshot 2026-09-08).
Every connector now ends in an ordinary arrowhead; the bolt is a badge (circle + bolt) centred on the
connector's vertical run (FlowGraphView.SpawnBadgePoint, kept in step with OrthogonalPath/AsyncPath).

Same screenshot, second defect: the cascade spawned by "RecipeDownloadFlag = false" was drawn from the
LAST write of the burst (RecipeProcessedFlag) because all four writes share a millisecond and the
spawner rule was time-only. FlowGraphBuilder now prefers the parent's last AttributeWrite of the attribute
the child's triggerAttribute names (at or before the child, else the last such write); the time/position
rules remain the fallback when the child names no attribute or the parent never wrote it.

Verified live on the rebuilt rig (DelmiaSim01 / DelmiaReceiverSimulator): both spawn edges show an
arrowhead + badge, the second one originates from the RecipeDownloadFlag write. bUnit + builder tests
updated/added; Playwright FlowDebuggerTests 4/4.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-08 09:04:39 -04:00
parent cb8c6eb813
commit 859922ba17
7 changed files with 174 additions and 18 deletions
@@ -27,7 +27,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <c>Notify</c>, or a cached operation whose first row is <c>CachedSubmit</c>. Each skipped step instead gets an
/// <c>Async</c> (dashed) edge from the chain step immediately before it in time.</item>
/// <item>The first step of an execution is linked from its spawner: a <c>RouteCall</c> whose
/// <c>childExecutionId</c> matches, else — a <c>Spawn</c> edge (trigger cascade) — the last non-async,
/// <c>childExecutionId</c> matches, else — a <c>Spawn</c> edge (trigger cascade) — the parent's last
/// <c>AttributeWrite</c> of the attribute the child's <c>triggerAttribute</c> names (at or before the child's
/// first step, else the last such write at all), and only when the child names no attribute or the parent
/// never wrote it, the last non-async,
/// non-<c>Response</c> parent step that <b>occurred at or before</b> the child's first step. Parent and
/// child of a cascade share the site clock, so that timestamp rule picks the write that actually fired
/// the trigger; only when it finds nothing (a child clock BEHIND its parent's — a mixed-clock Replay)
@@ -146,11 +149,21 @@ public static class FlowGraphBuilder
// (a run shaped ScriptRun → Write(A) → Wait → Write(B) would spawn from Write(B)).
// Position stays as the fallback for the mixed-clock case F5 was written for — a child
// clock behind its parent's, where no parent step satisfies the time rule.
// Before either rule: when the child names the attribute that fired it, the spawner is the
// parent's last write OF THAT ATTRIBUTE. Timestamps alone cannot separate a burst of writes
// that all land in the same millisecond (a run writing Result, ResultText, Flag=false, Processed
// back-to-back spawned its value-change child from the LAST of them on the rig, not from Flag).
var childTime = first.OccurredAtUtc;
var childPosition = position[first.EventId];
spawner = parentChain.LastOrDefault(p => Spawnable(p) && p.OccurredAtUtc <= childTime)
?? parentChain.LastOrDefault(p => Spawnable(p) && position[p.EventId] < childPosition)
?? parentChain[0];
var triggerAttribute = first.Extra?.TriggerAttribute;
if (!string.IsNullOrEmpty(triggerAttribute))
{
spawner = parentChain.LastOrDefault(p => WritesAttribute(p, triggerAttribute) && p.OccurredAtUtc <= childTime)
?? parentChain.LastOrDefault(p => WritesAttribute(p, triggerAttribute));
}
spawner ??= parentChain.LastOrDefault(p => Spawnable(p) && p.OccurredAtUtc <= childTime)
?? parentChain.LastOrDefault(p => Spawnable(p) && position[p.EventId] < childPosition)
?? parentChain[0];
}
if (spawner is null) continue;
@@ -228,6 +241,12 @@ public static class FlowGraphBuilder
private static bool Spawnable(FlowStep step)
=> step.Kind != FlowStepKind.Response && !IsAsync(step);
/// <summary>Is this an <c>AttributeWrite</c> step whose write list names <paramref name="attribute"/> (ordinal, case-insensitive)?</summary>
private static bool WritesAttribute(FlowStep step, string attribute)
=> step.Kind == FlowStepKind.AttributeWrite
&& step.Extra?.Writes is { Count: > 0 } writes
&& writes.Any(w => string.Equals(w.Attribute, attribute, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// A step the script did not wait for: a notification, or a cached operation — any API / DB step whose
/// first lifecycle row is not the sync one-shot kind (<c>ApiCall</c> / <c>DbWrite</c>), the same rule the