From ba7f8ee7a9fd9b1547b5f41a265b1e1c120aaf70 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 9 Sep 2026 12:23:38 -0400 Subject: [PATCH] feat(management): Flow Debugger inbound row carries exception detail; LogsTruncated on the run result FlowDebugRunner.RunMethodAsync now passes InboundScriptResult.ErrorDetail through to the synthesized inbound audit row's errorDetail, so a SCRIPT_ERROR run's exception ToString() lands on AuditDetails.ErrorDetail for the Flow Debugger's inbound node to read (never surfaced to the HTTP caller). FlowDebugRunResult grows an additive LogsTruncated (default false, last positional member) reporting the inline FlowTraceCollector's low-priority (ScriptLog) truncation separately from Truncated (step-row fidelity); RunMethodAsync/RunScriptAsync thread collector.LogsTruncated through on their two collector-backed results. --- .../Messages/Management/FlowDebugCommands.cs | 8 ++- .../FlowDebug/FlowDebugRunner.cs | 6 +- .../FlowDebugRunnerTests.cs | 61 +++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs index 42e89448..2d364033 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs @@ -61,6 +61,11 @@ public record FlowDebugReplayCommand(Guid ExecutionId); /// True when the inline event list was capped (the audit rows remain complete). /// When the run started. /// When the run finished (or the replay was served). +/// +/// Step-logs (D7, 2026-09-09): true when the inline FlowTraceCollector dropped one +/// or more low-priority ScriptLog rows to protect room for step rows — the run's log lines +/// are incomplete inline, but Replay (which reads the audit store, never truncated) is complete. +/// public record FlowDebugRunResult( Guid RootExecutionId, bool Completed, @@ -69,4 +74,5 @@ public record FlowDebugRunResult( IReadOnlyList Events, bool Truncated, DateTimeOffset StartedAtUtc, - DateTimeOffset FinishedAtUtc); + DateTimeOffset FinishedAtUtc, + bool LogsTruncated = false); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs index 1d964416..4498615b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs @@ -188,6 +188,7 @@ public sealed class FlowDebugRunner executionId: root, durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue), errorMessage: result.ErrorMessage, + errorDetail: result.ErrorDetail, requestSummary: requestJson, responseSummary: result.ResultJson, extra: new ScriptTraceExtra { Source = SourceTag }.ToJson()); @@ -212,7 +213,7 @@ public sealed class FlowDebugRunner return new FlowDebugRunResult( root, result.Success, result.ErrorMessage, result.Success ? null : result.ErrorCode, - ordered, Truncated: collector.Truncated, startedAt, finishedAt); + ordered, Truncated: collector.Truncated, startedAt, finishedAt, LogsTruncated: collector.LogsTruncated); } /// @@ -397,7 +398,8 @@ public sealed class FlowDebugRunner return new FlowDebugRunResult( root, response.Success, response.ErrorMessage, response.Success ? null : faultCode ?? CallFailedCode, - ordered, Truncated: collector.Truncated, startedAt, DateTimeOffset.UtcNow); + ordered, Truncated: collector.Truncated, startedAt, DateTimeOffset.UtcNow, + LogsTruncated: collector.LogsTruncated); } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs index 67d31354..d88d703d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs @@ -152,6 +152,30 @@ public class FlowDebugRunnerTests Assert.Same(siteRun, result.Events[2]); } + [Fact] + public async Task RunMethod_ScriptThrows_InboundRowCarriesErrorDetail() + { + // Step-logs D3: InboundScriptExecutor turns an unhandled script exception into a + // SCRIPT_ERROR InboundScriptResult whose ErrorDetail is the exception's own ToString() — + // never surfaced to the HTTP caller, but the Flow Debugger's inbound node reads it off + // the written audit row. + var method = new ApiMethod("M", "x") { Id = 21, TimeoutSeconds = 5 }; + _apiRepo.GetApiMethodByIdAsync(21, Arg.Any()).Returns(method); + _executor.RegisterHandler("M", _ => throw new InvalidOperationException("boom")); + + var result = await Runner().RunMethodAsync(_services.BuildServiceProvider(), + new FlowDebugRunMethodCommand(21, null), Deployer, CancellationToken.None); + + Assert.False(result.Completed); + Assert.Equal("SCRIPT_ERROR", result.ErrorCode); + var inbound = Assert.Single(_central, e => e.AsRow().Kind == AuditKind.InboundRequest).AsRow(); + Assert.NotNull(inbound.ErrorDetail); + Assert.StartsWith("System.InvalidOperationException: boom", inbound.ErrorDetail); + // The returned copy carries the same detail (redacted, same as every other field). + var returnedInbound = result.Events[0].AsRow(); + Assert.StartsWith("System.InvalidOperationException: boom", returnedInbound.ErrorDetail); + } + [Fact] public async Task RunMethod_UnknownMethod_IsNotFound() { @@ -389,6 +413,12 @@ public class FlowDebugRunnerTests executionId: Guid.NewGuid(), target: "work") with { DetailsJson = detailsJson }; + /// A ScriptLog row — the low-priority lane recognizes. + private static AuditEvent LogRow() => + ScadaBridgeAuditEventFactory.Create( + AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered, + executionId: Guid.NewGuid(), target: "work"); + [Fact] public async Task RunMethod_SinkEvents_AreRedacted_WhenARedactorIsRegistered() { @@ -454,6 +484,37 @@ public class FlowDebugRunnerTests Assert.True(result.Truncated); Assert.Equal(501, result.Events.Count); // inbound row + the 500 kept + Assert.False(result.LogsTruncated); // these are full-priority step rows, not log lines + } + + [Fact] + public async Task RunMethod_NoLogRows_LogsTruncatedIsFalse() + { + WireSinkPushingMethod(33, [TraceRow("{}")]); + + var result = await Runner().RunMethodAsync(_services.BuildServiceProvider(), + new FlowDebugRunMethodCommand(33, null), Deployer, CancellationToken.None); + + Assert.True(result.Completed, result.Error); + Assert.False(result.LogsTruncated); + Assert.False(result.Truncated); + } + + [Fact] + public async Task RunMethod_OverLowPriorityReserve_ReportsLogsTruncated_ButNotTruncated() + { + // FlowTraceCollector reserves the last LowPriorityReserveEvents (50) of DefaultMaxEvents + // (500) for full-priority step rows — a low-priority (ScriptLog) Add stops at 450 and + // sets LogsTruncated, leaving Truncated (the step-row fidelity signal) untouched. + WireSinkPushingMethod(34, Enumerable.Range(0, 451).Select(_ => LogRow()).ToList()); + + var result = await Runner().RunMethodAsync(_services.BuildServiceProvider(), + new FlowDebugRunMethodCommand(34, null), Deployer, CancellationToken.None); + + Assert.True(result.Completed, result.Error); + Assert.True(result.LogsTruncated); + Assert.False(result.Truncated); + Assert.Equal(451, result.Events.Count); // inbound row + the 450 log rows kept } // --- Flow Debugger script entry (design 2026-09-08) ---