feat(cli): flow-debug run-method|run-value-change|replay

New top-level command group for the Flow Debugger (design 2026-09-05 §7.1). run-method /
run-value-change post the FlowDebugRun* management commands with a 2-minute client wait;
replay posts FlowDebugReplayCommand. A table projection unwraps the FlowDebugRunResult
envelope to one row per step (occurred | kind | lane | target | durationMs | status |
error) using the Commons FlowStepProjector's kind/lane resolution, with a trailing note
row for an incomplete run (error code verbatim, e.g. STANDBY_NODE) or a truncated event
list. --params must be a JSON object (INVALID_PARAMS otherwise). README section added;
command-tree test bumped to 18 groups.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-06 00:11:42 -04:00
parent f783d4efc4
commit 0fb7e16ced
6 changed files with 534 additions and 3 deletions
@@ -0,0 +1,136 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
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 unwraps it to one row per step —
/// <c>occurred | kind | lane | target | durationMs | status | error</c> — in the same
/// chronological order the Flow Debugger page draws them, so a terminal user can read the
/// swimlane top-to-bottom. 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.
/// </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>
/// <returns>Compact JSON suitable for table rendering.</returns>
internal static string ProjectEvents(string json)
{
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 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 truncated = TryGetPropertyCI(root, "truncated", out var t) && t.ValueKind == JsonValueKind.True;
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>)");
if (notes.Count > 0)
rows.Add(new JsonObject { ["note"] = JsonValue.Create(string.Join("; ", notes)) });
return rows.ToJsonString();
}
}
/// <summary>Projects one canonical audit event (camelCase JSON) to its compact row.</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;
}