feat(commons): FlowStep/FlowGraph projection + FlowGraphBuilder for the Flow Debugger
Central-side projection of ScriptTrace/ApiInbound audit events (design §7.2): lanes Central/Site/Tags, in-execution chaining, spawner→child edges (RouteCall by childExecutionId), inferred write→satisfied-wait echo edge, synthesized Response node. ScriptTraceExtra is the camelCase Extra payload shared by every emitter. Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
|
||||
/// <summary>A directed edge between two flow steps.</summary>
|
||||
/// <param name="FromEventId">Source step.</param>
|
||||
/// <param name="ToEventId">Target step.</param>
|
||||
/// <param name="CrossesBoundary">True when the edge crosses the central ↔ site boundary (drawn in the accent colour).</param>
|
||||
/// <param name="External">True for an inferred relationship the system did not observe directly — a device echo satisfying a wait after a write (drawn dashed).</param>
|
||||
public sealed record FlowEdge(Guid FromEventId, Guid ToEventId, bool CrossesBoundary, bool External);
|
||||
|
||||
/// <summary>The projected flow of one execution tree (Flow Debugger design 2026-09-05 §7.2).</summary>
|
||||
/// <param name="Steps">Steps in display order (time, then kind rank, then event id).</param>
|
||||
/// <param name="Edges">Edges between steps.</param>
|
||||
/// <param name="Predicted">True when the graph came from static analysis rather than real events.</param>
|
||||
/// <param name="Truncated">True when the inline event list was capped (the audit rows remain complete — Replay shows everything).</param>
|
||||
public sealed record FlowGraph(
|
||||
IReadOnlyList<FlowStep> Steps,
|
||||
IReadOnlyList<FlowEdge> Edges,
|
||||
bool Predicted,
|
||||
bool Truncated)
|
||||
{
|
||||
/// <summary>An empty, non-predicted graph.</summary>
|
||||
public static FlowGraph Empty { get; } = new(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), false, false);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="FlowGraph"/> from the audit events of one execution tree
|
||||
/// (Flow Debugger design 2026-09-05 §7.2). Pure: same events in, same graph out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rules:
|
||||
/// <list type="bullet">
|
||||
/// <item>Steps are ordered by <c>OccurredAtUtc</c>, then a kind rank (inbound before route call
|
||||
/// before script run before writes/waits), then <c>EventId</c>.</item>
|
||||
/// <item>Within one <c>ExecutionId</c> consecutive steps are chained in that order.</item>
|
||||
/// <item>The first step of an execution is linked from its spawner: a <c>RouteCall</c> whose
|
||||
/// <c>childExecutionId</c> matches, else the latest step of the parent execution that occurred
|
||||
/// no later than it. The edge <c>CrossesBoundary</c> when exactly one end is in the Central lane.</item>
|
||||
/// <item>An <c>AttributeWait</c> that was satisfied gets an <c>External</c> (dashed) edge from the
|
||||
/// preceding <c>AttributeWrite</c> in the same execution — the device echo the system inferred.</item>
|
||||
/// <item>When an <c>InboundRequest</c> row carries a response summary, a <c>Response</c> step is
|
||||
/// synthesized as the final Central node, linked from the last real step.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class FlowGraphBuilder
|
||||
{
|
||||
/// <summary>Builds the graph. <paramref name="truncated"/> is passed through to <see cref="FlowGraph.Truncated"/>.</summary>
|
||||
public static FlowGraph Build(IReadOnlyList<AuditEvent> events, bool truncated = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
if (events.Count == 0)
|
||||
return new FlowGraph(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), Predicted: false, Truncated: truncated);
|
||||
|
||||
var steps = events
|
||||
.Select(FlowStepProjector.Project)
|
||||
.OrderBy(s => s.OccurredAtUtc)
|
||||
.ThenBy(s => KindRank(s.Kind))
|
||||
.ThenBy(s => s.EventId)
|
||||
.ToList();
|
||||
|
||||
var edges = new List<FlowEdge>();
|
||||
var byExecution = steps
|
||||
.Where(s => s.ExecutionId is not null)
|
||||
.GroupBy(s => s.ExecutionId!.Value)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// 1. Chain consecutive steps inside each execution.
|
||||
foreach (var chain in byExecution.Values)
|
||||
{
|
||||
for (var i = 1; i < chain.Count; i++)
|
||||
edges.Add(new FlowEdge(chain[i - 1].EventId, chain[i].EventId, CrossesBoundary(chain[i - 1], chain[i]), External: false));
|
||||
}
|
||||
|
||||
// 2. Link each child execution's first step from its spawner.
|
||||
var routeCallsByChild = steps
|
||||
.Where(s => s.Kind == FlowStepKind.RouteCall && s.Extra?.ChildExecutionId is not null)
|
||||
.GroupBy(s => s.Extra!.ChildExecutionId!.Value)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
foreach (var (executionId, chain) in byExecution)
|
||||
{
|
||||
var first = chain[0];
|
||||
if (first.ParentExecutionId is not { } parentId) continue;
|
||||
|
||||
FlowStep? spawner = null;
|
||||
if (routeCallsByChild.TryGetValue(executionId, out var routeCall))
|
||||
spawner = routeCall;
|
||||
else if (byExecution.TryGetValue(parentId, out var parentChain))
|
||||
spawner = parentChain.LastOrDefault(p => p.OccurredAtUtc <= first.OccurredAtUtc && p.Kind != FlowStepKind.Response)
|
||||
?? parentChain[0];
|
||||
|
||||
if (spawner is not null && spawner.EventId != first.EventId)
|
||||
edges.Add(new FlowEdge(spawner.EventId, first.EventId, CrossesBoundary(spawner, first), External: false));
|
||||
}
|
||||
|
||||
// 3. Satisfied wait ⇐ preceding write in the same execution (inferred device echo).
|
||||
foreach (var chain in byExecution.Values)
|
||||
{
|
||||
for (var i = 0; i < chain.Count; i++)
|
||||
{
|
||||
var wait = chain[i];
|
||||
if (wait.Kind != FlowStepKind.AttributeWait) continue;
|
||||
if (!string.Equals(wait.Extra?.WaitOutcome, "satisfied", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
for (var j = i - 1; j >= 0; j--)
|
||||
{
|
||||
if (chain[j].Kind != FlowStepKind.AttributeWrite) continue;
|
||||
edges.Add(new FlowEdge(chain[j].EventId, wait.EventId, CrossesBoundary: false, External: true));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Synthesize the Response node from the inbound row's response summary.
|
||||
var inbound = steps.FirstOrDefault(s => s.Kind == FlowStepKind.InboundRequest);
|
||||
if (inbound is not null && inbound.OutputJson is not null)
|
||||
{
|
||||
var last = steps[^1];
|
||||
var response = new FlowStep(
|
||||
EventId: DeriveResponseId(inbound.EventId),
|
||||
ExecutionId: inbound.ExecutionId,
|
||||
ParentExecutionId: null,
|
||||
Kind: FlowStepKind.Response,
|
||||
Lane: FlowLane.Central,
|
||||
Title: "Response",
|
||||
Subtitle: inbound.Outcome == FlowStepOutcome.Ok ? "200" : "error",
|
||||
OccurredAtUtc: inbound.DurationMs is { } ms ? inbound.OccurredAtUtc.AddMilliseconds(ms) : last.OccurredAtUtc,
|
||||
DurationMs: null,
|
||||
Outcome: inbound.Outcome,
|
||||
InputJson: null,
|
||||
OutputJson: inbound.OutputJson,
|
||||
ErrorMessage: inbound.ErrorMessage,
|
||||
SiteId: null,
|
||||
Node: inbound.Node,
|
||||
Instance: null,
|
||||
Target: inbound.Target,
|
||||
Extra: null);
|
||||
steps.Add(response);
|
||||
edges.Add(new FlowEdge(last.EventId, response.EventId, CrossesBoundary(last, response), External: false));
|
||||
}
|
||||
|
||||
return new FlowGraph(steps, edges, Predicted: false, Truncated: truncated);
|
||||
}
|
||||
|
||||
/// <summary>Display rank used to break timestamp ties: spawners before what they spawn.</summary>
|
||||
public static int KindRank(FlowStepKind kind) => kind switch
|
||||
{
|
||||
FlowStepKind.InboundRequest => 0,
|
||||
FlowStepKind.RouteCall => 1,
|
||||
FlowStepKind.ScriptRun => 2,
|
||||
FlowStepKind.AttributeWrite => 3,
|
||||
FlowStepKind.AttributeWait => 4,
|
||||
FlowStepKind.External => 5,
|
||||
FlowStepKind.Unknown => 6,
|
||||
FlowStepKind.Response => 9,
|
||||
_ => 7,
|
||||
};
|
||||
|
||||
private static bool CrossesBoundary(FlowStep a, FlowStep b)
|
||||
=> (a.Lane == FlowLane.Central) != (b.Lane == FlowLane.Central);
|
||||
|
||||
/// <summary>A stable id for the synthesized Response node so re-builds of the same events select the same node.</summary>
|
||||
private static Guid DeriveResponseId(Guid inboundEventId)
|
||||
{
|
||||
var bytes = inboundEventId.ToByteArray();
|
||||
bytes[15] ^= 0x5A;
|
||||
bytes[0] ^= 0xA5;
|
||||
return new Guid(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
|
||||
/// <summary>Swimlane a flow step is drawn in (Flow Debugger design 2026-09-05 §4/§7.2).</summary>
|
||||
public enum FlowLane
|
||||
{
|
||||
/// <summary>Central node: inbound request, method script, route call, response.</summary>
|
||||
Central,
|
||||
/// <summary>Site node: script runs and attribute waits.</summary>
|
||||
Site,
|
||||
/// <summary>Attribute / tag layer: attribute writes and their device echoes.</summary>
|
||||
Tags,
|
||||
}
|
||||
|
||||
/// <summary>What a flow step represents — the projection of an audit channel/kind pair.</summary>
|
||||
public enum FlowStepKind
|
||||
{
|
||||
/// <summary><c>ApiInbound.InboundRequest</c>.</summary>
|
||||
InboundRequest,
|
||||
/// <summary><c>ScriptTrace.RouteCall</c>.</summary>
|
||||
RouteCall,
|
||||
/// <summary><c>ScriptTrace.ScriptRun</c>.</summary>
|
||||
ScriptRun,
|
||||
/// <summary><c>ScriptTrace.AttributeWrite</c>.</summary>
|
||||
AttributeWrite,
|
||||
/// <summary><c>ScriptTrace.AttributeWait</c>.</summary>
|
||||
AttributeWait,
|
||||
/// <summary>Synthesized final node — the inbound response (from the inbound row's response summary).</summary>
|
||||
Response,
|
||||
/// <summary>An outbound API / DB / notification row emitted by a traced run (shown, not chained across the boundary).</summary>
|
||||
External,
|
||||
/// <summary>A channel/kind this build does not recognise — rendered, never dropped.</summary>
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// <summary>Per-step outcome.</summary>
|
||||
public enum FlowStepOutcome
|
||||
{
|
||||
/// <summary>Completed successfully.</summary>
|
||||
Ok,
|
||||
/// <summary>Failed (the step's error message says why).</summary>
|
||||
Failed,
|
||||
/// <summary>A script run or wait that hit its deadline.</summary>
|
||||
TimedOut,
|
||||
/// <summary>Predicted only — no real event yet.</summary>
|
||||
Predicted,
|
||||
/// <summary>No trace event exists for this position (e.g. site on an older build).</summary>
|
||||
NotTraced,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One node of the flow graph — a central-side projection of a <c>ScriptTrace</c> /
|
||||
/// <c>ApiInbound.InboundRequest</c> <see cref="ZB.MOM.WW.Audit.AuditEvent"/>. Never serialized
|
||||
/// to the wire; the wire carries the audit events themselves (design §5.4).
|
||||
/// </summary>
|
||||
public sealed record FlowStep(
|
||||
Guid EventId,
|
||||
Guid? ExecutionId,
|
||||
Guid? ParentExecutionId,
|
||||
FlowStepKind Kind,
|
||||
FlowLane Lane,
|
||||
string Title,
|
||||
string? Subtitle,
|
||||
DateTimeOffset OccurredAtUtc,
|
||||
int? DurationMs,
|
||||
FlowStepOutcome Outcome,
|
||||
string? InputJson,
|
||||
string? OutputJson,
|
||||
string? ErrorMessage,
|
||||
string? SiteId,
|
||||
string? Node,
|
||||
string? Instance,
|
||||
string? Target,
|
||||
ScriptTraceExtra? Extra);
|
||||
@@ -0,0 +1,110 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
|
||||
/// <summary>
|
||||
/// Projects one canonical <see cref="AuditEvent"/> onto a <see cref="FlowStep"/>
|
||||
/// (Flow Debugger design 2026-09-05 §5.4: trace steps ARE audit events; this is the read side).
|
||||
/// Pure and total — an unrecognised channel/kind projects to <see cref="FlowStepKind.Unknown"/>,
|
||||
/// never throws.
|
||||
/// </summary>
|
||||
public static class FlowStepProjector
|
||||
{
|
||||
/// <summary>Projects an audit event onto a flow step.</summary>
|
||||
public static FlowStep Project(AuditEvent evt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
||||
var extra = ScriptTraceExtra.TryParse(d.Extra);
|
||||
var kind = ResolveKind(d.Channel, d.Kind);
|
||||
var lane = ResolveLane(kind, d.SourceSiteId);
|
||||
var outcome = ResolveOutcome(kind, d.Status, d.ErrorMessage);
|
||||
var title = string.IsNullOrWhiteSpace(evt.Target) ? kind.ToString() : evt.Target!;
|
||||
|
||||
return new FlowStep(
|
||||
EventId: evt.EventId,
|
||||
ExecutionId: d.ExecutionId,
|
||||
ParentExecutionId: d.ParentExecutionId,
|
||||
Kind: kind,
|
||||
Lane: lane,
|
||||
Title: title,
|
||||
Subtitle: BuildSubtitle(kind, evt, d, extra),
|
||||
OccurredAtUtc: evt.OccurredAtUtc,
|
||||
DurationMs: d.DurationMs,
|
||||
Outcome: outcome,
|
||||
InputJson: d.RequestSummary,
|
||||
OutputJson: d.ResponseSummary,
|
||||
ErrorMessage: d.ErrorMessage,
|
||||
SiteId: d.SourceSiteId,
|
||||
Node: evt.SourceNode ?? extra?.Node,
|
||||
Instance: d.SourceInstanceId,
|
||||
Target: evt.Target,
|
||||
Extra: extra);
|
||||
}
|
||||
|
||||
/// <summary>Maps a channel/kind string pair onto a step kind.</summary>
|
||||
public static FlowStepKind ResolveKind(string? channel, string? kind)
|
||||
{
|
||||
if (string.Equals(channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal))
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
nameof(AuditKind.RouteCall) => FlowStepKind.RouteCall,
|
||||
nameof(AuditKind.ScriptRun) => FlowStepKind.ScriptRun,
|
||||
nameof(AuditKind.AttributeWrite) => FlowStepKind.AttributeWrite,
|
||||
nameof(AuditKind.AttributeWait) => FlowStepKind.AttributeWait,
|
||||
_ => FlowStepKind.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(channel, nameof(AuditChannel.ApiInbound), StringComparison.Ordinal)
|
||||
&& string.Equals(kind, nameof(AuditKind.InboundRequest), StringComparison.Ordinal))
|
||||
return FlowStepKind.InboundRequest;
|
||||
|
||||
if (string.Equals(channel, nameof(AuditChannel.ApiOutbound), StringComparison.Ordinal)
|
||||
|| string.Equals(channel, nameof(AuditChannel.DbOutbound), StringComparison.Ordinal)
|
||||
|| string.Equals(channel, nameof(AuditChannel.Notification), StringComparison.Ordinal))
|
||||
return FlowStepKind.External;
|
||||
|
||||
return FlowStepKind.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags.</summary>
|
||||
public static FlowLane ResolveLane(FlowStepKind kind, string? sourceSiteId) => kind switch
|
||||
{
|
||||
FlowStepKind.InboundRequest or FlowStepKind.RouteCall or FlowStepKind.Response => FlowLane.Central,
|
||||
FlowStepKind.ScriptRun or FlowStepKind.AttributeWait => FlowLane.Site,
|
||||
FlowStepKind.AttributeWrite => FlowLane.Tags,
|
||||
_ => string.IsNullOrEmpty(sourceSiteId) ? FlowLane.Central : FlowLane.Site,
|
||||
};
|
||||
|
||||
private static FlowStepOutcome ResolveOutcome(FlowStepKind kind, string? status, string? error)
|
||||
{
|
||||
if (!string.Equals(status, nameof(AuditStatus.Failed), StringComparison.Ordinal))
|
||||
return FlowStepOutcome.Ok;
|
||||
if ((kind == FlowStepKind.ScriptRun || kind == FlowStepKind.AttributeWait)
|
||||
&& error is not null && error.Contains("timed out", StringComparison.OrdinalIgnoreCase))
|
||||
return FlowStepOutcome.TimedOut;
|
||||
return FlowStepOutcome.Failed;
|
||||
}
|
||||
|
||||
private static string? BuildSubtitle(FlowStepKind kind, AuditEvent evt, AuditDetails d, ScriptTraceExtra? extra) => kind switch
|
||||
{
|
||||
FlowStepKind.InboundRequest => string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor,
|
||||
FlowStepKind.RouteCall => extra?.ChildExecutionId is { } child ? $"→ {child.ToString("D")[..8]}" : null,
|
||||
FlowStepKind.ScriptRun => d.SourceInstanceId is null
|
||||
? extra?.TriggerKind
|
||||
: extra?.TriggerKind is null ? d.SourceInstanceId : $"{d.SourceInstanceId} · {extra.TriggerKind}",
|
||||
FlowStepKind.AttributeWrite => extra?.Writes is { Count: > 0 } w
|
||||
? string.Join(", ", w.Select(x => x.Written is null ? x.Attribute : $"{x.Attribute} = {x.Written}"))
|
||||
: null,
|
||||
FlowStepKind.AttributeWait => extra is null
|
||||
? null
|
||||
: extra.WaitOutcome is null
|
||||
? extra.ObservedValue
|
||||
: extra.ObservedValue is null ? extra.WaitOutcome : $"{extra.WaitOutcome} · {extra.ObservedValue}",
|
||||
_ => d.Kind,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
|
||||
/// <summary>
|
||||
/// Kind-specific detail carried in <c>AuditDetails.Extra</c> as camelCase JSON on
|
||||
/// <c>ScriptTrace</c> rows (and on the Flow Debugger's synthesized inbound row). Every member is
|
||||
/// optional; unknown members are ignored on read so older/newer emitters interoperate.
|
||||
/// </summary>
|
||||
public sealed record ScriptTraceExtra
|
||||
{
|
||||
/// <summary>Emitting node (<c>node-a</c> / <c>central-a</c>) when not already on the event.</summary>
|
||||
public string? Node { get; init; }
|
||||
|
||||
/// <summary>Trigger kind of a <c>ScriptRun</c>: <c>call</c> | <c>valuechange</c> | …</summary>
|
||||
public string? TriggerKind { get; init; }
|
||||
|
||||
/// <summary>Attribute whose change fired a value-change run.</summary>
|
||||
public string? TriggerAttribute { get; init; }
|
||||
|
||||
/// <summary>Encoded value that fired a value-change run.</summary>
|
||||
public string? TriggerValue { get; init; }
|
||||
|
||||
/// <summary>Where the triggering change came from: <c>value-change</c> (the actor cannot tell a static write from a device echo).</summary>
|
||||
public string? TriggerSource { get; init; }
|
||||
|
||||
/// <summary>On a <c>RouteCall</c> row: the routed site run's <c>ExecutionId</c>.</summary>
|
||||
public Guid? ChildExecutionId { get; init; }
|
||||
|
||||
/// <summary>On an <c>AttributeWrite</c> row: each write's outcome.</summary>
|
||||
public IReadOnlyList<AttributeWriteDetail>? Writes { get; init; }
|
||||
|
||||
/// <summary>On an <c>AttributeWait</c> row: the awaited attribute.</summary>
|
||||
public string? WaitAttribute { get; init; }
|
||||
|
||||
/// <summary>On an <c>AttributeWait</c> row: the target value / predicate description.</summary>
|
||||
public string? WaitTarget { get; init; }
|
||||
|
||||
/// <summary>On an <c>AttributeWait</c> row: the timeout that applied.</summary>
|
||||
public int? WaitTimeoutMs { get; init; }
|
||||
|
||||
/// <summary>On an <c>AttributeWait</c> row: <c>satisfied</c> | <c>timeout</c> | <c>cancelled</c> | <c>error</c>.</summary>
|
||||
public string? WaitOutcome { get; init; }
|
||||
|
||||
/// <summary>On an <c>AttributeWait</c> row: the encoded value observed when the wait resolved.</summary>
|
||||
public string? ObservedValue { get; init; }
|
||||
|
||||
/// <summary><c>flow-debugger</c> on rows the debugger synthesizes (the Live-run inbound row, the value-change root).</summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>Serializer options shared by every emitter and reader: camelCase, nulls omitted.</summary>
|
||||
public static JsonSerializerOptions JsonOptions { get; } = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>Serializes to the compact camelCase JSON stored in <c>AuditDetails.Extra</c>.</summary>
|
||||
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||
|
||||
/// <summary>Parses <c>AuditDetails.Extra</c>; returns <c>null</c> for null/blank/invalid JSON — never throws.</summary>
|
||||
public static ScriptTraceExtra? TryParse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ScriptTraceExtra>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One attribute write inside an <c>AttributeWrite</c> trace row.</summary>
|
||||
/// <param name="Attribute">Canonical attribute name.</param>
|
||||
/// <param name="Written">Encoded value written.</param>
|
||||
/// <param name="Success">Whether the instance actor accepted the write.</param>
|
||||
/// <param name="Error">Error message when it did not.</param>
|
||||
public sealed record AttributeWriteDetail(string Attribute, string? Written, bool Success, string? Error);
|
||||
@@ -0,0 +1,182 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
|
||||
|
||||
/// <summary>
|
||||
/// Flow Debugger design 2026-09-05 §7.2: lane/edge/boundary rules over a Delmia-shaped
|
||||
/// event list, truncation pass-through, and total-ness on unknown kinds.
|
||||
/// </summary>
|
||||
public class FlowGraphBuilderTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly Guid Root = Guid.NewGuid();
|
||||
private static readonly Guid SiteRun = Guid.NewGuid();
|
||||
private static readonly Guid Cascade = Guid.NewGuid();
|
||||
|
||||
/// <summary>Inbound → RouteCall → site ScriptRun → write → wait → write, plus a value-change ScriptRun spawned by the second write.</summary>
|
||||
private static List<AuditEvent> DelmiaShaped()
|
||||
{
|
||||
var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0, actor: "delmia-key", target: "DelmiaRecipeDownload", sourceNode: "central-a",
|
||||
executionId: Root, durationMs: 900, requestSummary: "{\"MachineCode\":\"M1\"}", responseSummary: "{\"ok\":true}");
|
||||
var route = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(10), actor: "inbound-api", target: "M1.ProcessRecipeDownload", sourceNode: "central-a",
|
||||
executionId: Root, durationMs: 850, extra: new ScriptTraceExtra { ChildExecutionId = SiteRun }.ToJson());
|
||||
var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(20), actor: "ProcessRecipeDownload", target: "ProcessRecipeDownload", sourceNode: "node-a",
|
||||
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 800,
|
||||
extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson());
|
||||
var write1 = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(30), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.RecipePath", sourceNode: "node-a",
|
||||
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 5,
|
||||
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("DelmiaReceiver.RecipePath", "C:\\r.nc", true, null)] }.ToJson());
|
||||
var wait = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWait, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(40), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.Processed", sourceNode: "node-a",
|
||||
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 700,
|
||||
extra: new ScriptTraceExtra { WaitAttribute = "DelmiaReceiver.Processed", WaitOutcome = "satisfied", ObservedValue = "true" }.ToJson());
|
||||
var write2 = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(750), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.Trigger", sourceNode: "node-a",
|
||||
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 3,
|
||||
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("DelmiaReceiver.Trigger", "false", true, null)] }.ToJson());
|
||||
var cascade = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(760), actor: "OnTriggerReset", target: "OnTriggerReset", sourceNode: "node-a",
|
||||
executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 10,
|
||||
extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "DelmiaReceiver.Trigger", TriggerValue = "false" }.ToJson());
|
||||
|
||||
// Deliberately shuffled: the builder must order by time.
|
||||
return [wait, cascade, inbound, write2, run, route, write1];
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DelmiaShape_ProjectsLanes_Edges_AndSynthesizedResponse()
|
||||
{
|
||||
var graph = FlowGraphBuilder.Build(DelmiaShaped());
|
||||
|
||||
Assert.False(graph.Predicted);
|
||||
Assert.False(graph.Truncated);
|
||||
Assert.Equal(8, graph.Steps.Count); // 7 events + synthesized Response
|
||||
|
||||
var kinds = graph.Steps.Select(s => s.Kind).ToArray();
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun, FlowStepKind.AttributeWrite,
|
||||
FlowStepKind.AttributeWait, FlowStepKind.AttributeWrite, FlowStepKind.ScriptRun, FlowStepKind.Response,
|
||||
},
|
||||
kinds);
|
||||
|
||||
Assert.Equal(FlowLane.Central, graph.Steps[0].Lane);
|
||||
Assert.Equal(FlowLane.Central, graph.Steps[1].Lane);
|
||||
Assert.Equal(FlowLane.Site, graph.Steps[2].Lane);
|
||||
Assert.Equal(FlowLane.Tags, graph.Steps[3].Lane);
|
||||
Assert.Equal(FlowLane.Site, graph.Steps[4].Lane);
|
||||
Assert.Equal(FlowLane.Tags, graph.Steps[5].Lane);
|
||||
Assert.Equal(FlowLane.Site, graph.Steps[6].Lane);
|
||||
Assert.Equal(FlowLane.Central, graph.Steps[7].Lane);
|
||||
|
||||
// Exactly two boundary crossings: route → site run, and last site step → response.
|
||||
var crossing = graph.Edges.Where(e => e.CrossesBoundary).ToList();
|
||||
Assert.Equal(2, crossing.Count);
|
||||
Assert.Contains(crossing, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[2].EventId);
|
||||
Assert.Contains(crossing, e => e.FromEventId == graph.Steps[6].EventId && e.ToEventId == graph.Steps[7].EventId);
|
||||
|
||||
// One inferred echo edge: first write → satisfied wait.
|
||||
var external = Assert.Single(graph.Edges, e => e.External);
|
||||
Assert.Equal(graph.Steps[3].EventId, external.FromEventId);
|
||||
Assert.Equal(graph.Steps[4].EventId, external.ToEventId);
|
||||
|
||||
// The cascade run hangs off the write that spawned it (same site, no boundary).
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[5].EventId && e.ToEventId == graph.Steps[6].EventId && !e.CrossesBoundary);
|
||||
|
||||
// Inbound → route call chained inside the root execution.
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[0].EventId && e.ToEventId == graph.Steps[1].EventId);
|
||||
|
||||
// Projection detail.
|
||||
Assert.Equal("→ " + SiteRun.ToString("D")[..8], graph.Steps[1].Subtitle);
|
||||
Assert.Equal("M1 · call", graph.Steps[2].Subtitle);
|
||||
Assert.Equal("DelmiaReceiver.RecipePath = C:\\r.nc", graph.Steps[3].Subtitle);
|
||||
Assert.Equal("satisfied · true", graph.Steps[4].Subtitle);
|
||||
Assert.Equal("{\"ok\":true}", graph.Steps[7].OutputJson);
|
||||
Assert.All(graph.Steps, s => Assert.Equal(FlowStepOutcome.Ok, s.Outcome));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsDeterministic_ResponseIdIsStable()
|
||||
{
|
||||
var events = DelmiaShaped();
|
||||
var a = FlowGraphBuilder.Build(events);
|
||||
var b = FlowGraphBuilder.Build(events);
|
||||
Assert.Equal(a.Steps.Select(s => s.EventId), b.Steps.Select(s => s.EventId));
|
||||
Assert.Equal(a.Edges, b.Edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncated_PassesThrough()
|
||||
{
|
||||
Assert.True(FlowGraphBuilder.Build(DelmiaShaped(), truncated: true).Truncated);
|
||||
Assert.True(FlowGraphBuilder.Build(Array.Empty<AuditEvent>(), truncated: true).Truncated);
|
||||
Assert.Empty(FlowGraphBuilder.Build(Array.Empty<AuditEvent>()).Steps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownKind_ProjectsToUnknown_NeverThrows()
|
||||
{
|
||||
var weird = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = T0,
|
||||
Actor = "x",
|
||||
Action = "Future.Thing",
|
||||
Outcome = AuditOutcome.Success,
|
||||
DetailsJson = "{\"channel\":\"Future\",\"kind\":\"Thing\",\"extra\":\"not json at all\"}",
|
||||
};
|
||||
var garbage = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = T0,
|
||||
Actor = "x",
|
||||
Action = "?",
|
||||
Outcome = AuditOutcome.Failure,
|
||||
DetailsJson = "{{{",
|
||||
};
|
||||
|
||||
var graph = FlowGraphBuilder.Build([weird, garbage]);
|
||||
|
||||
Assert.Equal(2, graph.Steps.Count);
|
||||
Assert.All(graph.Steps, s => Assert.Equal(FlowStepKind.Unknown, s.Kind));
|
||||
Assert.All(graph.Steps, s => Assert.Null(s.Extra));
|
||||
Assert.Empty(graph.Edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedScriptRun_WithTimeoutMessage_IsTimedOut()
|
||||
{
|
||||
var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Failed,
|
||||
occurredAtUtc: T0, target: "S", executionId: SiteRun, errorMessage: "Script timed out after 30s");
|
||||
var write = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Failed,
|
||||
occurredAtUtc: T0.AddMilliseconds(1), target: "A", executionId: SiteRun, errorMessage: "rejected");
|
||||
|
||||
var graph = FlowGraphBuilder.Build([run, write]);
|
||||
|
||||
Assert.Equal(FlowStepOutcome.TimedOut, graph.Steps[0].Outcome);
|
||||
Assert.Equal(FlowStepOutcome.Failed, graph.Steps[1].Outcome);
|
||||
Assert.Single(graph.Edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptTraceExtra_RoundTrips_CamelCase_AndIgnoresUnknownMembers()
|
||||
{
|
||||
var extra = new ScriptTraceExtra { ChildExecutionId = SiteRun, WaitTimeoutMs = 1500, Source = "flow-debugger" };
|
||||
var json = extra.ToJson();
|
||||
Assert.Contains("\"childExecutionId\"", json);
|
||||
Assert.DoesNotContain("triggerKind", json);
|
||||
|
||||
var back = ScriptTraceExtra.TryParse(json.Insert(1, "\"futureField\":1,"));
|
||||
Assert.Equal(extra, back);
|
||||
Assert.Null(ScriptTraceExtra.TryParse(null));
|
||||
Assert.Null(ScriptTraceExtra.TryParse("nope"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user