From c6529fa99607d6526392b1291b380db54e6f1353 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 9 Sep 2026 12:03:42 -0400 Subject: [PATCH 1/2] feat(cli): flow-debug logs column and --show-logs --- .../Commands/FlowDebugCommands.cs | 20 ++++- .../Commands/FlowDebugTableProjection.cs | 47 +++++++++++- src/ZB.MOM.WW.ScadaBridge.CLI/README.md | 24 ++++-- .../FlowDebugCommandsTests.cs | 76 +++++++++++++++++++ 4 files changed, 154 insertions(+), 13 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs index 20eac1b8..fc86ac84 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs @@ -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("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; 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("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; 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("--attribute") { Description = "Canonical attribute name to write", Required = true }; var valueOption = new Option("--value") { Description = "Value to write (canonical string form)", Required = true }; var waitOption = new Option("--wait-seconds") { Description = "How long to wait for a triggered script run before reporting NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 }; + var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; 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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var executionIdOption = new Option("--execution-id") { Description = "Any execution ID in the chain (from an audit row)", Required = true }; + var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; 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; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs index 5d99c6df..fa6576e3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs @@ -37,8 +37,14 @@ internal static class FlowDebugTableProjection /// renderer's own fallbacks still apply. /// /// The raw success JSON body from the management API. + /// + /// When true, prints each step's ScriptLog lines and (on a failed step) the first + /// three lines of its exception detail as additional indented note rows immediately + /// following that step's row (step-logs design 2026-09-09, CLI Task 14). The logs + /// column (a count of attached log lines) is always present, independent of this flag. + /// /// Compact JSON suitable for table rendering. - internal static string ProjectEvents(string json) + internal static string ProjectEvents(string json, bool showLogs = false) { JsonDocument doc; try @@ -62,7 +68,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 +109,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. /// - private static JsonArray? ProjectSteps(JsonElement events, bool truncated) + private static JsonArray? ProjectSteps(JsonElement events, bool truncated, bool showLogs) { List? list; try @@ -148,12 +154,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; } + /// Formats one as HH:mm:ss.fff LEVEL source message (UTC, level upper-cased and padded to 5 chars). + private static string FormatLogLine(FlowLogLine line) + { + var time = line.OccurredAtUtc.UtcDateTime.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); + var level = (line.Level ?? "info").ToUpperInvariant().PadRight(5); + return $" {time} {level} {line.Source} {line.Message}"; + } + + /// + /// Formats the first three lines of an exception's ToString() detail — the first + /// prefixed exception: , the rest indented four spaces. + /// + private static IEnumerable 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]}"; + } + /// Projects one canonical audit event (camelCase JSON) to its compact row (fallback path). private static JsonNode ProjectEvent(JsonElement evt) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 47d4a288..a347f493 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -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: `, +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 flow-debug run-method --method-id [--params ] [--timeout-seconds 120] +scadabridge --url flow-debug run-method --method-id [--params ] [--timeout-seconds 120] [--show-logs] ``` | Option | Required | Description | @@ -2317,6 +2325,7 @@ scadabridge --url flow-debug run-method --method-id [--params | `--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 flow-debug run-script --instance-id --script [--params ] [--value ] [--wait-seconds 45] [--timeout-seconds 120] +scadabridge --url flow-debug run-script --instance-id --script [--params ] [--value ] [--wait-seconds 45] [--timeout-seconds 120] [--show-logs] ``` | Option | Required | Description | @@ -2342,6 +2351,7 @@ scadabridge --url flow-debug run-script --instance-id --script flow-debug run-value-change --instance-id --attribute --value [--wait-seconds 45] +scadabridge --url flow-debug run-value-change --instance-id --attribute --value [--wait-seconds 45] [--show-logs] ``` | Option | Required | Description | @@ -2364,6 +2374,7 @@ scadabridge --url flow-debug run-value-change --instance-id --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 flow-debug replay --execution-id +scadabridge --url flow-debug replay --execution-id [--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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs index 784636c0..06f01603 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs @@ -302,4 +302,80 @@ public class FlowDebugCommandsTests Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("lifecycle").ValueKind); Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("correlation").ValueKind); } + + /// Builds a run whose script step carries two ScriptLog rows and fails with an exception detail (step-logs plan 2026-09-09, Task 14). + 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 + { + 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); + } } From c1b1702397c95ff81e000b1f86f0b454514d1e35 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 9 Sep 2026 12:15:05 -0400 Subject: [PATCH 2/2] fix(cli): single-key note rows render as raw indented lines under their step (Task 14 review) --- .../Commands/CommandHelpers.cs | 32 +++++++++- .../Commands/FlowDebugCommands.cs | 8 +-- .../Commands/FlowDebugTableProjection.cs | 17 +++-- .../OutputFormatter.cs | 63 +++++++++++++++++-- .../FlowDebugCommandsTests.cs | 56 +++++++++++++++++ 5 files changed, 159 insertions(+), 17 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs index c1f0e170..71aa2098 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs @@ -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(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); diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs index fc86ac84..62316187 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs @@ -51,7 +51,7 @@ public static class FlowDebugCommands + "(default 5 min), so a larger value still 504s at that ceiling", DefaultValueFactory = _ => 120, }; - var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; + var showLogsOption = new Option("--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); @@ -105,7 +105,7 @@ public static class FlowDebugCommands + "(default 5 min), so a larger value still 504s at that ceiling", DefaultValueFactory = _ => 120, }; - var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; + var showLogsOption = new Option("--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); @@ -162,7 +162,7 @@ public static class FlowDebugCommands var attributeOption = new Option("--attribute") { Description = "Canonical attribute name to write", Required = true }; var valueOption = new Option("--value") { Description = "Value to write (canonical string form)", Required = true }; var waitOption = new Option("--wait-seconds") { Description = "How long to wait for a triggered script run before reporting NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 }; - var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; + var showLogsOption = new Option("--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); @@ -195,7 +195,7 @@ public static class FlowDebugCommands private static Command BuildReplay(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var executionIdOption = new Option("--execution-id") { Description = "Any execution ID in the chain (from an audit row)", Required = true }; - var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; + var showLogsOption = new Option("--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); diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs index fa6576e3..13a59599 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs @@ -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 /// the Flow Debugger page uses and prints one row per STEP — -/// occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error — in the -/// order the page draws them, so a terminal user can read the swimlane top-to-bottom. An +/// occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error | logs — +/// 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 lifecycle column counts them -/// and whose status is the latest row's (outbound-calls plan 2026-09-06, D3). A trailing -/// note row reports a failed / incomplete run (with the error code verbatim, e.g. -/// STANDBY_NODE) or a truncated event list. +/// and whose status is the latest row's (outbound-calls plan 2026-09-06, D3). logs +/// counts the step's attached ScriptLog lines (step-logs design 2026-09-09); passing +/// showLogs additionally prints those lines, and a failed step's exception detail, as +/// raw indented lines immediately under that step's row — 's table +/// renderer special-cases a single-property { "note": … } row to print it that way +/// instead of padding it into a table cell. A trailing note row (the same special case) +/// reports a failed / incomplete run (with the error code verbatim, e.g. STANDBY_NODE) +/// or a truncated event list. /// /// /// JSON output is never touched — --format json gets the full raw result. @@ -179,7 +184,7 @@ internal static class FlowDebugTableProjection /// Formats one as HH:mm:ss.fff LEVEL source message (UTC, level upper-cased and padded to 5 chars). private static string FormatLogLine(FlowLogLine line) { - var time = line.OccurredAtUtc.UtcDateTime.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); + 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}"; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/OutputFormatter.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/OutputFormatter.cs index e4e06043..38a94378 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/OutputFormatter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/OutputFormatter.cs @@ -31,19 +31,74 @@ public static class OutputFormatter /// Data rows; each inner array corresponds to a column in the same order as . /// Column header labels. public static void WriteTable(IEnumerable rows, string[] headers) + => WriteTable(rows.Select(TableRow.Columns), headers); + + /// + /// One row for the overload of WriteTable: either padded + /// column cells like the plain 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). + /// + internal readonly struct TableRow { - var allRows = new List { headers }; - allRows.AddRange(rows); + private TableRow(string[]? cells, string? rawLine) + { + Cells = cells; + RawLine = rawLine; + } + + internal string[]? Cells { get; } + + internal string? RawLine { get; } + + /// A normal row, rendered as padded columns aligned with the header set. + internal static TableRow Columns(string[] cells) => new(cells, null); + + /// An already-formatted line, printed exactly as given with no column alignment. + internal static TableRow Raw(string line) => new(null, line); + } + + /// + /// Writes a plain-text padded table like , + /// except a row is printed verbatim — unpadded, uninvolved in the + /// column-width computation — at its position in the row sequence, immediately after + /// whatever row precedes it. + /// + /// Data rows, each either padded columns or a raw line. + /// Column header labels. + internal static void WriteTable(IEnumerable 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!); + } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs index 06f01603..3fac25fe 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs @@ -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, --params parsing, and the table projection that turns a /// into one row per step. /// +[Collection("Console")] public class FlowDebugCommandsTests { private static readonly Option Url = new("--url") { Recursive = true }; @@ -378,4 +380,58 @@ public class FlowDebugCommandsTests Assert.DoesNotContain("WARN", projected); Assert.DoesNotContain("exception:", projected); } + + /// + /// Drives the real table renderer ('s table + /// branch, which is what every flow-debug 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 note 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). + /// + [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()); + } }