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);
|
||||
Reference in New Issue
Block a user