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