fix(flow-debugger): final-review remediation — 2000-row trace pull, shared ExceptionHeader parser, surrogate-safe clip, wording, alarm source, register renumber (I1/I2/M1-M8)

This commit is contained in:
Joseph Doherty
2026-09-09 13:30:48 -04:00
parent 0bacc3630e
commit 97f01ae98e
21 changed files with 462 additions and 71 deletions
@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// 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.
/// 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>
@@ -72,6 +72,11 @@ internal static class FlowDebugTableProjection
}
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)
@@ -98,6 +103,10 @@ internal static class FlowDebugTableProjection
if (truncated)
notes.Add("event list truncated — the audit rows remain complete (audit tree --execution-id <root>)");
// Final review M8: 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)) });
@@ -372,31 +372,21 @@
};
}
/// <summary>First line of the exception detail — <c>Namespace.Type: message</c> for a .NET <c>ToString()</c>.</summary>
private string FirstDetailLine
=> Step?.ErrorDetail is { } d ? d.Split('\n')[0].Trim('\r', ' ') : string.Empty;
/// <summary>
/// The parsed header of the captured exception detail (final review M1: the shared
/// <see cref="ExceptionHeader"/>, so this panel and the graph node name the SAME exception —
/// the innermost of a <c>" ---&gt; "</c> chain, where this panel used to show the outermost).
/// The type is NOT shortened here: the node badge is the place that strips the namespace.
/// </summary>
private (string Type, string Message)? ExceptionHead
=> ExceptionHeader.Parse(Step?.ErrorDetail, shortenType: false);
/// <summary>The exception's type, shown in full here (the node badge is the place that shortens it).</summary>
private string ExceptionTypeName
{
get
{
var line = FirstDetailLine;
var i = line.IndexOf(": ", StringComparison.Ordinal);
return i > 0 ? line[..i] : line;
}
}
/// <summary>The exception's type, shown in full here.</summary>
private string ExceptionTypeName => ExceptionHead?.Type ?? string.Empty;
/// <summary>The exception's message; falls back to the step's own error summary if the detail is not the usual shape.</summary>
private string ExceptionMessageText
{
get
{
var line = FirstDetailLine;
var i = line.IndexOf(": ", StringComparison.Ordinal);
return i > 0 ? line[(i + 2)..] : (Step?.ErrorMessage ?? string.Empty);
}
}
=> ExceptionHead is { Message.Length: > 0 } head ? head.Message : (Step?.ErrorMessage ?? string.Empty);
/// <summary>Lines shown under the current filter — <c>Warn +</c> hides <c>debug</c> and <c>info</c>, keeping anything unrecognised.</summary>
private IEnumerable<FlowLogLine> VisibleLogLines
@@ -690,7 +690,10 @@ public partial class FlowDebugger : ComponentBase, IDisposable
// row the run did not already have, the page is no longer short of lines and the flag
// clears. Until then it stays: the site's rows reach central's audit store by telemetry
// 5-30 s later, so a refresh that adds nothing proves nothing.
var logsTruncated = (_raw.LogsTruncated || outcome.Raw?.LogsTruncated == true)
// Final review M7: read the OUTCOME's own flag — FlowDebugService copies the raw result's
// onto it precisely so a caller need not unwrap Raw, and reading Raw here left the record
// member with no consumer.
var logsTruncated = (_raw.LogsTruncated || outcome.LogsTruncated)
&& CountLogRows(merged) <= CountLogRows(_raw.Events);
_raw = _raw with { Events = merged, Truncated = truncated, LogsTruncated = logsTruncated };
_graph = FlowGraphBuilder.Build(merged, truncated, logsTruncated);
@@ -272,37 +272,18 @@ public partial class FlowGraphView : ComponentBase
/// <summary>
/// The subtitle a failed or timed-out step shows instead of its own: <c>{Type}: {message}</c>, where
/// the type is the exception's name without its namespace, parsed from the first line of the captured
/// <see cref="FlowStep.ErrorDetail"/> (a .NET <c>ToString()</c> opens with <c>Namespace.Type: message</c>).
/// A wrapper's first line chains its inner exceptions with <c>" ---&gt; "</c>; the LAST segment is taken,
/// since the innermost exception is the one the script author has to act on. The namespace is stripped
/// only from the head of the type name — any generic arity/argument suffix (<c>`1[System.String]</c>)
/// is kept verbatim. Falls back to the line itself when it carries no <c>": "</c>, and to
/// <see cref="FlowStep.ErrorMessage"/> when there is no detail. Null for every non-failed step.
/// the type is the exception's name without its namespace. Parsing is
/// <see cref="ExceptionHeader.Parse"/> (final review M1 — the same parser the step detail panel
/// uses), which takes the first line of the captured <see cref="FlowStep.ErrorDetail"/> and follows
/// its <c>" ---&gt; "</c> chain to the innermost exception. Falls back to the line itself when it
/// carries no <c>": "</c>, and to <see cref="FlowStep.ErrorMessage"/> when there is no usable detail.
/// Null for every non-failed step.
/// </summary>
internal static string? ExceptionSubtitle(FlowStep s)
{
if (s.Outcome is not (FlowStepOutcome.Failed or FlowStepOutcome.TimedOut)) return null;
if (string.IsNullOrWhiteSpace(s.ErrorDetail)) return s.ErrorMessage;
var first = s.ErrorDetail.Split('\n', 2)[0].TrimEnd('\r').Trim();
// A wrapper (AggregateException, a rethrow) chains inner exceptions on the same line — the last is innermost.
var inner = first.LastIndexOf(" ---> ", StringComparison.Ordinal);
if (inner >= 0) first = first[(inner + " ---> ".Length)..].Trim();
if (first.Length == 0) return s.ErrorMessage;
var split = first.IndexOf(": ", StringComparison.Ordinal);
if (split < 0) return first;
var type = first[..split];
var message = first[(split + 2)..];
// Shorten only the leading namespace: everything from a generic marker on is part of the name.
var generic = type.IndexOfAny(['`', '[']);
var head = generic < 0 ? type : type[..generic];
var dot = head.LastIndexOf('.');
if (dot >= 0 && dot < head.Length - 1)
type = head[(dot + 1)..] + (generic < 0 ? string.Empty : type[generic..]);
return type + ": " + message;
if (ExceptionHeader.Parse(s.ErrorDetail, shortenType: true) is not { } head) return s.ErrorMessage;
return head.Message.Length == 0 ? head.Type : head.Type + ": " + head.Message;
}
private static string Eyebrow(FlowLayoutNode n) => n.Kind switch
@@ -0,0 +1,62 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <summary>
/// Flow Debugger (final review M1): the ONE parser for the header line of a captured
/// exception detail. A .NET <c>Exception.ToString()</c> opens with
/// <c>Namespace.Type: message</c>, and a wrapper (an <c>AggregateException</c>, a rethrow)
/// chains its inner exceptions on that same first line with <c>" ---&gt; "</c>.
///
/// <para>
/// Two surfaces render that header — the graph node's subtitle (namespace stripped, so the
/// badge stays narrow) and the step detail panel's exception block (type in full) — and they
/// had drifted: the panel showed the OUTERMOST exception while the node showed the innermost.
/// Both now call this, so they always agree on which exception is being named.
/// </para>
/// </summary>
public static class ExceptionHeader
{
private const string InnerMarker = " ---> ";
/// <summary>
/// Parses the innermost exception's type and message out of <paramref name="detail"/>.
/// Takes the first line, follows the <c>" ---&gt; "</c> chain to its LAST segment (the
/// innermost exception, which is the one a script author has to act on) and splits it on
/// the first <c>": "</c>.
/// </summary>
/// <param name="detail">The captured detail (a full <c>ToString()</c>), or null.</param>
/// <param name="shortenType">
/// When true the namespace is stripped from the head of the type name — only the head,
/// so a generic arity/argument suffix (<c>`1[System.String]</c>) is kept verbatim.
/// </param>
/// <returns>
/// The type and message; the whole line as the type with an EMPTY message when the line
/// carries no <c>": "</c>; null when there is no detail, or nothing left after the chain
/// is followed (callers fall back to their own error summary in both those cases).
/// </returns>
public static (string Type, string Message)? Parse(string? detail, bool shortenType)
{
if (string.IsNullOrWhiteSpace(detail)) return null;
var first = detail.Split('\n', 2)[0].TrimEnd('\r').Trim();
var inner = first.LastIndexOf(InnerMarker, StringComparison.Ordinal);
if (inner >= 0) first = first[(inner + InnerMarker.Length)..].Trim();
if (first.Length == 0) return null;
var split = first.IndexOf(": ", StringComparison.Ordinal);
if (split < 0) return (first, string.Empty);
var type = first[..split];
var message = first[(split + 2)..];
if (!shortenType) return (type, message);
// Shorten only the leading namespace: everything from a generic marker on is part of the name.
var generic = type.IndexOfAny(['`', '[']);
var head = generic < 0 ? type : type[..generic];
var dot = head.LastIndexOf('.');
if (dot >= 0 && dot < head.Length - 1)
{
type = head[(dot + 1)..] + (generic < 0 ? string.Empty : type[generic..]);
}
return (type, message);
}
}
@@ -209,11 +209,20 @@ public sealed class InboundScriptLogger
}
}
/// <summary>
/// Applies the per-message cap, marking a truncated message with a trailing ellipsis.
///
/// <para>Final review M2 — mirrors the site runtime's <c>ScriptLogger.Clip</c>: cuts one char
/// short when the cap would land between the two halves of a surrogate pair, so an emoji
/// straddling the boundary is dropped whole rather than leaving a lone surrogate that breaks
/// JSON serialization downstream.</para>
/// </summary>
private static string Truncate(string? message)
{
if (string.IsNullOrEmpty(message)) return string.Empty;
return message.Length <= MaxMessageChars
? message
: string.Concat(message.AsSpan(0, MaxMessageChars), "…");
if (message.Length <= MaxMessageChars) return message;
var keep = char.IsHighSurrogate(message[MaxMessageChars - 1]) ? MaxMessageChars - 1 : MaxMessageChars;
return string.Concat(message.AsSpan(0, keep), "…");
}
}
@@ -82,6 +82,26 @@ public sealed class FlowDebugRunner
/// <summary>Quiet period after the last new row before a value-change run is declared complete.</summary>
public static readonly TimeSpan SettleWindow = TimeSpan.FromSeconds(2);
/// <summary>
/// Row cap asked of the site on each <see cref="IPullTraceEventsClient.PullAsync"/> poll — the
/// site's own ceiling (<c>SiteStreamGrpcServer.MaxTraceEventsPerPull</c> = 2000; a larger
/// <c>max_events</c> is rejected <c>InvalidArgument</c>), so this asks for everything the site
/// will serve.
///
/// <para>
/// Final review I1: this was 500, which was ample before step logs but is not any more. The
/// site query is <c>ORDER BY OccurredAtUtc ASC LIMIT</c>, so the page is the OLDEST rows in
/// the window — and a single traced run now emits up to 201 <c>ScriptLog</c> rows (design
/// §3, <c>MaxLogLinesPerRun</c> + the closing dropped-count row). Two chatty runs could
/// therefore fill a 500-row page and push a later spawned run's <c>ScriptRun</c> row past the
/// end of it, making <see cref="WriteAndPollAsync"/> report a false
/// <see cref="NoTriggerCode"/> for a run that did happen. 2000 makes that ~10x harder; the
/// real fix (paging by last-seen timestamp, or excluding <c>ScriptLog</c> from the awaited-run
/// poll) is deferred-work register row 117.
/// </para>
/// </summary>
public const int PullTraceEventsMax = 2000;
private readonly ILogger<FlowDebugRunner> _logger;
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
private readonly Func<DateTimeOffset> _now;
@@ -487,7 +507,7 @@ public sealed class FlowDebugRunner
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
if (d.ExecutionId is { } x) ids.Add(x);
}
var pulled = await puller.PullAsync(site.SiteIdentifier, ids.Take(64).ToList(), since, 500, ct).ConfigureAwait(false);
var pulled = await puller.PullAsync(site.SiteIdentifier, ids.Take(64).ToList(), since, PullTraceEventsMax, ct).ConfigureAwait(false);
var added = false;
foreach (var e in pulled)
{
@@ -157,7 +157,11 @@ public sealed class ScriptLogger
try
{
_emit("warn", "runtime", $"Log truncated: {n} line(s) dropped past the per-run cap of {_maxLines}", null, null);
// Final review M3: worded identically to the central InboundScriptLogger's closing row,
// so the two surfaces read the same in the step-log panel (only the option key differs).
_emit("warn", "runtime",
$"log line cap reached - {n} further log line(s) were not captured " +
$"(ScadaBridge:SiteRuntime:MaxLogLinesPerRun = {_maxLines})", null, null);
}
catch
{
@@ -432,7 +432,8 @@ internal static class ScriptRunLauncher
var state = await compiledScript.RunAsync(globals, cts.Token);
// Step logs (design D7): close the run's log BEFORE the anchor row, so the closing
// "N line(s) dropped" row precedes the ScriptRun in emission order on every path.
// "log line cap reached - N further log line(s)…" row precedes the ScriptRun in
// emission order on every path.
context.Log.FlushDropped();
// Flow Debugger: the ScriptRun row goes into the collector BEFORE the reply so the
@@ -705,6 +706,12 @@ internal static class ScriptRunLauncher
timeout,
instanceName,
logger,
// Final review M6: name the source so ScriptRuntimeContext's `_logTarget` derives
// the alarm this on-trigger run belongs to ("AlarmActor:Foo" -> "Foo") — without
// it every Log.* line from an alarm script was attributed to the INSTANCE, not the
// alarm. `scriptName` above is already the "AlarmActor:{alarmName}" source key, so
// it is passed as-is (re-prefixing it would leave "AlarmActor:" in the target).
sourceScript: scriptName,
// ParentExecutionId tag-cascade: the
// alarm on-trigger run mints its own fresh ExecutionId (the
// ctor's `?? NewGuid()` fallback) and records the firing