Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs
T
Joseph Doherty 0ffd4bdbea docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)
Comment/XML-doc changes only, no code logic:
- strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from
  comments, keeping the explanatory rationale
- add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/>
- replace misused <inheritdoc/> with real docs; where <inheritdoc/> was
  combined with useful remarks, move the rationale into // comments
- drop stale replicationActor <param> on DeploymentManagerActor ctor

Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365).
Build 0 errors / 0 warnings.
2026-10-07 14:12:25 -04:00

265 lines
12 KiB
C#

using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Compact table projection for the <c>flow-debug</c> commands.
///
/// <para>
/// The handler returns a <c>FlowDebugRunResult</c> — <c>{ rootExecutionId, completed,
/// error, errorCode, events: [ … ], truncated, … }</c> whose <c>events</c> are canonical
/// audit events (each carrying its domain fields inside a <c>detailsJson</c> string). Handed
/// 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 | 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). <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 — and, separately, an inline log-line cap (<c>logsTruncated</c>).
/// </para>
///
/// <para>JSON output is never touched — <c>--format json</c> gets the full raw result.</para>
/// </summary>
internal static class FlowDebugTableProjection
{
/// <summary>
/// Projects a <c>FlowDebugRunResult</c> body to the row array the table renderer
/// consumes. Returns the input unchanged when it is not JSON or not that shape, so the
/// 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. 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, bool showLogs = false)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json);
}
catch (JsonException)
{
return json;
}
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !TryGetPropertyCI(root, "events", out var events)
|| events.ValueKind != JsonValueKind.Array)
{
return json;
}
var truncated = TryGetPropertyCI(root, "truncated", out var t) && t.ValueKind == JsonValueKind.True;
// Step logs (design 2026-09-09 D7): the inline copy sheds low-priority ScriptLog rows
// BEFORE it sheds step rows, so `logsTruncated` is a separate signal from `truncated`
// and needs its own note — the audit rows are complete, so a replay shows every line.
var logsTruncated = TryGetPropertyCI(root, "logsTruncated", out var lt)
&& lt.ValueKind == JsonValueKind.True;
var rows = ProjectSteps(events, truncated, showLogs);
if (rows == null)
{
// The event array did not deserialize as canonical audit events (a future wire
// shape, or a proxy body) — fall back to one row per raw event so nothing is hidden.
rows = new JsonArray();
foreach (var evt in events.EnumerateArray())
rows.Add(ProjectEvent(evt));
}
var completed = TryGetPropertyCI(root, "completed", out var c) && c.ValueKind == JsonValueKind.True;
var errorCode = StrValue(root, "errorCode");
var error = StrValue(root, "error");
var notes = new List<string>();
if (!completed || errorCode != null || error != null)
{
notes.Add(errorCode != null
? $"{errorCode}: {error ?? "run did not complete"}"
: error ?? "run did not complete");
}
if (truncated)
notes.Add("event list truncated — the audit rows remain complete (audit tree --execution-id <root>)");
// The page shows this; the CLI silently dropped it.
if (logsTruncated)
notes.Add("inline log lines capped — replay pulls the rest");
if (notes.Count > 0)
rows.Add(new JsonObject { ["note"] = JsonValue.Create(string.Join("; ", notes)) });
return rows.ToJsonString();
}
}
private static readonly JsonSerializerOptions EventOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
};
/// <summary>
/// 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, bool showLogs)
{
List<AuditEvent>? list;
try
{
list = JsonSerializer.Deserialize<List<AuditEvent>>(events.GetRawText(), EventOptions);
}
catch (JsonException)
{
return null;
}
if (list == null || list.Any(e => e == null || e.EventId == Guid.Empty))
return null;
// Status per event: the graph's outcome is a coarser projection; the table keeps the
// audit status word (Delivered / Submitted / Parked …) the operator sees elsewhere.
var statusById = new Dictionary<Guid, string?>();
foreach (var e in list)
statusById[e.EventId] = AuditDetailsCodec.Deserialize(e.DetailsJson).Status;
var graph = FlowGraphBuilder.Build(list, truncated);
var rows = new JsonArray();
foreach (var step in graph.Steps)
{
var lifecycle = step.Lifecycle;
var status = lifecycle is { Count: > 0 }
? lifecycle[^1].Status
: statusById.TryGetValue(step.EventId, out var st) ? st : step.Outcome.ToString();
var target = step.Title;
if (step.Lane != FlowLane.External && step.Instance != null && !target.StartsWith(step.Instance, StringComparison.Ordinal))
target = $"{step.Instance}.{target}";
rows.Add(new JsonObject
{
["occurred"] = step.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture),
["kind"] = step.Kind.ToString(),
["lane"] = step.Lane.ToString(),
["target"] = target,
["durationMs"] = step.DurationMs,
["status"] = status,
["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)
{
if (evt.ValueKind != JsonValueKind.Object)
return JsonValue.Create(evt.ToString())!;
var details = AuditDetailsCodec.Deserialize(StrValue(evt, "detailsJson"));
var kind = FlowStepProjector.ResolveKind(details.Channel, details.Kind);
var lane = FlowStepProjector.ResolveLane(kind, details.SourceSiteId);
string? occurred = null;
if (TryGetPropertyCI(evt, "occurredAtUtc", out var o) && o.ValueKind == JsonValueKind.String
&& o.TryGetDateTimeOffset(out var when))
{
occurred = when.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
}
var target = StrValue(evt, "target");
var instance = details.SourceInstanceId;
var shownTarget = target ?? details.SourceScript;
if (instance != null && shownTarget != null && !shownTarget.StartsWith(instance, StringComparison.Ordinal))
shownTarget = $"{instance}.{shownTarget}";
return new JsonObject
{
["occurred"] = occurred,
["kind"] = kind.ToString(),
["lane"] = lane.ToString(),
["target"] = shownTarget ?? instance,
["durationMs"] = details.DurationMs,
["status"] = details.Status,
["error"] = details.ErrorMessage,
};
}
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
private static string? StrValue(JsonElement obj, string name)
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}