Merge wt/step-logs-t14: Task 14

This commit is contained in:
Joseph Doherty
2026-09-09 12:19:36 -04:00
6 changed files with 308 additions and 25 deletions
@@ -255,6 +255,20 @@ internal static class CommandHelpers
// element, in first-seen order. Using only items[0] would silently drop
// columns for any later element with a different shape.
var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList();
// A "note" property that appears ONLY on single-property rows (a trailing
// run-level note, or — Task 14 review, 2026-09-09 — a step's folded log/exception
// line) is not a real column: every other row's cell there would render blank,
// and column padding would swallow the raw line's own leading indentation. Such
// rows print as a raw unpadded line instead, at their position in the sequence,
// and "note" is excluded from the header union so nothing renders a blank column
// for it.
var noteOnlyRows = objectItems
.Where(i => i.EnumerateObject().Any(p => p.Name == "note"))
.ToList();
var noteIsRawLine = noteOnlyRows.Count > 0
&& noteOnlyRows.All(i => i.EnumerateObject().Count() == 1);
string[] headers;
if (objectItems.Count > 0)
{
@@ -262,8 +276,12 @@ internal static class CommandHelpers
var known = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in objectItems)
foreach (var prop in item.EnumerateObject())
{
if (noteIsRawLine && prop.Name == "note")
continue;
if (known.Add(prop.Name))
seen.Add(prop.Name);
}
headers = seen.ToArray();
}
else
@@ -275,12 +293,20 @@ internal static class CommandHelpers
{
if (item.ValueKind == JsonValueKind.Object)
{
return headers.Select(h =>
if (noteIsRawLine
&& item.EnumerateObject().Count() == 1
&& item.TryGetProperty("note", out var noteValue))
{
return OutputFormatter.TableRow.Raw(
noteValue.ValueKind == JsonValueKind.Null ? "" : noteValue.ToString());
}
return OutputFormatter.TableRow.Columns(headers.Select(h =>
item.TryGetProperty(h, out var val)
? val.ValueKind == JsonValueKind.Null ? "" : val.ToString()
: "").ToArray();
: "").ToArray());
}
return new[] { item.ToString() };
return OutputFormatter.TableRow.Columns(new[] { item.ToString() });
});
OutputFormatter.WriteTable(rows, headers);
@@ -51,13 +51,16 @@ public static class FlowDebugCommands
+ "(default 5 min), so a larger value still 504s at that ceiling",
DefaultValueFactory = _ => 120,
};
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
var cmd = new Command("run-method") { Description = "Run an inbound API method on the active central node with full tracing and print the flow's steps" };
cmd.Add(methodIdOption);
cmd.Add(paramsOption);
cmd.Add(timeoutOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var timeoutSeconds = result.GetValue(timeoutOption);
var showLogs = result.GetValue(showLogsOption);
if (timeoutSeconds <= 0)
{
OutputFormatter.WriteError("--timeout-seconds must be greater than zero.", "INVALID_ARGUMENT");
@@ -79,7 +82,7 @@ public static class FlowDebugCommands
result, urlOption, formatOption, usernameOption, passwordOption,
new FlowDebugRunMethodCommand(result.GetValue(methodIdOption), parameters),
timeout: TimeSpan.FromSeconds(timeoutSeconds),
tableProjector: FlowDebugTableProjection.ProjectEvents);
tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
return cmd;
}
@@ -102,6 +105,7 @@ public static class FlowDebugCommands
+ "(default 5 min), so a larger value still 504s at that ceiling",
DefaultValueFactory = _ => 120,
};
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
var cmd = new Command("run-script") { Description = "Run one site script with tracing — a ValueChange/Conditional script is fired by writing its trigger attribute (--value); any other script is called directly (--params)" };
cmd.Add(instanceIdOption);
cmd.Add(scriptOption);
@@ -109,10 +113,12 @@ public static class FlowDebugCommands
cmd.Add(valueOption);
cmd.Add(waitOption);
cmd.Add(timeoutOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var waitSeconds = result.GetValue(waitOption);
var timeoutSeconds = result.GetValue(timeoutOption);
var showLogs = result.GetValue(showLogsOption);
if (waitSeconds <= 0)
{
OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT");
@@ -145,7 +151,7 @@ public static class FlowDebugCommands
result.GetValue(valueOption),
waitSeconds),
timeout: TimeSpan.FromSeconds(Math.Max(timeoutSeconds, waitSeconds + ClientSlackSeconds)),
tableProjector: FlowDebugTableProjection.ProjectEvents);
tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
return cmd;
}
@@ -156,14 +162,17 @@ public static class FlowDebugCommands
var attributeOption = new Option<string>("--attribute") { Description = "Canonical attribute name to write", Required = true };
var valueOption = new Option<string>("--value") { Description = "Value to write (canonical string form)", Required = true };
var waitOption = new Option<int>("--wait-seconds") { Description = "How long to wait for a triggered script run before reporting NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 };
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
var cmd = new Command("run-value-change") { Description = "Write an attribute through the traced path and capture the script run(s) the change triggers — the attribute-level path (also fires Expression triggers); prefer run-script to run a named script" };
cmd.Add(instanceIdOption);
cmd.Add(attributeOption);
cmd.Add(valueOption);
cmd.Add(waitOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var waitSeconds = result.GetValue(waitOption);
var showLogs = result.GetValue(showLogsOption);
if (waitSeconds <= 0)
{
OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT");
@@ -178,7 +187,7 @@ public static class FlowDebugCommands
result.GetValue(valueOption)!,
waitSeconds),
timeout: TimeSpan.FromSeconds(waitSeconds + ClientSlackSeconds),
tableProjector: FlowDebugTableProjection.ProjectEvents);
tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
return cmd;
}
@@ -186,14 +195,17 @@ public static class FlowDebugCommands
private static Command BuildReplay(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var executionIdOption = new Option<Guid>("--execution-id") { Description = "Any execution ID in the chain (from an audit row)", Required = true };
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
var cmd = new Command("replay") { Description = "Replay a past flow from the central audit store" };
cmd.Add(executionIdOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var showLogs = result.GetValue(showLogsOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new FlowDebugReplayCommand(result.GetValue(executionIdOption)),
tableProjector: FlowDebugTableProjection.ProjectEvents);
tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
return cmd;
}
@@ -18,13 +18,18 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// to the generic renderer unprojected, the object takes the Property/Value branch and the
/// whole event array lands in one cell as raw JSON. This projects the events through the same
/// <see cref="FlowGraphBuilder"/> the Flow Debugger page uses and prints one row per STEP —
/// <c>occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error</c> — in the
/// order the page draws them, so a terminal user can read the swimlane top-to-bottom. An
/// <c>occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error | logs</c> —
/// in the order the page draws them, so a terminal user can read the swimlane top-to-bottom. An
/// outbound operation's rows (a cached call's Submit → Forwarded → Attempted → Resolve, a
/// notification's Send → Deliver) fold into ONE row whose <c>lifecycle</c> column counts them
/// and whose <c>status</c> is the latest row's (outbound-calls plan 2026-09-06, D3). A trailing
/// <c>note</c> row reports a failed / incomplete run (with the error code verbatim, e.g.
/// <c>STANDBY_NODE</c>) or a truncated event list.
/// and whose <c>status</c> is the latest row's (outbound-calls plan 2026-09-06, D3). <c>logs</c>
/// counts the step's attached <c>ScriptLog</c> lines (step-logs design 2026-09-09); passing
/// <c>showLogs</c> additionally prints those lines, and a failed step's exception detail, as
/// raw indented lines immediately under that step's row — <see cref="CommandHelpers"/>'s table
/// renderer special-cases a single-property <c>{ "note": … }</c> row to print it that way
/// instead of padding it into a table cell. A trailing <c>note</c> row (the same special case)
/// reports a failed / incomplete run (with the error code verbatim, e.g. <c>STANDBY_NODE</c>)
/// or a truncated event list.
/// </para>
///
/// <para>JSON output is never touched — <c>--format json</c> gets the full raw result.</para>
@@ -37,8 +42,14 @@ internal static class FlowDebugTableProjection
/// renderer's own fallbacks still apply.
/// </summary>
/// <param name="json">The raw success JSON body from the management API.</param>
/// <param name="showLogs">
/// When true, prints each step's <c>ScriptLog</c> lines and (on a failed step) the first
/// three lines of its exception detail as additional indented <c>note</c> rows immediately
/// following that step's row (step-logs design 2026-09-09, CLI Task 14). The <c>logs</c>
/// column (a count of attached log lines) is always present, independent of this flag.
/// </param>
/// <returns>Compact JSON suitable for table rendering.</returns>
internal static string ProjectEvents(string json)
internal static string ProjectEvents(string json, bool showLogs = false)
{
JsonDocument doc;
try
@@ -62,7 +73,7 @@ internal static class FlowDebugTableProjection
var truncated = TryGetPropertyCI(root, "truncated", out var t) && t.ValueKind == JsonValueKind.True;
var rows = ProjectSteps(events, truncated);
var rows = ProjectSteps(events, truncated, showLogs);
if (rows == null)
{
// The event array did not deserialize as canonical audit events (a future wire
@@ -103,7 +114,7 @@ internal static class FlowDebugTableProjection
/// Builds the flow graph from the canonical events and projects one row per step. Returns
/// null when the array is not a list of canonical audit events.
/// </summary>
private static JsonArray? ProjectSteps(JsonElement events, bool truncated)
private static JsonArray? ProjectSteps(JsonElement events, bool truncated, bool showLogs)
{
List<AuditEvent>? list;
try
@@ -148,12 +159,47 @@ internal static class FlowDebugTableProjection
["lifecycle"] = lifecycle is { Count: > 0 } ? $"{lifecycle.Count} {(lifecycle.Count == 1 ? "row" : "rows")}" : null,
["correlation"] = step.CorrelationId?.ToString("D"),
["error"] = step.ErrorMessage,
["logs"] = step.Logs?.Count ?? 0,
});
if (!showLogs)
continue;
if (step.Logs is { Count: > 0 } logs)
{
foreach (var line in logs)
rows.Add(new JsonObject { ["note"] = JsonValue.Create(FormatLogLine(line)) });
}
if (!string.IsNullOrWhiteSpace(step.ErrorDetail))
{
foreach (var line in FormatErrorDetailLines(step.ErrorDetail))
rows.Add(new JsonObject { ["note"] = JsonValue.Create(line) });
}
}
return rows;
}
/// <summary>Formats one <see cref="FlowLogLine"/> as <c> HH:mm:ss.fff LEVEL source message</c> (UTC, level upper-cased and padded to 5 chars).</summary>
private static string FormatLogLine(FlowLogLine line)
{
var time = line.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
var level = (line.Level ?? "info").ToUpperInvariant().PadRight(5);
return $" {time} {level} {line.Source} {line.Message}";
}
/// <summary>
/// Formats the first three lines of an exception's <c>ToString()</c> detail — the first
/// prefixed <c> exception: </c>, the rest indented four spaces.
/// </summary>
private static IEnumerable<string> FormatErrorDetailLines(string errorDetail)
{
var lines = errorDetail.Replace("\r\n", "\n").Split('\n');
for (var i = 0; i < Math.Min(3, lines.Length); i++)
yield return i == 0 ? $" exception: {lines[i]}" : $" {lines[i]}";
}
/// <summary>Projects one canonical audit event (camelCase JSON) to its compact row (fallback path).</summary>
private static JsonNode ProjectEvent(JsonElement evt)
{
@@ -31,19 +31,74 @@ public static class OutputFormatter
/// <param name="rows">Data rows; each inner array corresponds to a column in the same order as <paramref name="headers"/>.</param>
/// <param name="headers">Column header labels.</param>
public static void WriteTable(IEnumerable<string[]> rows, string[] headers)
=> WriteTable(rows.Select(TableRow.Columns), headers);
/// <summary>
/// One row for the <see cref="TableRow"/> overload of <c>WriteTable</c>: either padded
/// column cells like the plain <see cref="string[]"/> overload, or an already-formatted
/// line printed verbatim with no column padding/alignment at all (Task 14 review,
/// 2026-09-09) — used for a note/log line folded onto a step, which must render as a raw
/// indented line under that step's row rather than as a cell in a table column (every
/// other row's cell there would otherwise render blank, and the line's own leading
/// whitespace would be swallowed by column padding).
/// </summary>
internal readonly struct TableRow
{
var allRows = new List<string[]> { headers };
allRows.AddRange(rows);
private TableRow(string[]? cells, string? rawLine)
{
Cells = cells;
RawLine = rawLine;
}
internal string[]? Cells { get; }
internal string? RawLine { get; }
/// <summary>A normal row, rendered as padded columns aligned with the header set.</summary>
internal static TableRow Columns(string[] cells) => new(cells, null);
/// <summary>An already-formatted line, printed exactly as given with no column alignment.</summary>
internal static TableRow Raw(string line) => new(null, line);
}
/// <summary>
/// Writes a plain-text padded table like <see cref="WriteTable(IEnumerable{string[]}, string[])"/>,
/// except a <see cref="TableRow.Raw"/> row is printed verbatim — unpadded, uninvolved in the
/// column-width computation — at its position in the row sequence, immediately after
/// whatever row precedes it.
/// </summary>
/// <param name="rows">Data rows, each either padded columns or a raw line.</param>
/// <param name="headers">Column header labels.</param>
internal static void WriteTable(IEnumerable<TableRow> rows, string[] headers)
{
var rowList = rows.ToList();
var widths = new int[headers.Length];
foreach (var row in allRows)
void Consider(string[] row)
{
for (int i = 0; i < Math.Min(row.Length, widths.Length); i++)
widths[i] = Math.Max(widths[i], (row[i] ?? "").Length);
}
foreach (var row in allRows)
Consider(headers);
foreach (var row in rowList)
if (row.Cells != null)
Consider(row.Cells);
void PrintColumns(string[] row)
{
for (int i = 0; i < headers.Length; i++)
Console.Write((i < row.Length ? row[i] ?? "" : "").PadRight(widths[i] + 2));
Console.WriteLine();
}
PrintColumns(headers);
foreach (var row in rowList)
{
if (row.RawLine != null)
Console.WriteLine(row.RawLine);
else
PrintColumns(row.Cells!);
}
}
}
+18 -6
View File
@@ -2293,23 +2293,31 @@ Trace steps are ordinary audit rows: a run leaves them in the central `AuditLog`
chain after the fact.
Table output prints one row per step in chronological order —
`occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error` — the same
`occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error | logs` — the same
projection the Flow Debugger page draws (`FlowGraphBuilder`): an outbound `ExternalSystem.*`,
`Database.*` or `Notify` call is ONE row on the `External` lane (kinds `ApiCall` / `DbCall` /
`Notify`), and a cached call's or notification's later rows (Forwarded → Attempted → Delivered /
Parked) fold into it — `lifecycle` counts them and `status` is the latest row's (`Submitted`
while the operation is still in flight; re-run `replay` to see it settle). A trailing `note`
while the operation is still in flight; re-run `replay` to see it settle). `logs` is the count
of `ScriptLog` lines a step's script emitted (step-logs design 2026-09-09). A trailing `note`
row reports a run that did not complete (the error code verbatim) or a truncated inline event
list. `--format json` returns the raw `FlowDebugRunResult` (`rootExecutionId`,
`completed`, `error`, `errorCode`, `events`, `truncated`, `startedAtUtc`, `finishedAtUtc`).
Pass `--show-logs` (on `run-method`, `run-script`, `run-value-change` and `replay`) to print
each step's log lines immediately under its row, as additional indented `note` rows —
` HH:mm:ss.fff LEVEL source message` (UTC, level upper-cased) — and, for a failed step whose
error carries an exception detail, the first three lines of it (` exception: <first line>`,
then two more lines indented four spaces). Off by default; the `logs` count column is always
shown regardless of this flag.
#### `flow-debug run-method`
Run an inbound API method with the given parameters, exactly as an external caller would,
and print the traced flow.
```sh
scadabridge --url <url> flow-debug run-method --method-id <int> [--params <json>] [--timeout-seconds 120]
scadabridge --url <url> flow-debug run-method --method-id <int> [--params <json>] [--timeout-seconds 120] [--show-logs]
```
| Option | Required | Description |
@@ -2317,6 +2325,7 @@ scadabridge --url <url> flow-debug run-method --method-id <int> [--params <json>
| `--method-id` | yes | Inbound API method ID (`api-method list`) |
| `--params` | no | Flat JSON object of parameters, validated against the method's parameter schema. Omit for a parameterless method. |
| `--timeout-seconds` | no | Client-side wait for the run (default 120). Raise this above the method's own `TimeoutSeconds + 25` for a long-running method, or the client gives up before the server's own run grace does. **Server ceiling:** the management endpoint applies `ScadaBridge:ManagementService:LongRunningCommandTimeout` (default 5 min) to `flow-debug run-method`, so a value above that still returns `504` at the server's ceiling — raise the server setting too if a method legitimately runs longer. |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
Parameters that fail schema validation are reported as `VALIDATION_FAILED` without running
the script.
@@ -2331,7 +2340,7 @@ method. The runner resolves the script from the instance's flattened configurati
the branch itself — the client does not need to know the script's trigger kind ahead of time.
```sh
scadabridge --url <url> flow-debug run-script --instance-id <int> --script <name> [--params <json>] [--value <string>] [--wait-seconds 45] [--timeout-seconds 120]
scadabridge --url <url> flow-debug run-script --instance-id <int> --script <name> [--params <json>] [--value <string>] [--wait-seconds 45] [--timeout-seconds 120] [--show-logs]
```
| Option | Required | Description |
@@ -2342,6 +2351,7 @@ scadabridge --url <url> flow-debug run-script --instance-id <int> --script <name
| `--value` | no | Value to write to the trigger attribute of a ValueChange/Conditional script, in canonical string form |
| `--wait-seconds` | no | Write kinds: how long to wait for the triggered run before reporting `NO_TRIGGER_WITHIN_WINDOW` (default 45) |
| `--timeout-seconds` | no | Client-side wait for the run (default 120). Raise this above the script's own `ExecutionTimeoutSeconds + 25` for a long-running script. **Server ceiling:** the management endpoint applies `ScadaBridge:ManagementService:LongRunningCommandTimeout` (default 5 min) to `flow-debug run-script`, so a value above that still returns `504` at the server's ceiling — raise the server setting too if a script legitimately runs longer. |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
A missing `--value` on a write-kind script, or `--params` that fail schema validation on a
call-kind script, are reported as `VALIDATION_FAILED` without running the script. An unknown
@@ -2355,7 +2365,7 @@ attribute-level path (it is also the way to fire an `Expression` trigger, which
attribute of its own); prefer `run-script` to run a script by name.
```sh
scadabridge --url <url> flow-debug run-value-change --instance-id <int> --attribute <name> --value <string> [--wait-seconds 45]
scadabridge --url <url> flow-debug run-value-change --instance-id <int> --attribute <name> --value <string> [--wait-seconds 45] [--show-logs]
```
| Option | Required | Description |
@@ -2364,6 +2374,7 @@ scadabridge --url <url> flow-debug run-value-change --instance-id <int> --attrib
| `--attribute` | yes | Canonical attribute name to write |
| `--value` | yes | Value to write, in canonical string form |
| `--wait-seconds` | no | How long to wait for a triggered run before reporting `NO_TRIGGER_WITHIN_WINDOW` (default 45) |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
The client waits `--wait-seconds` + 25 (no separate option — the client's HTTP wait always
derives from `--wait-seconds`). The write itself is real — the attribute changes — and is the root step of the flow.
@@ -2376,12 +2387,13 @@ default 30) correlates back to this run.
Rebuild a past flow from the central audit store.
```sh
scadabridge --url <url> flow-debug replay --execution-id <guid>
scadabridge --url <url> flow-debug replay --execution-id <guid> [--show-logs]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--execution-id` | yes | Any execution ID in the chain — the root is resolved server-side |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
Only flows whose site legs were traced (a Live run, or a method with `--trace-all-calls`
set) have site steps to replay; an untraced call shows its central `RouteCall` step plus the
@@ -1,6 +1,7 @@
using System.CommandLine;
using System.Text.Json;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -14,6 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// the command tree shape, <c>--params</c> parsing, and the table projection that turns a
/// <see cref="FlowDebugRunResult"/> into one row per step.
/// </summary>
[Collection("Console")]
public class FlowDebugCommandsTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
@@ -302,4 +304,134 @@ public class FlowDebugCommandsTests
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("lifecycle").ValueKind);
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("correlation").ValueKind);
}
/// <summary>Builds a run whose script step carries two <c>ScriptLog</c> rows and fails with an exception detail (step-logs plan 2026-09-09, Task 14).</summary>
private static string StepLogsPayload(out DateTime log1Time, out DateTime log2Time)
{
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var t0 = new DateTime(2026, 9, 9, 10, 0, 0, 0, DateTimeKind.Utc);
log1Time = t0.AddMilliseconds(30);
log2Time = t0.AddMilliseconds(31);
var events = new List<AuditEvent>
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0, target: "DelmiaSim01.ProcessRecipeDownload", executionId: root, durationMs: 100,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Failed,
occurredAtUtc: t0.AddMilliseconds(10), target: "ProcessRecipeDownload", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", sourceScript: "ProcessRecipeDownload", durationMs: 50,
errorMessage: "boom", errorDetail: "System.InvalidOperationException: boom\n at X\n at Y\n at Z"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
occurredAtUtc: log1Time, target: "ProcessRecipeDownload", executionId: child,
requestSummary: "hello", extra: new ScriptTraceExtra { LogLevel = "warn", LogSource = "script", Seq = 1 }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
occurredAtUtc: log2Time, target: "ProcessRecipeDownload", executionId: child,
requestSummary: "world", extra: new ScriptTraceExtra { LogLevel = "warn", LogSource = "script", Seq = 2 }.ToJson()),
};
var result = new FlowDebugRunResult(root, true, null, null, events, false, new DateTimeOffset(t0), new DateTimeOffset(t0.AddSeconds(1)));
return JsonSerializer.Serialize(result, CamelCase);
}
[Fact]
public void Projection_LogsColumn_CountsAttachedLogLines()
{
var projected = FlowDebugTableProjection.ProjectEvents(StepLogsPayload(out _, out _));
using var doc = JsonDocument.Parse(projected);
var rows = doc.RootElement.EnumerateArray().ToList();
Assert.Equal(2, rows.Count); // RouteCall + ScriptRun — no note rows without --show-logs
Assert.Equal(0, rows[0].GetProperty("logs").GetInt32());
Assert.Equal(2, rows[1].GetProperty("logs").GetInt32());
Assert.All(rows, r => Assert.False(r.TryGetProperty("note", out _)));
}
[Fact]
public void Projection_ShowLogsTrue_PrintsIndentedLogLinesAndExceptionDetail_UnderTheStepRow()
{
var json = StepLogsPayload(out var log1Time, out var log2Time);
var projected = FlowDebugTableProjection.ProjectEvents(json, showLogs: true);
using var doc = JsonDocument.Parse(projected);
var rows = doc.RootElement.EnumerateArray().ToList();
// RouteCall, ScriptRun, 2 log lines, 3 exception-detail lines (capped at 3 of the 4 available).
Assert.Equal(7, rows.Count);
var expectedLog1 = $" {log1Time:HH:mm:ss.fff} WARN script hello";
var expectedLog2 = $" {log2Time:HH:mm:ss.fff} WARN script world";
Assert.Equal(expectedLog1, rows[2].GetProperty("note").GetString());
Assert.Equal(expectedLog2, rows[3].GetProperty("note").GetString());
Assert.Equal(" exception: System.InvalidOperationException: boom", rows[4].GetProperty("note").GetString());
Assert.Equal(" " + " at X", rows[5].GetProperty("note").GetString());
Assert.Equal(" " + " at Y", rows[6].GetProperty("note").GetString());
Assert.DoesNotContain("at Z", projected);
}
[Fact]
public void Projection_ShowLogsFalse_OmitsLogAndExceptionDetailLines()
{
var projected = FlowDebugTableProjection.ProjectEvents(StepLogsPayload(out _, out _), showLogs: false);
Assert.DoesNotContain("WARN", projected);
Assert.DoesNotContain("exception:", projected);
}
/// <summary>
/// Drives the real table renderer (<see cref="CommandHelpers.HandleResponse"/>'s table
/// branch, which is what every <c>flow-debug</c> command actually prints to the terminal)
/// rather than only inspecting the intermediate JSON — a code review of Task 14 found the
/// generic column-union renderer padded the log/exception <c>note</c> rows into a far-right
/// "note" column instead of printing them as raw indented lines under their step, which the
/// JSON-level assertions above could not have caught (Task 14 review, 2026-09-09).
/// </summary>
[Fact]
public void HandleResponse_TableFormat_ShowLogsTrue_PrintsLogLineRawUnderScriptRunRow_NoNoteHeader()
{
var json = StepLogsPayload(out var log1Time, out var log2Time);
var projected = FlowDebugTableProjection.ProjectEvents(json, showLogs: true);
var response = new ManagementResponse(200, projected, null, null);
var writer = new StringWriter();
Console.SetOut(writer);
try
{
var exitCode = CommandHelpers.HandleResponse(response, "table");
Assert.Equal(0, exitCode);
}
finally
{
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
var lines = writer.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
// The header row must not gain a "note" column — a raw log/exception line has nowhere
// sensible to sit in a column table, so it must never become one.
Assert.DoesNotContain("note", lines[0]);
var scriptRunLineIndex = Array.FindIndex(lines, l => l.Contains("ScriptRun"));
Assert.True(scriptRunLineIndex >= 0, "expected a ScriptRun row in the rendered table");
var expectedLog1 = $" {log1Time:HH:mm:ss.fff} WARN script hello";
var expectedLog2 = $" {log2Time:HH:mm:ss.fff} WARN script world";
// The two log lines print as raw, unpadded lines immediately under the ScriptRun row —
// not aligned into any table column.
var log1Line = lines[scriptRunLineIndex + 1];
Assert.StartsWith(" ", log1Line);
Assert.Contains("WARN", log1Line);
Assert.Equal(expectedLog1, log1Line);
Assert.Equal(expectedLog2, lines[scriptRunLineIndex + 2]);
// The exception detail follows, also raw and capped at 3 of the 4 available lines.
Assert.Equal(" exception: System.InvalidOperationException: boom", lines[scriptRunLineIndex + 3]);
Assert.Equal(" at X", lines[scriptRunLineIndex + 4]);
Assert.Equal(" at Y", lines[scriptRunLineIndex + 5]);
Assert.DoesNotContain("at Z", writer.ToString());
}
}