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 90d1689a..a7f18782 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
@@ -9,7 +9,7 @@
{
"id": 1,
"subject": "Task 1: Commons \u2014 FlowStepProjector maps outbound rows",
- "status": "in_progress",
+ "status": "completed",
"blockedBy": [
0
]
@@ -17,7 +17,7 @@
{
"id": 2,
"subject": "Task 2: Commons \u2014 FlowGraphBuilder groups operations, async branches, spawn edges, Response origin",
- "status": "pending",
+ "status": "in_progress",
"blockedBy": [
0,
1
@@ -96,5 +96,5 @@
]
}
],
- "lastUpdated": "2026-09-06T05:37:29.919766+00:00"
+ "lastUpdated": "2026-09-06T05:38:41.681788+00:00"
}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs
index ebf1e14f..e268cd3b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs
@@ -396,6 +396,14 @@ public partial class FlowGraphView : ComponentBase
}
}
+ // The Response is the flow's final node: it sits right of everything real regardless of
+ // its predecessor's column (the builder links it from the route call — D9 — which would
+ // otherwise drag it back to column 2 while the site work sits to its right).
+ var isResponse = (stepByKey.TryGetValue(k, out var sk) && sk.Kind == FlowStepKind.Response)
+ || (predByKey.TryGetValue(k, out var pk) && pk.Kind == FlowStepKind.Response);
+ if (isResponse && column.Count > 0)
+ col = Math.Max(col, column.Values.Max() + 1);
+
while (!occupied.Add((lane[k], col)))
col++;
column[k] = col;
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs
index 3e98d657..318cce34 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs
@@ -1,5 +1,6 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
@@ -8,18 +9,27 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// (Flow Debugger design 2026-09-05 §7.2). Pure: same events in, same graph out.
///
///
-/// Rules:
+/// Rules (v1.1, 2026-09-06 outbound-calls plan):
///
+/// Fold. Outbound rows (ApiOutbound / DbOutbound / Notification) sharing a kind and
+/// CorrelationId fold into ONE step (a sync row with a null correlation is a group of one). The folded step
+/// takes the first row's identity, time, input and title, the latest row's outcome, subtitle, output and error;
+/// DurationMs is the sync row's own, or submit→terminal wall time for a cached operation (null while pending);
+/// Lifecycle lists every row in time order.
/// Steps are ordered by OccurredAtUtc, then a kind rank (inbound before route call
-/// before script run before writes/waits), then EventId.
-/// Within one ExecutionId consecutive steps are chained in that order.
+/// before script run before writes/waits before outbound), then EventId.
+/// Within one ExecutionId consecutive steps are chained in that order, skipping async steps — a
+/// Notify, or a cached operation whose first row is CachedSubmit. Each skipped step instead gets an
+/// Async (dashed) edge from the chain step immediately before it in time.
/// The first step of an execution is linked from its spawner: a RouteCall whose
/// childExecutionId matches, else the latest step of the parent execution that occurred
-/// no later than it. The edge CrossesBoundary when exactly one end is in the Central lane.
+/// no later than it — that second case is a Spawn edge (trigger cascade). The edge
+/// CrossesBoundary when exactly one end is in the Central lane.
/// An AttributeWait that was satisfied gets an External (dashed) edge from the
/// preceding AttributeWrite in the same execution — the device echo the system inferred.
/// When an InboundRequest row carries a response summary, a Response step is
-/// synthesized as the final Central node, linked from the last real step.
+/// synthesized as the final Central node, linked from the last non-async step of the inbound
+/// execution (the route call, or the inbound row itself), never from a cascaded site step.
///
///
public static class FlowGraphBuilder
@@ -31,8 +41,7 @@ public static class FlowGraphBuilder
if (events.Count == 0)
return new FlowGraph(Array.Empty(), Array.Empty(), Predicted: false, Truncated: truncated);
- var steps = events
- .Select(FlowStepProjector.Project)
+ var steps = Fold(events)
.OrderBy(s => s.OccurredAtUtc)
.ThenBy(s => KindRank(s.Kind))
.ThenBy(s => s.EventId)
@@ -44,11 +53,23 @@ public static class FlowGraphBuilder
.GroupBy(s => s.ExecutionId!.Value)
.ToDictionary(g => g.Key, g => g.ToList());
- // 1. Chain consecutive steps inside each execution.
+ // 1. Chain consecutive non-async steps inside each execution; async steps branch off the step before them.
foreach (var chain in byExecution.Values)
{
- for (var i = 1; i < chain.Count; i++)
- edges.Add(new FlowEdge(chain[i - 1].EventId, chain[i].EventId, CrossesBoundary(chain[i - 1], chain[i]), External: false));
+ FlowStep? prev = null;
+ foreach (var step in chain)
+ {
+ if (IsAsync(step))
+ {
+ if (prev is not null)
+ edges.Add(new FlowEdge(prev.EventId, step.EventId, CrossesBoundary(prev, step), External: false, Async: true));
+ continue;
+ }
+
+ if (prev is not null)
+ edges.Add(new FlowEdge(prev.EventId, step.EventId, CrossesBoundary(prev, step), External: false));
+ prev = step;
+ }
}
// 2. Link each child execution's first step from its spawner.
@@ -66,11 +87,12 @@ public static class FlowGraphBuilder
if (routeCallsByChild.TryGetValue(executionId, out var routeCall))
spawner = routeCall;
else if (byExecution.TryGetValue(parentId, out var parentChain))
- spawner = parentChain.LastOrDefault(p => p.OccurredAtUtc <= first.OccurredAtUtc && p.Kind != FlowStepKind.Response)
+ spawner = parentChain.LastOrDefault(p => p.OccurredAtUtc <= first.OccurredAtUtc && p.Kind != FlowStepKind.Response && !IsAsync(p))
?? parentChain[0];
if (spawner is not null && spawner.EventId != first.EventId)
- edges.Add(new FlowEdge(spawner.EventId, first.EventId, CrossesBoundary(spawner, first), External: false));
+ edges.Add(new FlowEdge(spawner.EventId, first.EventId, CrossesBoundary(spawner, first), External: false,
+ Spawn: spawner.Kind != FlowStepKind.RouteCall));
}
// 3. Satisfied wait ⇐ preceding write in the same execution (inferred device echo).
@@ -95,6 +117,10 @@ public static class FlowGraphBuilder
if (inbound is not null && inbound.OutputJson is not null)
{
var last = steps[^1];
+ var origin = (inbound.ExecutionId is { } inboundExecution && byExecution.TryGetValue(inboundExecution, out var inboundChain)
+ ? inboundChain.LastOrDefault(s => s.Kind != FlowStepKind.Response && !IsAsync(s))
+ : null)
+ ?? last;
var response = new FlowStep(
EventId: DeriveResponseId(inbound.EventId),
ExecutionId: inbound.ExecutionId,
@@ -115,12 +141,88 @@ public static class FlowGraphBuilder
Target: inbound.Target,
Extra: null);
steps.Add(response);
- edges.Add(new FlowEdge(last.EventId, response.EventId, CrossesBoundary(last, response), External: false));
+ edges.Add(new FlowEdge(origin.EventId, response.EventId, CrossesBoundary(origin, response), External: false));
}
return new FlowGraph(steps, edges, Predicted: false, Truncated: truncated);
}
+ ///
+ /// A step the script did not wait for: a notification, or a cached operation (first lifecycle row
+ /// CachedSubmit). Such steps hang off the chain on an edge (D4).
+ ///
+ public static bool IsAsync(FlowStep step)
+ => step.Kind == FlowStepKind.Notify
+ || (step.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall
+ && string.Equals(step.Lifecycle?.FirstOrDefault()?.Kind, nameof(AuditKind.CachedSubmit), StringComparison.Ordinal));
+
+ ///
+ /// Projects every event and folds the outbound rows of one operation (same kind + CorrelationId) into one step
+ /// carrying the whole lifecycle. Non-outbound rows and null-correlation outbound rows pass through one-to-one.
+ ///
+ private static IEnumerable Fold(IReadOnlyList events)
+ {
+ var groups = new Dictionary<(FlowStepKind Kind, Guid Correlation), List<(FlowStep Step, AuditEvent Evt)>>();
+ var order = new List