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
@@ -55,7 +55,7 @@ public sealed record FlowLayoutNode(
/// <param name="External">Dashed — an external actor (the tag source responding).</param>
/// <param name="PredictedOnly">Light — an edge of the prediction, not of a run.</param>
/// <param name="Async">Dashed — a fire-and-forget branch (cached call / notification) the source never waited for (plan D4).</param>
/// <param name="Spawn">Bolt marker — the target is the first step of a trigger-spawned child execution (plan D9).</param>
/// <param name="Spawn">Bolt badge on the connector — the target is the first step of a trigger-spawned child execution (plan D9).</param>
public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly, bool Async = false, bool Spawn = false);
/// <summary>A computed swimlane layout.</summary>
@@ -184,8 +184,9 @@ public partial class FlowGraphView : ComponentBase
return sb.ToString();
}
/// <summary>Every connector ends in an arrowhead — a spawn edge marks itself with a bolt badge on the line instead (<see cref="SpawnBadgeFor"/>).</summary>
private static string MarkerFor(FlowLayoutEdge e)
=> e.Spawn ? "url(#fg-arrow-spawn)" : e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
=> e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
/// <summary>True for a real outbound step (API call / database / notification) — the kinds that carry a status pill instead of a subtitle line.</summary>
private static bool HasPill(FlowLayoutNode n)
@@ -351,6 +352,47 @@ public partial class FlowGraphView : ComponentBase
return b is null ? null : (F(b.X + 22), F(b.Y - 4));
}
/// <summary>
/// Where a spawn edge's bolt badge sits: centred on the connector's vertical run (the riser between the
/// lanes), so the badge is read as a property of the hop and the arrowhead stays an arrowhead. Null for
/// every non-spawn edge.
/// </summary>
private (string X, string Y)? SpawnBadgeFor(FlowLayoutEdge e)
{
if (!e.Spawn) return null;
var a = _layout.Nodes.FirstOrDefault(n => n.Key == e.From);
var b = _layout.Nodes.FirstOrDefault(n => n.Key == e.To);
if (a is null || b is null) return null;
var (x, y) = SpawnBadgePoint(a, b, e.Async);
return (F(x), F(y));
}
/// <summary>
/// Midpoint of the vertical run of the connector <see cref="OrthogonalPath"/> / <see cref="AsyncPath"/>
/// draws from <paramref name="a"/> to <paramref name="b"/> (the middle of a same-lane straight line).
/// Kept in step with those two path builders.
/// </summary>
public static (double X, double Y) SpawnBadgePoint(FlowLayoutNode a, FlowLayoutNode b, bool isAsync)
{
var x1 = a.X + NodeWidth;
var y1 = a.Y + NodeHeight / 2;
var x2 = b.X;
var y2 = b.Y + NodeHeight / 2;
if (isAsync && b.Y > a.Y)
{
// AsyncPath: first vertical run drops from the source's bottom-centre to the strip above the target.
var strip = b.Y - LanePad / 2;
return (a.X + NodeWidth / 2, (a.Y + NodeHeight + strip) / 2);
}
if (Math.Abs(y1 - y2) < 0.01)
return ((x1 + x2) / 2, y1);
if (x2 >= x1)
return (x1 + (x2 - x1) / 2, (y1 + y2) / 2);
var drop = Math.Max(a.Y, b.Y) + NodeHeight + 8;
return ((x1 + 8 + x2 - 8) / 2, drop);
}
private Task SelectAsync(FlowLayoutNode n)
=> n.Step != null && OnSelect.HasDelegate ? OnSelect.InvokeAsync(n.Step) : Task.CompletedTask;