feat(site-runtime): ScriptRuntimeContext emits ScriptTrace AttributeWrite/AttributeWait rows and inherits the Trace flag

Flow Debugger (design 2026-09-05 §5.1/§5.2): the context is the single funnel for
SetAttribute / WriteBatchAndWait / WaitInternal, so it emits the write/wait rows (collector
first, then the best-effort IAuditWriter whose redactor applies the payload cap), stamps
Trace on SetStaticAttributeCommand and ScriptCallRequest, and hands the flag + sink to the
inline CallShared child context. Untraced contexts emit nothing (pinned).

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-05 23:43:32 -04:00
parent 23157d2bcf
commit 93489cd7a2
2 changed files with 393 additions and 9 deletions
@@ -0,0 +1,206 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Flow Debugger design 2026-09-05 §5.1/§5.2: a traced <see cref="ScriptRuntimeContext"/>
/// emits <c>ScriptTrace</c> rows for attribute writes and waits (into the inline collector
/// AND the audit writer), stamps <c>Trace</c> on the commands it sends, and inherits the flag
/// into nested calls. An untraced context is byte-for-byte the pre-feature behaviour.
/// </summary>
public class ScriptRuntimeContextTraceEmissionTests : TestKit
{
private const string InstanceName = "Plant.Pump42";
private sealed class CapturingAuditWriter : IAuditWriter
{
public List<AuditEvent> Events { get; } = new();
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events) Events.Add(evt);
return Task.CompletedTask;
}
}
private static SharedScriptLibrary NewLibrary() =>
new(new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
NullLogger<SharedScriptLibrary>.Instance);
private static ScriptRuntimeContext CreateContext(
IActorRef instanceActor, IAuditWriter? writer, bool traced, FlowTraceCollector? collector,
Guid? executionId = null, Guid? parentExecutionId = null)
=> new(
instanceActor, NewLibrary(),
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5),
instanceName: InstanceName, logger: NullLogger.Instance,
siteId: "site-a", sourceScript: "ScriptActor:A", auditWriter: writer,
executionId: executionId, parentExecutionId: parentExecutionId, sourceNode: "node-a",
isTraced: traced, traceCollector: collector);
[Fact]
public async Task SetAttribute_WhenTraced_EmitsAttributeWriteRow_AndPassesTraceOnCommand()
{
var probe = CreateTestProbe();
var writer = new CapturingAuditWriter();
var collector = new FlowTraceCollector();
var exec = Guid.NewGuid();
var parent = Guid.NewGuid();
var ctx = CreateContext(probe.Ref, writer, traced: true, collector, exec, parent);
var call = ctx.SetAttribute("Setpoint", "42");
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
Assert.True(cmd.Trace);
Assert.Equal(exec, cmd.SourceExecutionId);
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
await call;
var evt = Assert.Single(writer.Events);
var row = evt.AsRow();
Assert.Equal("ScriptTrace", evt.Category);
Assert.Equal("ScriptTrace.AttributeWrite", evt.Action);
Assert.Equal(exec, row.ExecutionId);
Assert.Equal(parent, row.ParentExecutionId);
Assert.Equal("site-a", row.SourceSiteId);
Assert.Equal("node-a", evt.SourceNode);
Assert.Equal("Setpoint", evt.Target);
Assert.Equal(AuditOutcome.Success, evt.Outcome);
var extra = ScriptTraceExtra.TryParse(row.Extra);
var write = Assert.Single(extra!.Writes!);
Assert.Equal(("Setpoint", "42", true), (write.Attribute, write.Written, write.Success));
Assert.Contains("\"value\":\"42\"", row.RequestSummary);
Assert.Same(evt, Assert.Single(collector.Snapshot()));
}
[Fact]
public async Task SetAttribute_WhenNotTraced_EmitsNothing_AndCommandTraceFalse()
{
var probe = CreateTestProbe();
var writer = new CapturingAuditWriter();
var ctx = CreateContext(probe.Ref, writer, traced: false, collector: null);
var call = ctx.SetAttribute("Setpoint", "42");
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
Assert.False(cmd.Trace);
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
await call;
Assert.Empty(writer.Events);
Assert.False(ctx.IsTraced);
}
[Fact]
public async Task SetAttribute_WhenTraced_FailedWrite_EmitsFailedRow_ThenThrows()
{
var probe = CreateTestProbe();
var writer = new CapturingAuditWriter();
var ctx = CreateContext(probe.Ref, writer, traced: true, new FlowTraceCollector());
var call = ctx.SetAttribute("Setpoint", "42");
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", false, "device rejected", DateTimeOffset.UtcNow));
await Assert.ThrowsAsync<InvalidOperationException>(() => call);
var row = Assert.Single(writer.Events).AsRow();
Assert.Equal("Failed", row.Status.ToString());
Assert.Equal("device rejected", row.ErrorMessage);
}
[Fact]
public async Task WaitInternal_WhenTraced_EmitsAttributeWait_WithOutcomeSatisfied()
{
var probe = CreateTestProbe();
var writer = new CapturingAuditWriter();
var collector = new FlowTraceCollector();
var ctx = CreateContext(probe.Ref, writer, traced: true, collector);
var wait = ctx.WaitAttributeFull("Processed", "true", null, TimeSpan.FromSeconds(3));
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(req.CorrelationId, Matched: true, true, "Good", TimedOut: false));
var result = await wait;
Assert.True(result.Matched);
var evt = Assert.Single(writer.Events);
Assert.Equal("ScriptTrace.AttributeWait", evt.Action);
Assert.Equal("Processed", evt.Target);
var extra = ScriptTraceExtra.TryParse(evt.AsRow().Extra)!;
Assert.Equal("satisfied", extra.WaitOutcome);
Assert.Equal("Processed", extra.WaitAttribute);
Assert.Equal("true", extra.WaitTarget);
Assert.Equal(3000, extra.WaitTimeoutMs);
Assert.Equal("true", extra.ObservedValue, ignoreCase: true);
Assert.Single(collector.Snapshot());
}
[Fact]
public async Task WaitInternal_WhenTraced_Timeout_EmitsFailedRow_WithTimeoutOutcome()
{
var probe = CreateTestProbe();
var writer = new CapturingAuditWriter();
var ctx = CreateContext(probe.Ref, writer, traced: true, new FlowTraceCollector());
var wait = ctx.WaitAttribute("Processed", "true", null, TimeSpan.FromSeconds(1));
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(req.CorrelationId, Matched: false, null, null, TimedOut: true));
Assert.False(await wait);
var row = Assert.Single(writer.Events).AsRow();
Assert.Equal("Failed", row.Status.ToString());
Assert.Contains("timed out", row.ErrorMessage);
Assert.Equal("timeout", ScriptTraceExtra.TryParse(row.Extra)!.WaitOutcome);
}
[Fact]
public async Task WaitInternal_WhenNotTraced_EmitsNothing()
{
var probe = CreateTestProbe();
var writer = new CapturingAuditWriter();
var ctx = CreateContext(probe.Ref, writer, traced: false, collector: null);
var wait = ctx.WaitAttribute("Processed", "true", null, TimeSpan.FromSeconds(1));
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(req.CorrelationId, Matched: true, true, "Good", TimedOut: false));
await wait;
Assert.Empty(writer.Events);
}
[Fact]
public async Task CallScript_WhenTraced_PropagatesTraceOnScriptCallRequest()
{
var probe = CreateTestProbe();
var ctx = CreateContext(probe.Ref, null, traced: true, new FlowTraceCollector());
var call = ctx.CallScript("B");
var req = probe.ExpectMsg<ScriptCallRequest>(TimeSpan.FromSeconds(5));
Assert.True(req.Trace);
probe.Reply(new ScriptCallResult(req.CorrelationId, true, null, null));
await call;
// The inline shared-script child context inherits the flag too.
Assert.True(ctx.CreateChildContextForSharedScript(1).IsTraced);
Assert.False(CreateContext(probe.Ref, null, traced: false, null).CreateChildContextForSharedScript(1).IsTraced);
}
[Fact]
public async Task CallScript_WhenNotTraced_RequestTraceIsFalse()
{
var probe = CreateTestProbe();
var ctx = CreateContext(probe.Ref, null, traced: false, null);
var call = ctx.CallScript("B");
var req = probe.ExpectMsg<ScriptCallRequest>(TimeSpan.FromSeconds(5));
Assert.False(req.Trace);
probe.Reply(new ScriptCallResult(req.CorrelationId, true, null, null));
await call;
}
}