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 // element, in first-seen order. Using only items[0] would silently drop
// columns for any later element with a different shape. // columns for any later element with a different shape.
var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList(); 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; string[] headers;
if (objectItems.Count > 0) if (objectItems.Count > 0)
{ {
@@ -262,8 +276,12 @@ internal static class CommandHelpers
var known = new HashSet<string>(StringComparer.Ordinal); var known = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in objectItems) foreach (var item in objectItems)
foreach (var prop in item.EnumerateObject()) foreach (var prop in item.EnumerateObject())
{
if (noteIsRawLine && prop.Name == "note")
continue;
if (known.Add(prop.Name)) if (known.Add(prop.Name))
seen.Add(prop.Name); seen.Add(prop.Name);
}
headers = seen.ToArray(); headers = seen.ToArray();
} }
else else
@@ -275,12 +293,20 @@ internal static class CommandHelpers
{ {
if (item.ValueKind == JsonValueKind.Object) 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) item.TryGetProperty(h, out var val)
? val.ValueKind == JsonValueKind.Null ? "" : val.ToString() ? val.ValueKind == JsonValueKind.Null ? "" : val.ToString()
: "").ToArray(); : "").ToArray());
} }
return new[] { item.ToString() }; return OutputFormatter.TableRow.Columns(new[] { item.ToString() });
}); });
OutputFormatter.WriteTable(rows, headers); OutputFormatter.WriteTable(rows, headers);
@@ -51,13 +51,16 @@ public static class FlowDebugCommands
+ "(default 5 min), so a larger value still 504s at that ceiling", + "(default 5 min), so a larger value still 504s at that ceiling",
DefaultValueFactory = _ => 120, 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" }; 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(methodIdOption);
cmd.Add(paramsOption); cmd.Add(paramsOption);
cmd.Add(timeoutOption); cmd.Add(timeoutOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) => cmd.SetAction(async (ParseResult result) =>
{ {
var timeoutSeconds = result.GetValue(timeoutOption); var timeoutSeconds = result.GetValue(timeoutOption);
var showLogs = result.GetValue(showLogsOption);
if (timeoutSeconds <= 0) if (timeoutSeconds <= 0)
{ {
OutputFormatter.WriteError("--timeout-seconds must be greater than zero.", "INVALID_ARGUMENT"); OutputFormatter.WriteError("--timeout-seconds must be greater than zero.", "INVALID_ARGUMENT");
@@ -79,7 +82,7 @@ public static class FlowDebugCommands
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new FlowDebugRunMethodCommand(result.GetValue(methodIdOption), parameters), new FlowDebugRunMethodCommand(result.GetValue(methodIdOption), parameters),
timeout: TimeSpan.FromSeconds(timeoutSeconds), timeout: TimeSpan.FromSeconds(timeoutSeconds),
tableProjector: FlowDebugTableProjection.ProjectEvents); tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
}); });
return cmd; return cmd;
} }
@@ -102,6 +105,7 @@ public static class FlowDebugCommands
+ "(default 5 min), so a larger value still 504s at that ceiling", + "(default 5 min), so a larger value still 504s at that ceiling",
DefaultValueFactory = _ => 120, 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)" }; 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(instanceIdOption);
cmd.Add(scriptOption); cmd.Add(scriptOption);
@@ -109,10 +113,12 @@ public static class FlowDebugCommands
cmd.Add(valueOption); cmd.Add(valueOption);
cmd.Add(waitOption); cmd.Add(waitOption);
cmd.Add(timeoutOption); cmd.Add(timeoutOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) => cmd.SetAction(async (ParseResult result) =>
{ {
var waitSeconds = result.GetValue(waitOption); var waitSeconds = result.GetValue(waitOption);
var timeoutSeconds = result.GetValue(timeoutOption); var timeoutSeconds = result.GetValue(timeoutOption);
var showLogs = result.GetValue(showLogsOption);
if (waitSeconds <= 0) if (waitSeconds <= 0)
{ {
OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT"); OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT");
@@ -145,7 +151,7 @@ public static class FlowDebugCommands
result.GetValue(valueOption), result.GetValue(valueOption),
waitSeconds), waitSeconds),
timeout: TimeSpan.FromSeconds(Math.Max(timeoutSeconds, waitSeconds + ClientSlackSeconds)), timeout: TimeSpan.FromSeconds(Math.Max(timeoutSeconds, waitSeconds + ClientSlackSeconds)),
tableProjector: FlowDebugTableProjection.ProjectEvents); tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
}); });
return cmd; 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 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 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 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" }; 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(instanceIdOption);
cmd.Add(attributeOption); cmd.Add(attributeOption);
cmd.Add(valueOption); cmd.Add(valueOption);
cmd.Add(waitOption); cmd.Add(waitOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) => cmd.SetAction(async (ParseResult result) =>
{ {
var waitSeconds = result.GetValue(waitOption); var waitSeconds = result.GetValue(waitOption);
var showLogs = result.GetValue(showLogsOption);
if (waitSeconds <= 0) if (waitSeconds <= 0)
{ {
OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT"); OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT");
@@ -178,7 +187,7 @@ public static class FlowDebugCommands
result.GetValue(valueOption)!, result.GetValue(valueOption)!,
waitSeconds), waitSeconds),
timeout: TimeSpan.FromSeconds(waitSeconds + ClientSlackSeconds), timeout: TimeSpan.FromSeconds(waitSeconds + ClientSlackSeconds),
tableProjector: FlowDebugTableProjection.ProjectEvents); tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
}); });
return cmd; 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) 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 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" }; var cmd = new Command("replay") { Description = "Replay a past flow from the central audit store" };
cmd.Add(executionIdOption); cmd.Add(executionIdOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) => cmd.SetAction(async (ParseResult result) =>
{ {
var showLogs = result.GetValue(showLogsOption);
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new FlowDebugReplayCommand(result.GetValue(executionIdOption)), new FlowDebugReplayCommand(result.GetValue(executionIdOption)),
tableProjector: FlowDebugTableProjection.ProjectEvents); tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
}); });
return cmd; 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 /// 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 /// 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 — /// <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 /// <c>occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error | logs</c> —
/// order the page draws them, so a terminal user can read the swimlane top-to-bottom. An /// 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 /// 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 /// 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 /// and whose <c>status</c> is the latest row's (outbound-calls plan 2026-09-06, D3). <c>logs</c>
/// <c>note</c> row reports a failed / incomplete run (with the error code verbatim, e.g. /// counts the step's attached <c>ScriptLog</c> lines (step-logs design 2026-09-09); passing
/// <c>STANDBY_NODE</c>) or a truncated event list. /// <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>
/// ///
/// <para>JSON output is never touched — <c>--format json</c> gets the full raw result.</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. /// renderer's own fallbacks still apply.
/// </summary> /// </summary>
/// <param name="json">The raw success JSON body from the management API.</param> /// <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> /// <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; JsonDocument doc;
try try
@@ -62,7 +73,7 @@ internal static class FlowDebugTableProjection
var truncated = TryGetPropertyCI(root, "truncated", out var t) && t.ValueKind == JsonValueKind.True; 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) if (rows == null)
{ {
// The event array did not deserialize as canonical audit events (a future wire // 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 /// 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. /// null when the array is not a list of canonical audit events.
/// </summary> /// </summary>
private static JsonArray? ProjectSteps(JsonElement events, bool truncated) private static JsonArray? ProjectSteps(JsonElement events, bool truncated, bool showLogs)
{ {
List<AuditEvent>? list; List<AuditEvent>? list;
try try
@@ -148,12 +159,47 @@ internal static class FlowDebugTableProjection
["lifecycle"] = lifecycle is { Count: > 0 } ? $"{lifecycle.Count} {(lifecycle.Count == 1 ? "row" : "rows")}" : null, ["lifecycle"] = lifecycle is { Count: > 0 } ? $"{lifecycle.Count} {(lifecycle.Count == 1 ? "row" : "rows")}" : null,
["correlation"] = step.CorrelationId?.ToString("D"), ["correlation"] = step.CorrelationId?.ToString("D"),
["error"] = step.ErrorMessage, ["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; 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> /// <summary>Projects one canonical audit event (camelCase JSON) to its compact row (fallback path).</summary>
private static JsonNode ProjectEvent(JsonElement evt) 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="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> /// <param name="headers">Column header labels.</param>
public static void WriteTable(IEnumerable<string[]> rows, string[] headers) 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 }; private TableRow(string[]? cells, string? rawLine)
allRows.AddRange(rows); {
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]; 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++) for (int i = 0; i < Math.Min(row.Length, widths.Length); i++)
widths[i] = Math.Max(widths[i], (row[i] ?? "").Length); 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++) for (int i = 0; i < headers.Length; i++)
Console.Write((i < row.Length ? row[i] ?? "" : "").PadRight(widths[i] + 2)); Console.Write((i < row.Length ? row[i] ?? "" : "").PadRight(widths[i] + 2));
Console.WriteLine(); 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. chain after the fact.
Table output prints one row per step in chronological order — 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.*`, projection the Flow Debugger page draws (`FlowGraphBuilder`): an outbound `ExternalSystem.*`,
`Database.*` or `Notify` call is ONE row on the `External` lane (kinds `ApiCall` / `DbCall` / `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 / `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` 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 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`, list. `--format json` returns the raw `FlowDebugRunResult` (`rootExecutionId`,
`completed`, `error`, `errorCode`, `events`, `truncated`, `startedAtUtc`, `finishedAtUtc`). `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` #### `flow-debug run-method`
Run an inbound API method with the given parameters, exactly as an external caller would, Run an inbound API method with the given parameters, exactly as an external caller would,
and print the traced flow. and print the traced flow.
```sh ```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 | | 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`) | | `--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. | | `--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. | | `--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 Parameters that fail schema validation are reported as `VALIDATION_FAILED` without running
the script. 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. the branch itself — the client does not need to know the script's trigger kind ahead of time.
```sh ```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 | | 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 | | `--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) | | `--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. | | `--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 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 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. attribute of its own); prefer `run-script` to run a script by name.
```sh ```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 | | 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 | | `--attribute` | yes | Canonical attribute name to write |
| `--value` | yes | Value to write, in canonical string form | | `--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) | | `--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 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. 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. Rebuild a past flow from the central audit store.
```sh ```sh
scadabridge --url <url> flow-debug replay --execution-id <guid> scadabridge --url <url> flow-debug replay --execution-id <guid> [--show-logs]
``` ```
| Option | Required | Description | | Option | Required | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `--execution-id` | yes | Any execution ID in the chain — the root is resolved server-side | | `--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` 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 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.CommandLine;
using System.Text.Json; using System.Text.Json;
using ZB.MOM.WW.Audit; using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.CLI.Commands; using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; 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 /// the command tree shape, <c>--params</c> parsing, and the table projection that turns a
/// <see cref="FlowDebugRunResult"/> into one row per step. /// <see cref="FlowDebugRunResult"/> into one row per step.
/// </summary> /// </summary>
[Collection("Console")]
public class FlowDebugCommandsTests public class FlowDebugCommandsTests
{ {
private static readonly Option<string> Url = new("--url") { Recursive = true }; 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("lifecycle").ValueKind);
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("correlation").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());
}
} }