feat(wire): route_to_call trace flag + child_execution_id + trace_events; route_to_set_attributes trace (additive proto + mapper)
Flow Debugger (design 2026-09-05 §5.2/§5.4): site_command.proto gains trace=7 on RouteToCallRequestDto, child_execution_id=6 + repeated sitestream.AuditEventDto trace_events=7 on RouteToCallResponseDto, trace=6 on RouteToSetAttributesRequestDto (imports Protos/sitestream.proto the way central_control.proto does). Mapper round-trips them; an empty trace_events list maps to null so an untraced/older site reads as 'not traced'. Vendored C# regenerated via docker/regen-proto.sh (csproj untouched); golden samples extended. Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
@@ -2420,7 +2420,8 @@ public static class SiteCommandDtoMapper
|
||||
ScriptName = request.ScriptName,
|
||||
Parameters = LooseValueCodec.ToProtoMapOrNull(request.Parameters),
|
||||
Timestamp = Ts(request.Timestamp),
|
||||
ParentExecutionId = GuidToWire(request.ParentExecutionId)
|
||||
ParentExecutionId = GuidToWire(request.ParentExecutionId),
|
||||
Trace = request.Trace
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2437,7 +2438,8 @@ public static class SiteCommandDtoMapper
|
||||
dto.ScriptName,
|
||||
LooseValueCodec.FromProtoMapOrNull(dto.Parameters),
|
||||
Dto(dto.Timestamp),
|
||||
GuidFromWire(dto.ParentExecutionId));
|
||||
GuidFromWire(dto.ParentExecutionId),
|
||||
dto.Trace);
|
||||
}
|
||||
|
||||
/// <summary>Projects a <see cref="RouteToGetAttributesRequest"/> onto the wire.</summary>
|
||||
@@ -2485,7 +2487,8 @@ public static class SiteCommandDtoMapper
|
||||
CorrelationId = request.CorrelationId,
|
||||
InstanceUniqueName = request.InstanceUniqueName,
|
||||
Timestamp = Ts(request.Timestamp),
|
||||
ParentExecutionId = GuidToWire(request.ParentExecutionId)
|
||||
ParentExecutionId = GuidToWire(request.ParentExecutionId),
|
||||
Trace = request.Trace
|
||||
};
|
||||
|
||||
foreach (var (key, value) in request.AttributeValues)
|
||||
@@ -2508,7 +2511,8 @@ public static class SiteCommandDtoMapper
|
||||
dto.InstanceUniqueName,
|
||||
dto.AttributeValues.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
Dto(dto.Timestamp),
|
||||
GuidFromWire(dto.ParentExecutionId));
|
||||
GuidFromWire(dto.ParentExecutionId),
|
||||
dto.Trace);
|
||||
}
|
||||
|
||||
/// <summary>Projects a <see cref="RouteToWaitForAttributeRequest"/> onto the wire.</summary>
|
||||
@@ -2564,14 +2568,24 @@ public static class SiteCommandDtoMapper
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
|
||||
return new RouteToCallResponseDto
|
||||
var dto = new RouteToCallResponseDto
|
||||
{
|
||||
CorrelationId = response.CorrelationId,
|
||||
Success = response.Success,
|
||||
ReturnValue = LooseValueCodec.ToProtoOrNull(response.ReturnValue),
|
||||
ErrorMessage = OrEmpty(response.ErrorMessage),
|
||||
Timestamp = Ts(response.Timestamp)
|
||||
Timestamp = Ts(response.Timestamp),
|
||||
// Flow Debugger (design 2026-09-05 §5.4): additive fields 6/7.
|
||||
ChildExecutionId = GuidToWire(response.ChildExecutionId)
|
||||
};
|
||||
if (response.TraceEvents is { Count: > 0 } traceEvents)
|
||||
{
|
||||
foreach (var evt in traceEvents)
|
||||
{
|
||||
dto.TraceEvents.Add(AuditEventDtoMapper.ToDto(evt));
|
||||
}
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// <summary>Rehydrates a <see cref="RouteToCallResponse"/> from the wire.</summary>
|
||||
@@ -2586,7 +2600,11 @@ public static class SiteCommandDtoMapper
|
||||
dto.Success,
|
||||
LooseValueCodec.FromProto(dto.ReturnValue),
|
||||
NullIfEmpty(dto.ErrorMessage),
|
||||
Dto(dto.Timestamp));
|
||||
Dto(dto.Timestamp),
|
||||
GuidFromWire(dto.ChildExecutionId),
|
||||
// An untraced run — or an older site that never sets the field — sends no
|
||||
// events, which maps to null so the UI can label the lane "site not traced".
|
||||
dto.TraceEvents.Count == 0 ? null : dto.TraceEvents.Select(AuditEventDtoMapper.FromDto).ToList());
|
||||
}
|
||||
|
||||
/// <summary>Projects a <see cref="RouteToGetAttributesResponse"/> onto the wire.</summary>
|
||||
|
||||
@@ -5,6 +5,9 @@ package scadabridge.sitecommand.v1;
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/duration.proto";
|
||||
import "google/protobuf/wrappers.proto";
|
||||
// Flow Debugger (design 2026-09-05 §5.4): RouteToCallResponseDto carries the site's
|
||||
// ScriptTrace rows inline as the AuditEventDto sitestream.proto already defines.
|
||||
import "Protos/sitestream.proto";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Site command plane (central → site) — the gRPC replacement for the per-site
|
||||
@@ -765,6 +768,9 @@ message RouteToCallRequestDto {
|
||||
LooseValueMap parameters = 4; // absent => null (distinct from empty)
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
string parent_execution_id = 6; // empty string represents null
|
||||
// Flow Debugger trace flag (design 2026-09-05 §5.2). proto3 default false = untraced,
|
||||
// which is what an older central sends and what an older site reads.
|
||||
bool trace = 7;
|
||||
}
|
||||
|
||||
message RouteToCallResponseDto {
|
||||
@@ -773,6 +779,12 @@ message RouteToCallResponseDto {
|
||||
LooseValue return_value = 3; // absent => null
|
||||
string error_message = 4; // empty string represents null
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
// Flow Debugger (design §5.4): the routed site run's ExecutionId ("D" GUID form;
|
||||
// empty represents null — an older site never sets it).
|
||||
string child_execution_id = 6;
|
||||
// Flow Debugger (design §5.4): the site's ScriptTrace audit events for a traced run,
|
||||
// in emission order. Empty from an untraced run or an older site (mapper => null).
|
||||
repeated sitestream.AuditEventDto trace_events = 7;
|
||||
}
|
||||
|
||||
message RouteToGetAttributesRequestDto {
|
||||
@@ -797,6 +809,8 @@ message RouteToSetAttributesRequestDto {
|
||||
map<string, string> attribute_values = 3;
|
||||
google.protobuf.Timestamp timestamp = 4;
|
||||
string parent_execution_id = 5; // empty string represents null
|
||||
// Flow Debugger trace flag (design 2026-09-05 §5.2/§5.3): arms the echo stamp.
|
||||
bool trace = 6;
|
||||
}
|
||||
|
||||
message RouteToSetAttributesResponseDto {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Flow Debugger (design 2026-09-05 §5.2/§5.4): the site_command.proto trace fields — the
|
||||
/// <c>trace</c> flag on both route requests, and <c>child_execution_id</c> +
|
||||
/// <c>trace_events</c> on the call response — round-trip through
|
||||
/// <see cref="SiteCommandDtoMapper"/>, and an old-shape DTO (no fields set) maps to the
|
||||
/// untraced defaults.
|
||||
/// </summary>
|
||||
public class SiteCommandDtoMapperTraceTests
|
||||
{
|
||||
private static readonly DateTimeOffset T = new(2026, 9, 5, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void RouteToCallResponse_RoundTrips_ChildExecutionId_AndTraceEvents()
|
||||
{
|
||||
var child = Guid.NewGuid();
|
||||
var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: T.UtcDateTime, actor: "ScriptActor:S", target: "S", sourceNode: "node-b",
|
||||
executionId: child, parentExecutionId: Guid.NewGuid(), sourceSiteId: "site-a", durationMs: 40,
|
||||
requestSummary: "{\"x\":1}", responseSummary: "\"ok\"",
|
||||
extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson());
|
||||
var write = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
|
||||
occurredAtUtc: T.UtcDateTime.AddMilliseconds(5), actor: "ScriptActor:S", target: "A", executionId: child,
|
||||
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("A", "1", true, null)] }.ToJson());
|
||||
var original = new RouteToCallResponse("c", true, 7L, null, T, child, [run, write]);
|
||||
|
||||
var restored = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(original));
|
||||
|
||||
Assert.Equal(child, restored.ChildExecutionId);
|
||||
Assert.NotNull(restored.TraceEvents);
|
||||
Assert.Equal(2, restored.TraceEvents!.Count);
|
||||
Assert.Equal(run.Action, restored.TraceEvents[0].Action);
|
||||
Assert.Equal(run.DetailsJson, restored.TraceEvents[0].DetailsJson);
|
||||
Assert.Equal(write.EventId, restored.TraceEvents[1].EventId);
|
||||
Assert.Equal(original, restored with { TraceEvents = original.TraceEvents });
|
||||
Assert.Equal(run, restored.TraceEvents[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldShapeDtos_MapToUntracedDefaults()
|
||||
{
|
||||
// A DTO from a site/central that predates the fields: proto3 defaults everywhere.
|
||||
var call = SiteCommandDtoMapper.FromProto(new RouteToCallRequestDto
|
||||
{
|
||||
CorrelationId = "c", InstanceUniqueName = "i", ScriptName = "s",
|
||||
Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(T),
|
||||
});
|
||||
Assert.False(call.Trace);
|
||||
|
||||
var set = SiteCommandDtoMapper.FromProto(new RouteToSetAttributesRequestDto
|
||||
{
|
||||
CorrelationId = "c", InstanceUniqueName = "i",
|
||||
Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(T),
|
||||
});
|
||||
Assert.False(set.Trace);
|
||||
|
||||
var reply = SiteCommandDtoMapper.FromProto(new RouteToCallResponseDto
|
||||
{
|
||||
CorrelationId = "c", Success = true,
|
||||
Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(T),
|
||||
});
|
||||
Assert.Null(reply.ChildExecutionId);
|
||||
Assert.Null(reply.TraceEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TraceFlag_PropagatesOnBothRequests()
|
||||
{
|
||||
var call = new RouteToCallRequest("c", "i", "s", null, T, Guid.NewGuid(), Trace: true);
|
||||
Assert.True(SiteCommandDtoMapper.ToProto(call).Trace);
|
||||
Assert.True(SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(call)).Trace);
|
||||
|
||||
var set = new RouteToSetAttributesRequest("c", "i", new Dictionary<string, string> { ["a"] = "1" }, T, Guid.NewGuid(), Trace: true);
|
||||
Assert.True(SiteCommandDtoMapper.ToProto(set).Trace);
|
||||
Assert.True(SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(set)).Trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyTraceEventsList_MapsToNull_NotEmpty()
|
||||
{
|
||||
// An untraced run sends none; the CLR side distinguishes "none" (null) so the UI can
|
||||
// label the site lane — an empty list would read as "traced, did nothing".
|
||||
var original = new RouteToCallResponse("c", true, null, null, T, Guid.NewGuid(), Array.Empty<ZB.MOM.WW.Audit.AuditEvent>());
|
||||
var restored = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(original));
|
||||
Assert.Null(restored.TraceEvents);
|
||||
Assert.Equal(original.ChildExecutionId, restored.ChildExecutionId);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
@@ -10,6 +11,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
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.Communication.Tests;
|
||||
@@ -180,13 +183,13 @@ internal static class SiteCommandSamples
|
||||
new ParkedOperationActionAck("corr-10", false, "handler missing"));
|
||||
|
||||
// ── Route commands ──
|
||||
Add(new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", Parameters(), T1, G1),
|
||||
Add(new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", Parameters(), T1, G1, Trace: true),
|
||||
new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", null, T2),
|
||||
new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", new Dictionary<string, object?>(), T1));
|
||||
Add(new RouteToGetAttributesRequest("corr-13", "Site1.Pump1", ["Speed", "Flow"], T1, G1),
|
||||
new RouteToGetAttributesRequest("corr-13", "Site1.Pump1", [], T2));
|
||||
Add(new RouteToSetAttributesRequest(
|
||||
"corr-14", "Site1.Pump1", new Dictionary<string, string> { ["Speed"] = "10" }, T1, G1),
|
||||
"corr-14", "Site1.Pump1", new Dictionary<string, string> { ["Speed"] = "10" }, T1, G1, Trace: true),
|
||||
new RouteToSetAttributesRequest("corr-14", "Site1.Pump1", new Dictionary<string, string>(), T2));
|
||||
Add(new RouteToWaitForAttributeRequest(
|
||||
"corr-15", "Site1.Pump1", "Speed", "10", TimeSpan.FromSeconds(30), T1, G1, true),
|
||||
@@ -197,7 +200,7 @@ internal static class SiteCommandSamples
|
||||
"corr-15", "Site1.Pump1", "Speed", "", TimeSpan.FromMinutes(1), T1));
|
||||
|
||||
// ── Route replies ──
|
||||
Add(new RouteToCallResponse("corr-12", true, 17L, null, T1),
|
||||
Add(new RouteToCallResponse("corr-12", true, 17L, null, T1, G1, TraceSamples()),
|
||||
new RouteToCallResponse("corr-12", false, null, "script faulted", T2));
|
||||
Add(new RouteToGetAttributesResponse("corr-13", Parameters(), true, null, T1),
|
||||
new RouteToGetAttributesResponse("corr-13", new Dictionary<string, object?>(), false, "no instance", T2));
|
||||
@@ -293,6 +296,30 @@ internal static class SiteCommandSamples
|
||||
IsConfiguredPlaceholder = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Flow Debugger (design 2026-09-05 §5.4): two ScriptTrace rows for the maximal
|
||||
/// RouteToCallResponse sample — a ScriptRun and an AttributeWrite with a populated Extra.
|
||||
/// Fixed EventIds/timestamps so the golden is deterministic.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<AuditEvent> TraceSamples() =>
|
||||
[
|
||||
ScadaBridgeAuditEventFactory.Create(
|
||||
AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
eventId: Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"), occurredAtUtc: D1,
|
||||
actor: "ScriptActor:DoWork", target: "DoWork", sourceNode: "node-a",
|
||||
executionId: G1, parentExecutionId: Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002"),
|
||||
sourceSiteId: "site-1", sourceInstanceId: "Site1.Pump1", sourceScript: "ScriptActor:DoWork",
|
||||
durationMs: 12, requestSummary: "{\"count\":3}", responseSummary: "7",
|
||||
extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()),
|
||||
ScadaBridgeAuditEventFactory.Create(
|
||||
AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Failed,
|
||||
eventId: Guid.Parse("aaaaaaaa-0000-0000-0000-000000000003"), occurredAtUtc: D2,
|
||||
actor: "ScriptActor:DoWork", target: "Speed", sourceNode: "node-a",
|
||||
executionId: G1, sourceSiteId: "site-1", sourceInstanceId: "Site1.Pump1",
|
||||
durationMs: 3, errorMessage: "device rejected",
|
||||
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("Speed", "10", false, "device rejected")] }.ToJson()),
|
||||
];
|
||||
|
||||
private static Dictionary<string, object?> Parameters() => new()
|
||||
{
|
||||
["count"] = 3,
|
||||
|
||||
Reference in New Issue
Block a user