From e26616bebc0be57d0f05bdb9ffe360d0072cf2e8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 01:38:41 -0400 Subject: [PATCH] feat(commons): project outbound API/DB/notification rows onto the External lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolveKind maps ApiOutbound/DbOutbound/Notification to ApiCall/DbCall/Notify; ResolveLane puts the three on FlowLane.External; outbound outcomes map Delivered→Ok, Failed→Failed, Parked/Discarded→Parked, else Pending; subtitles '{status} · sync|cached', '{op} · n rows' from a DbWrite Extra, '{status}' for Notify. New FlowStepProjector.IsOutbound helper. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- ...flow-debugger-outbound-calls.md.tasks.json | 8 +- .../Types/Debugging/FlowStepProjector.cs | 59 +++++++++- .../Debugging/FlowStepProjectorTests.cs | 101 ++++++++++++++++++ 3 files changed, 163 insertions(+), 5 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 1279add0..90d1689a 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 @@ -4,12 +4,12 @@ { "id": 0, "subject": "Task 0: Commons \u2014 kinds, lane, outcomes, lifecycle row", - "status": "pending" + "status": "completed" }, { "id": 1, "subject": "Task 1: Commons \u2014 FlowStepProjector maps outbound rows", - "status": "pending", + "status": "in_progress", "blockedBy": [ 0 ] @@ -96,5 +96,5 @@ ] } ], - "lastUpdated": "2026-09-06T05:32:15.141670+00:00" -} + "lastUpdated": "2026-09-06T05:37:29.919766+00:00" +} \ No newline at end of file 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 0803c7ad..4a038d9c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; @@ -73,17 +74,34 @@ public static class FlowStepProjector return FlowStepKind.Unknown; } - /// Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags. + /// Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags; outbound API / DB / notify → External. public static FlowLane ResolveLane(FlowStepKind kind, string? sourceSiteId) => kind switch { FlowStepKind.InboundRequest or FlowStepKind.RouteCall or FlowStepKind.Response => FlowLane.Central, FlowStepKind.ScriptRun or FlowStepKind.AttributeWait => FlowLane.Site, FlowStepKind.AttributeWrite => FlowLane.Tags, + FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify => FlowLane.External, _ => string.IsNullOrEmpty(sourceSiteId) ? FlowLane.Central : FlowLane.Site, }; + /// True for the three outbound kinds that live on the External lane. + public static bool IsOutbound(FlowStepKind kind) + => kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify; + private static FlowStepOutcome ResolveOutcome(FlowStepKind kind, string? status, string? error) { + if (IsOutbound(kind)) + { + return status switch + { + nameof(AuditStatus.Delivered) => FlowStepOutcome.Ok, + nameof(AuditStatus.Failed) => FlowStepOutcome.Failed, + nameof(AuditStatus.Parked) or nameof(AuditStatus.Discarded) => FlowStepOutcome.Parked, + // Submitted / Forwarded / Attempted — and anything this build does not know — is still in flight. + _ => FlowStepOutcome.Pending, + }; + } + if (!string.Equals(status, nameof(AuditStatus.Failed), StringComparison.Ordinal)) return FlowStepOutcome.Ok; if ((kind == FlowStepKind.ScriptRun || kind == FlowStepKind.AttributeWait) @@ -107,6 +125,45 @@ public static class FlowStepProjector : extra.WaitOutcome is null ? extra.ObservedValue : extra.ObservedValue is null ? extra.WaitOutcome : $"{extra.WaitOutcome} · {extra.ObservedValue}", + FlowStepKind.ApiCall => string.Equals(d.Kind, nameof(AuditKind.ApiCall), StringComparison.Ordinal) + ? $"{d.Status} · sync" + : $"{d.Status} · cached", + FlowStepKind.DbCall => DbSubtitle(d), + FlowStepKind.Notify => d.Status, _ => d.Kind, }; + + /// + /// A sync DbWrite row carries {"op":"write","rowsAffected":n} / {"op":"read","rowsReturned":n} + /// in Extra (site AuditingDbCommand vocabulary); cached rows carry none and read as "{status} · cached". + /// + private static string? DbSubtitle(AuditDetails d) + { + if (string.Equals(d.Kind, nameof(AuditKind.DbWrite), StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(d.Extra)) + { + try + { + using var doc = JsonDocument.Parse(d.Extra); + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("op", out var opEl) && opEl.ValueKind == JsonValueKind.String) + { + var op = opEl.GetString(); + int? rows = null; + if (root.TryGetProperty("rowsAffected", out var ra) && ra.ValueKind == JsonValueKind.Number) rows = ra.GetInt32(); + else if (root.TryGetProperty("rowsReturned", out var rr) && rr.ValueKind == JsonValueKind.Number) rows = rr.GetInt32(); + if (rows is { } n && n >= 0) + return $"{op} · {n} {(n == 1 ? "row" : "rows")}"; + return op; + } + } + catch (JsonException) + { + // Fall through — an unparseable Extra never fails projection. + } + } + + return string.Equals(d.Kind, nameof(AuditKind.DbWrite), StringComparison.Ordinal) + ? d.Status + : $"{d.Status} · cached"; + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorTests.cs index fe9e8731..0826c6d0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorTests.cs @@ -1,4 +1,6 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging; @@ -47,4 +49,103 @@ public class FlowStepProjectorTests Assert.True((edge with { Async = true }).Async); Assert.True(new FlowEdge(a, b, false, false, Async: false, Spawn: true).Spawn); } + + // ---- Task 1: outbound rows project onto the External lane --------------------------------- + + [Fact] + public void SyncApiCall_Delivered_IsApiCall_External_Ok_SyncSubtitle() + { + var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, + occurredAtUtc: T0.UtcDateTime, actor: "AckRecipe", target: "Delmia.AckRecipe", sourceNode: "node-a", + executionId: Guid.NewGuid(), sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 212, + requestSummary: "{\"id\":1}", responseSummary: "{\"ok\":true}"); + + var step = FlowStepProjector.Project(evt); + + Assert.Equal(FlowStepKind.ApiCall, step.Kind); + Assert.Equal(FlowLane.External, step.Lane); + Assert.Equal(FlowStepOutcome.Ok, step.Outcome); + Assert.Equal("Delmia.AckRecipe", step.Title); + Assert.Equal("Delivered · sync", step.Subtitle); + Assert.Equal("{\"id\":1}", step.InputJson); + Assert.Equal("{\"ok\":true}", step.OutputJson); + Assert.Equal(212, step.DurationMs); + } + + [Fact] + public void CachedSubmit_InDbOutbound_IsDbCall_Pending_CachedSubtitle() + { + var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, + occurredAtUtc: T0.UtcDateTime, target: "MesDb", correlationId: Guid.NewGuid(), executionId: Guid.NewGuid()); + + var step = FlowStepProjector.Project(evt); + + Assert.Equal(FlowStepKind.DbCall, step.Kind); + Assert.Equal(FlowLane.External, step.Lane); + Assert.Equal(FlowStepOutcome.Pending, step.Outcome); + Assert.Equal("Submitted · cached", step.Subtitle); + } + + [Fact] + public void DbWrite_WithRowsAffectedExtra_SubtitleIsOpAndRows() + { + var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered, + occurredAtUtc: T0.UtcDateTime, target: "MesDb", executionId: Guid.NewGuid(), + extra: "{\"op\":\"write\",\"rowsAffected\":3}"); + + var step = FlowStepProjector.Project(evt); + + Assert.Equal(FlowStepKind.DbCall, step.Kind); + Assert.Equal(FlowStepOutcome.Ok, step.Outcome); + Assert.Equal("write · 3 rows", step.Subtitle); + + var read = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered, + occurredAtUtc: T0.UtcDateTime, target: "MesDb", extra: "{\"op\":\"read\",\"rowsReturned\":1}"); + Assert.Equal("read · 1 row", FlowStepProjector.Project(read).Subtitle); + } + + [Fact] + public void NotifySend_Submitted_IsNotify_Pending() + { + var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted, + occurredAtUtc: T0.UtcDateTime, target: "Planners", correlationId: Guid.NewGuid(), executionId: Guid.NewGuid()); + + var step = FlowStepProjector.Project(evt); + + Assert.Equal(FlowStepKind.Notify, step.Kind); + Assert.Equal(FlowLane.External, step.Lane); + Assert.Equal(FlowStepOutcome.Pending, step.Outcome); + Assert.Equal("Planners", step.Title); + Assert.Equal("Submitted", step.Subtitle); + } + + [Theory] + [InlineData(AuditStatus.Parked, FlowStepOutcome.Parked)] + [InlineData(AuditStatus.Discarded, FlowStepOutcome.Parked)] + [InlineData(AuditStatus.Failed, FlowStepOutcome.Failed)] + [InlineData(AuditStatus.Forwarded, FlowStepOutcome.Pending)] + [InlineData(AuditStatus.Attempted, FlowStepOutcome.Pending)] + [InlineData(AuditStatus.Delivered, FlowStepOutcome.Ok)] + public void OutboundStatus_MapsToOutcome(AuditStatus status, FlowStepOutcome expected) + { + var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, status, + occurredAtUtc: T0.UtcDateTime, target: "Delmia.AckRecipe", correlationId: Guid.NewGuid(), + errorMessage: status == AuditStatus.Failed ? "boom" : null); + + var step = FlowStepProjector.Project(evt); + + Assert.Equal(expected, step.Outcome); + Assert.Equal($"{status} · cached", step.Subtitle); + } + + [Fact] + public void ScriptTraceOutcome_Unchanged_FailedStaysFailed_OtherStatusesOk() + { + var ok = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered, + occurredAtUtc: T0.UtcDateTime, target: "A"); + var failed = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Failed, + occurredAtUtc: T0.UtcDateTime, target: "A", errorMessage: "rejected"); + Assert.Equal(FlowStepOutcome.Ok, FlowStepProjector.Project(ok).Outcome); + Assert.Equal(FlowStepOutcome.Failed, FlowStepProjector.Project(failed).Outcome); + } }