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.
This commit is contained in:
Joseph Doherty
2026-09-09 12:23:38 -04:00
parent 8ba1a0e2f8
commit ba7f8ee7a9
3 changed files with 72 additions and 3 deletions
@@ -61,6 +61,11 @@ public record FlowDebugReplayCommand(Guid ExecutionId);
/// <param name="Truncated">True when the inline event list was capped (the audit rows remain complete).</param>
/// <param name="StartedAtUtc">When the run started.</param>
/// <param name="FinishedAtUtc">When the run finished (or the replay was served).</param>
/// <param name="LogsTruncated">
/// Step-logs (D7, 2026-09-09): true when the inline <c>FlowTraceCollector</c> dropped one
/// or more low-priority <c>ScriptLog</c> 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.
/// </param>
public record FlowDebugRunResult(
Guid RootExecutionId,
bool Completed,
@@ -69,4 +74,5 @@ public record FlowDebugRunResult(
IReadOnlyList<AuditEvent> Events,
bool Truncated,
DateTimeOffset StartedAtUtc,
DateTimeOffset FinishedAtUtc);
DateTimeOffset FinishedAtUtc,
bool LogsTruncated = false);
@@ -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);
}
/// <summary>
@@ -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);
}
/// <summary>
@@ -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<CancellationToken>()).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 };
/// <summary>A <c>ScriptLog</c> row — the low-priority lane <see cref="FlowStepProjector.IsLogLine"/> recognizes.</summary>
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) ---