From 29129ef17a6c7beb092cd2b43a0d42cdcc66bd96 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 01:42:48 -0400 Subject: [PATCH] feat(commons): FlowGraphBuilder folds outbound lifecycles into one node; async/spawn edges; Response from the inbound execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold rule: outbound rows sharing (kind, CorrelationId) become one FlowStep with a Lifecycle list — first row's identity/time/input, latest row's outcome/ subtitle/output/error, DurationMs = sync row's own or submit→terminal wall time (null while pending). Chain skips async steps (Notify, or first row CachedSubmit) and links them with Async edges from the preceding chain step. Spawner edges from a non-RouteCall are Spawn: true. The Response links from the last non-async step of the inbound execution (the RouteCall), not the globally last cascaded step (D9). FlowGraphView places a Response at max column + 1 so the D9 origin does not pull it left (pulled forward from Task 7 to keep the layout test green). Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- ...flow-debugger-outbound-calls.md.tasks.json | 6 +- .../Components/Shared/FlowGraphView.razor.cs | 8 + .../Types/Debugging/FlowGraphBuilder.cs | 128 +++++++++-- .../Types/Debugging/FlowStepProjector.cs | 15 ++ .../Debugging/FlowGraphBuilderTests.cs | 198 +++++++++++++++++- 5 files changed, 332 insertions(+), 23 deletions(-) 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(events.Count); // FlowStep, or the group key — preserves first-seen order + + foreach (var evt in events) + { + var step = FlowStepProjector.Project(evt); + if (!FlowStepProjector.IsOutbound(step.Kind)) + { + order.Add(step); + continue; + } + + if (evt.CorrelationId is not { } correlation) + { + order.Add(step with { Lifecycle = [FlowStepProjector.ToLifecycleRow(evt)] }); + continue; + } + + var key = (step.Kind, correlation); + if (!groups.TryGetValue(key, out var group)) + { + group = []; + groups[key] = group; + order.Add(key); + } + group.Add((step, evt)); + } + + foreach (var item in order) + { + if (item is FlowStep single) + { + yield return single; + continue; + } + + var key = ((FlowStepKind, Guid))item; + var rows = groups[key] + .OrderBy(r => r.Step.OccurredAtUtc) + .ThenBy(r => r.Step.EventId) + .ToList(); + var first = rows[0].Step; + var latest = rows[^1].Step; + var lifecycle = rows.Select(r => FlowStepProjector.ToLifecycleRow(r.Evt)).ToList(); + var terminal = latest.Outcome != FlowStepOutcome.Pending; + int? duration = rows.Count == 1 + ? first.DurationMs + : terminal ? (int?)Math.Max(0, (int)Math.Round((latest.OccurredAtUtc - first.OccurredAtUtc).TotalMilliseconds)) : null; + + yield return first with + { + Outcome = latest.Outcome, + Subtitle = latest.Subtitle, + OutputJson = latest.OutputJson, + ErrorMessage = latest.ErrorMessage, + DurationMs = rows.Count == 1 && !terminal ? null : duration, + Lifecycle = lifecycle, + }; + } + } + /// Display rank used to break timestamp ties: spawners before what they spawn. public static int KindRank(FlowStepKind kind) => kind switch { diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs index 4a038d9c..45a64a1b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs @@ -45,6 +45,21 @@ public static class FlowStepProjector Extra: extra); } + /// Projects one outbound audit row onto its lifecycle entry (). + public static FlowLifecycleRow ToLifecycleRow(AuditEvent evt) + { + ArgumentNullException.ThrowIfNull(evt); + var d = AuditDetailsCodec.Deserialize(evt.DetailsJson); + return new FlowLifecycleRow( + EventId: evt.EventId, + Kind: d.Kind ?? string.Empty, + Status: d.Status ?? string.Empty, + OccurredAtUtc: evt.OccurredAtUtc, + Node: evt.SourceNode, + ErrorMessage: d.ErrorMessage, + DurationMs: d.DurationMs); + } + /// Maps a channel/kind string pair onto a step kind. public static FlowStepKind ResolveKind(string? channel, string? kind) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs index c4dced97..14fd97a4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs @@ -77,19 +77,26 @@ public class FlowGraphBuilderTests Assert.Equal(FlowLane.Site, graph.Steps[6].Lane); Assert.Equal(FlowLane.Central, graph.Steps[7].Lane); - // Exactly two boundary crossings: route → site run, and last site step → response. - var crossing = graph.Edges.Where(e => e.CrossesBoundary).ToList(); - Assert.Equal(2, crossing.Count); - Assert.Contains(crossing, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[2].EventId); - Assert.Contains(crossing, e => e.FromEventId == graph.Steps[6].EventId && e.ToEventId == graph.Steps[7].EventId); + // Exactly one boundary crossing: route → site run. The Response links from the RouteCall (D9), Central → Central. + var crossing = Assert.Single(graph.Edges, e => e.CrossesBoundary); + Assert.Equal(graph.Steps[1].EventId, crossing.FromEventId); + Assert.Equal(graph.Steps[2].EventId, crossing.ToEventId); + Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[7].EventId && !e.CrossesBoundary); + Assert.DoesNotContain(graph.Edges, e => e.FromEventId == graph.Steps[6].EventId && e.ToEventId == graph.Steps[7].EventId); // One inferred echo edge: first write → satisfied wait. var external = Assert.Single(graph.Edges, e => e.External); Assert.Equal(graph.Steps[3].EventId, external.FromEventId); Assert.Equal(graph.Steps[4].EventId, external.ToEventId); - // The cascade run hangs off the write that spawned it (same site, no boundary). - Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[5].EventId && e.ToEventId == graph.Steps[6].EventId && !e.CrossesBoundary); + // The cascade run hangs off the write that spawned it (same site, no boundary) — a Spawn edge (D9); + // the route → run edge and every in-chain edge are not. + var spawn = Assert.Single(graph.Edges, e => e.Spawn); + Assert.Equal(graph.Steps[5].EventId, spawn.FromEventId); + Assert.Equal(graph.Steps[6].EventId, spawn.ToEventId); + Assert.False(spawn.CrossesBoundary); + Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[2].EventId && !e.Spawn); + Assert.DoesNotContain(graph.Edges, e => e.Async); // Inbound → route call chained inside the root execution. Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[0].EventId && e.ToEventId == graph.Steps[1].EventId); @@ -179,4 +186,181 @@ public class FlowGraphBuilderTests Assert.Null(ScriptTraceExtra.TryParse(null)); Assert.Null(ScriptTraceExtra.TryParse("nope")); } + + // ---- Outbound calls (2026-09-06 plan, Task 2) -------------------------------------------- + + private static AuditEvent SiteRunRow(int ms, Guid executionId = default) => ScadaBridgeAuditEventFactory.Create( + AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: "S", sourceNode: "node-a", + executionId: executionId == default ? SiteRun : executionId, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 800, + extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()); + + private static AuditEvent WriteRow(int ms, string attr) => ScadaBridgeAuditEventFactory.Create( + AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered, + occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: attr, sourceNode: "node-a", + executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 2, + extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail(attr, "1", true, null)] }.ToJson()); + + private static AuditEvent Outbound(AuditChannel channel, AuditKind kind, AuditStatus status, int ms, Guid? correlation, string target, + string node = "node-a", string? error = null, int? durationMs = null, string? request = null, string? response = null) + => ScadaBridgeAuditEventFactory.Create(channel, kind, status, + occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: target, sourceNode: node, correlationId: correlation, + executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", errorMessage: error, durationMs: durationMs, + requestSummary: request, responseSummary: response); + + [Fact] + public void CachedApiCall_FourRows_FoldIntoOneAsyncNode() + { + var corr = Guid.NewGuid(); + var events = new List + { + SiteRunRow(20), + WriteRow(30, "A"), + Outbound(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 35, corr, "Delmia.AckRecipe", request: "{\"id\":1}"), + WriteRow(40, "B"), + Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, AuditStatus.Forwarded, 2000, corr, "Delmia.AckRecipe"), + Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, AuditStatus.Attempted, 3000, corr, "Delmia.AckRecipe", error: "503", durationMs: 40), + Outbound(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered, 4135, corr, "Delmia.AckRecipe", response: "{\"ok\":true}"), + }; + + var graph = FlowGraphBuilder.Build(events); + + Assert.Equal(4, graph.Steps.Count); + var api = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.ApiCall); + Assert.Equal(FlowLane.External, api.Lane); + Assert.Equal(events[2].EventId, api.EventId); + Assert.Equal(T0.AddMilliseconds(35), api.OccurredAtUtc.UtcDateTime); + Assert.Equal(4, api.Lifecycle!.Count); + Assert.Equal(new[] { "CachedSubmit", "ApiCallCached", "ApiCallCached", "CachedResolve" }, api.Lifecycle.Select(r => r.Kind)); + Assert.Equal("503", api.Lifecycle[2].ErrorMessage); + Assert.Equal(FlowStepOutcome.Ok, api.Outcome); + Assert.Equal(4100, api.DurationMs); + Assert.Equal("{\"id\":1}", api.InputJson); + Assert.Equal("{\"ok\":true}", api.OutputJson); + Assert.Equal("Delivered · cached", api.Subtitle); + Assert.Null(api.ErrorMessage); + Assert.Equal(SiteRun, api.ExecutionId); + + var run = graph.Steps[0]; var w1 = graph.Steps[1]; var w2 = graph.Steps[3]; + Assert.Equal(new[] { FlowStepKind.ScriptRun, FlowStepKind.AttributeWrite, FlowStepKind.ApiCall, FlowStepKind.AttributeWrite }, + graph.Steps.Select(s => s.Kind)); + var async = Assert.Single(graph.Edges, e => e.Async); + Assert.Equal(w1.EventId, async.FromEventId); + Assert.Equal(api.EventId, async.ToEventId); + Assert.Contains(graph.Edges, e => e.FromEventId == run.EventId && e.ToEventId == w1.EventId && !e.Async); + Assert.Contains(graph.Edges, e => e.FromEventId == w1.EventId && e.ToEventId == w2.EventId && !e.Async); + Assert.DoesNotContain(graph.Edges, e => e.FromEventId == api.EventId); + Assert.Equal(3, graph.Edges.Count); + } + + [Fact] + public void SyncApiCall_SitsInChain_NoAsyncEdge() + { + var events = new List + { + SiteRunRow(20), + WriteRow(30, "A"), + Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, 35, null, "Delmia.AckRecipe", durationMs: 212), + WriteRow(40, "B"), + }; + + var graph = FlowGraphBuilder.Build(events); + + var api = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.ApiCall); + Assert.Equal(212, api.DurationMs); + Assert.Single(api.Lifecycle!); + Assert.Equal("Delivered · sync", api.Subtitle); + Assert.DoesNotContain(graph.Edges, e => e.Async); + Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == api.EventId); + Assert.Contains(graph.Edges, e => e.FromEventId == api.EventId && e.ToEventId == graph.Steps[3].EventId); + Assert.DoesNotContain(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[3].EventId); + } + + [Fact] + public void TwoSyncCalls_NullCorrelation_AreNotFoldedTogether() + { + var events = new List + { + SiteRunRow(20), + Outbound(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered, 30, null, "MesDb"), + Outbound(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Failed, 40, null, "MesDb", error: "deadlock"), + }; + + var graph = FlowGraphBuilder.Build(events); + + Assert.Equal(2, graph.Steps.Count(s => s.Kind == FlowStepKind.DbCall)); + Assert.Equal(FlowStepOutcome.Failed, graph.Steps[2].Outcome); + Assert.Equal(2, graph.Edges.Count); + } + + [Fact] + public void NotifySend_PlusCentralDeliverParked_FoldsIntoOneParkedNotifyNode() + { + var notificationId = Guid.NewGuid(); + var events = new List + { + SiteRunRow(20), + WriteRow(30, "A"), + Outbound(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted, 35, notificationId, "Planners", request: "{\"subject\":\"hi\"}"), + Outbound(AuditChannel.Notification, AuditKind.NotifyDeliver, AuditStatus.Attempted, 5000, notificationId, "Planners", node: "central-a", error: "smtp down", durationMs: 30), + Outbound(AuditChannel.Notification, AuditKind.NotifyDeliver, AuditStatus.Parked, 9000, notificationId, "Planners", node: "central-a", error: "retries exhausted"), + }; + + var graph = FlowGraphBuilder.Build(events); + + var notify = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.Notify); + Assert.Equal(FlowStepOutcome.Parked, notify.Outcome); + Assert.Equal(3, notify.Lifecycle!.Count); + Assert.Equal("central-a", notify.Lifecycle[2].Node); + Assert.Equal("retries exhausted", notify.ErrorMessage); + Assert.Equal(8965, notify.DurationMs); + Assert.Equal("Parked", notify.Subtitle); + Assert.Equal("node-a", notify.Node); + var async = Assert.Single(graph.Edges, e => e.Async); + Assert.Equal(graph.Steps[1].EventId, async.FromEventId); + Assert.Equal(notify.EventId, async.ToEventId); + } + + [Fact] + public void CachedSubmitOnly_IsPending_WithNoDuration() + { + var events = new List + { + SiteRunRow(20), + Outbound(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 35, Guid.NewGuid(), "MesDb"), + }; + + var graph = FlowGraphBuilder.Build(events); + + var db = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.DbCall); + Assert.Equal(FlowStepOutcome.Pending, db.Outcome); + Assert.Null(db.DurationMs); + Assert.Single(db.Lifecycle!); + var async = Assert.Single(graph.Edges); + Assert.True(async.Async); + Assert.Equal(graph.Steps[0].EventId, async.FromEventId); + } + + [Fact] + public void ResponseLinksFromTheInboundExecutionsLastStep_NotTheLastCascadedStep() + { + var events = DelmiaShaped(); + var graph = FlowGraphBuilder.Build(events); + var response = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.Response); + var into = Assert.Single(graph.Edges, e => e.ToEventId == response.EventId); + Assert.Equal(graph.Steps[1].EventId, into.FromEventId); + Assert.Equal(FlowStepKind.RouteCall, graph.Steps[1].Kind); + } + + [Fact] + public void ResponseFallsBackToTheInboundRow_WhenNothingElseSharesItsExecution() + { + var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, + occurredAtUtc: T0, actor: "k", target: "Ping", sourceNode: "central-a", executionId: Root, durationMs: 5, responseSummary: "{}"); + var graph = FlowGraphBuilder.Build([inbound]); + Assert.Equal(2, graph.Steps.Count); + var edge = Assert.Single(graph.Edges); + Assert.Equal(graph.Steps[0].EventId, edge.FromEventId); + Assert.Equal(graph.Steps[1].EventId, edge.ToEventId); + } }