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
@@ -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