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) ---