feat(site-runtime): traced runs emit ScriptRun rows, return ExecutionId + trace events; traced value-change runs inherit the parent

Flow Debugger (design 2026-09-05 §5.1/§5.4): ScriptRunLauncher emits one ScriptRun row per
traced run on every exit path (OccurredAtUtc = run start), replies with the run's
ExecutionId (always) and the collector snapshot (traced only). ScriptActor forwards
ScriptCallRequest.Trace and, for a value-change trigger fired by a traced stamped change,
parents the run to the writer with trigger detail in Extra; every other trigger stays a root.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-05 23:43:33 -04:00
parent 93489cd7a2
commit eca1434160
3 changed files with 319 additions and 17 deletions
@@ -4,6 +4,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging; using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
@@ -272,7 +274,9 @@ public class ScriptActor : ReceiveActor, IWithTimers
// (tag-change / timer) runs and nested Script.Call invocations. // (tag-change / timer) runs and nested Script.Call invocations.
SpawnExecution( SpawnExecution(
request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId, request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId,
request.ParentExecutionId); request.ParentExecutionId,
// Flow Debugger (design §5.2): the trace flag rides beside the parent id.
request.Trace);
} }
/// <summary> /// <summary>
@@ -289,7 +293,23 @@ public class ScriptActor : ReceiveActor, IWithTimers
{ {
if (valueTrigger.AttributeName == changed.AttributeName) if (valueTrigger.AttributeName == changed.AttributeName)
{ {
TrySpawnExecution(null); // Flow Debugger (design §5.2/§5.3): a change stamped by a traced write parents
// the spawned run to the writing execution and carries the trace flag; every
// other value-change run stays a root by design (CLAUDE.md, alarm-leg note).
if (changed.Trace && changed.SourceExecutionId is { } sourceExecutionId)
{
TrySpawnExecution(null, sourceExecutionId, trace: true, new ScriptTraceExtra
{
TriggerKind = "valuechange",
TriggerAttribute = changed.AttributeName,
TriggerValue = AttributeValueCodec.Encode(changed.Value),
TriggerSource = "value-change",
});
}
else
{
TrySpawnExecution(null);
}
} }
} }
else if (_triggerConfig is ConditionalTriggerConfig conditional) else if (_triggerConfig is ConditionalTriggerConfig conditional)
@@ -496,7 +516,11 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// <summary> /// <summary>
/// Attempts to spawn a script execution, respecting MinTimeBetweenRuns. /// Attempts to spawn a script execution, respecting MinTimeBetweenRuns.
/// </summary> /// </summary>
private void TrySpawnExecution(IReadOnlyDictionary<string, object?>? parameters) private void TrySpawnExecution(
IReadOnlyDictionary<string, object?>? parameters,
Guid? parentExecutionId = null,
bool trace = false,
ScriptTraceExtra? triggerExtra = null)
{ {
if (_compiledScript == null) return; if (_compiledScript == null) return;
@@ -513,7 +537,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
} }
_lastExecutionTime = DateTimeOffset.UtcNow; _lastExecutionTime = DateTimeOffset.UtcNow;
SpawnExecution(parameters, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString()); SpawnExecution(parameters, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString(), parentExecutionId, trace, triggerExtra);
} }
/// <summary> /// <summary>
@@ -544,7 +568,9 @@ public class ScriptActor : ReceiveActor, IWithTimers
int callDepth, int callDepth,
IActorRef replyTo, IActorRef replyTo,
string correlationId, string correlationId,
Guid? parentExecutionId = null) Guid? parentExecutionId = null,
bool trace = false,
ScriptTraceExtra? triggerExtra = null)
{ {
if (callDepth == 0 && _runsInFlight >= _options.MaxConcurrentRunsPerScript) if (callDepth == 0 && _runsInFlight >= _options.MaxConcurrentRunsPerScript)
{ {
@@ -590,7 +616,10 @@ public class ScriptActor : ReceiveActor, IWithTimers
_executionTimeoutSeconds, _executionTimeoutSeconds,
// Scheduler seam (#18): thread this actor's scheduler override down so // Scheduler seam (#18): thread this actor's scheduler override down so
// launched script bodies share the same pool (null = process-wide shared). // launched script bodies share the same pool (null = process-wide shared).
_scheduler); _scheduler,
// Flow Debugger (design §5.2): trace flag + value-change trigger detail.
trace,
triggerExtra);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -7,7 +7,10 @@ using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
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.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
@@ -74,6 +77,8 @@ internal static class ScriptRunLauncher
/// <param name="parentExecutionId">ExecutionId of the spawning execution for audit correlation; null for root runs.</param> /// <param name="parentExecutionId">ExecutionId of the spawning execution for audit correlation; null for root runs.</param>
/// <param name="executionTimeoutSeconds">Per-script execution timeout in seconds. Null or non-positive falls back to the global value.</param> /// <param name="executionTimeoutSeconds">Per-script execution timeout in seconds. Null or non-positive falls back to the global value.</param>
/// <param name="scheduler">Script-execution scheduler seam (#18); null selects the process-wide shared instance.</param> /// <param name="scheduler">Script-execution scheduler seam (#18); null selects the process-wide shared instance.</param>
/// <param name="trace">Flow Debugger trace flag (design 2026-09-05 §5.2): when true the run emits <c>ScriptTrace</c> rows and returns them inline on the <see cref="ScriptCallResult"/>.</param>
/// <param name="triggerExtra">Flow Debugger: trigger detail for a traced value-change run (stamped into the <c>ScriptRun</c> row's <c>Extra</c>); null for calls.</param>
public static void LaunchScript( public static void LaunchScript(
string scriptName, string scriptName,
string instanceName, string instanceName,
@@ -94,7 +99,9 @@ internal static class ScriptRunLauncher
IServiceProvider? serviceProvider, IServiceProvider? serviceProvider,
Guid? parentExecutionId, Guid? parentExecutionId,
int? executionTimeoutSeconds, int? executionTimeoutSeconds,
ScriptExecutionScheduler? scheduler) ScriptExecutionScheduler? scheduler,
bool trace = false,
ScriptTraceExtra? triggerExtra = null)
{ {
var timeout = ResolveTimeout(executionTimeoutSeconds, options); var timeout = ResolveTimeout(executionTimeoutSeconds, options);
var executionScheduler = scheduler ?? ScriptExecutionScheduler.Shared(options); var executionScheduler = scheduler ?? ScriptExecutionScheduler.Shared(options);
@@ -111,7 +118,7 @@ internal static class ScriptRunLauncher
instanceActor, sharedScriptLibrary, options, replyTo, correlationId, instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
completionTarget, siteCommunicationActor, logger, scope, runId, completionTarget, siteCommunicationActor, logger, scope, runId,
healthCollector, serviceProvider, parentExecutionId, timeout, healthCollector, serviceProvider, parentExecutionId, timeout,
executionScheduler, cts), executionScheduler, cts, trace, triggerExtra),
CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler) CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler)
.Unwrap(); .Unwrap();
} }
@@ -231,9 +238,19 @@ internal static class ScriptRunLauncher
Guid? parentExecutionId, Guid? parentExecutionId,
TimeSpan timeout, TimeSpan timeout,
ScriptExecutionScheduler executionScheduler, ScriptExecutionScheduler executionScheduler,
CancellationTokenSource cts) CancellationTokenSource cts,
bool trace = false,
ScriptTraceExtra? triggerExtra = null)
{ {
IServiceScope? serviceScope = null; IServiceScope? serviceScope = null;
// Flow Debugger (design §5.4): the inline sink exists only for a traced run; the
// ScriptRun row itself is emitted below on every exit path of a traced run.
var collector = trace ? new FlowTraceCollector() : null;
var startedUtc = DateTime.UtcNow;
ScriptRuntimeContext? context = null;
IAuditWriter? auditWriter = null;
var siteId = string.Empty;
string? sourceNode = null;
// ISiteEventLogger is a singleton; resolve from the root provider so // ISiteEventLogger is a singleton; resolve from the root provider so
// it is available to the catch blocks regardless of scope state. // it is available to the catch blocks regardless of scope state.
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>(); var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
@@ -281,11 +298,11 @@ internal static class ScriptRunLauncher
// Notification Outbox: the S&F engine is a singleton; the site identity // Notification Outbox: the S&F engine is a singleton; the site identity
// provider supplies the site id stamped on enqueued notifications. // provider supplies the site id stamped on enqueued notifications.
StoreAndForwardService? storeAndForward = null; StoreAndForwardService? storeAndForward = null;
var siteId = string.Empty;
// The writer is a singleton (FallbackAuditWriter // The writer is a singleton (FallbackAuditWriter
// composes the SQLite hot-path + drop-oldest ring); null in tests / hosts // composes the SQLite hot-path + drop-oldest ring); null in tests / hosts
// that haven't called AddAuditLog, which the helper handles as a no-op. // that haven't called AddAuditLog, which the helper handles as a no-op.
IAuditWriter? auditWriter = null; // (siteId / auditWriter / sourceNode are declared above the try so the
// traced ScriptRun emission in the catch blocks can reach them.)
// Site-local tracking store // Site-local tracking store
// backing Tracking.Status(id). Singleton; null in tests / hosts // backing Tracking.Status(id). Singleton; null in tests / hosts
// that haven't wired the store, which the helper handles by // that haven't wired the store, which the helper handles by
@@ -304,7 +321,6 @@ internal static class ScriptRunLauncher
// provider, in which case NotificationSubmit.SourceNode and // provider, in which case NotificationSubmit.SourceNode and
// SiteCallOperational.SourceNode stay null and central // SiteCallOperational.SourceNode stay null and central
// persists the rows with SourceNode NULL. // persists the rows with SourceNode NULL.
string? sourceNode = null;
if (serviceProvider != null) if (serviceProvider != null)
{ {
@@ -320,7 +336,7 @@ internal static class ScriptRunLauncher
sourceNode = serviceScope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName; sourceNode = serviceScope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName;
} }
var context = new ScriptRuntimeContext( context = new ScriptRuntimeContext(
instanceActor, instanceActor,
sharedScriptLibrary, sharedScriptLibrary,
callDepth, callDepth,
@@ -371,7 +387,10 @@ internal static class ScriptRunLauncher
// execution-timeout token so Attributes.WaitAsync's Ask is // execution-timeout token so Attributes.WaitAsync's Ask is
// bounded by the script's own ExecutionTimeoutSeconds — a // bounded by the script's own ExecutionTimeoutSeconds — a
// shorter script deadline wins over the wait's own timeout. // shorter script deadline wins over the wait's own timeout.
scriptTimeoutToken: cts.Token); scriptTimeoutToken: cts.Token,
// Flow Debugger (design §5.2/§5.4): the trace flag and the inline sink.
isTraced: trace,
traceCollector: collector);
var globals = new ScriptGlobals var globals = new ScriptGlobals
{ {
@@ -397,10 +416,18 @@ internal static class ScriptRunLauncher
var state = await compiledScript.RunAsync(globals, cts.Token); var state = await compiledScript.RunAsync(globals, cts.Token);
// Flow Debugger: the ScriptRun row goes into the collector BEFORE the reply so the
// inline list the caller receives includes the run itself.
EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode,
context.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds,
parameters, state.ReturnValue, error: null, triggerExtra);
// Send result to requester if this was an Ask-based call // Send result to requester if this was an Ask-based call
if (!replyTo.IsNobody()) if (!replyTo.IsNobody())
{ {
replyTo.Tell(new ScriptCallResult(correlationId, true, state.ReturnValue, null)); replyTo.Tell(new ScriptCallResult(
correlationId, true, state.ReturnValue, null,
context.ExecutionId, collector?.Snapshot()));
} }
// Operational `script` event — execution completed successfully. Same gating as // Operational `script` event — execution completed successfully. Same gating as
@@ -428,9 +455,14 @@ internal static class ScriptRunLauncher
_ = siteEventLogger?.LogEventAsync( _ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg); "script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg);
EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode,
context?.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds,
parameters, null, errorMsg, triggerExtra);
if (!replyTo.IsNobody()) if (!replyTo.IsNobody())
{ {
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg)); replyTo.Tell(new ScriptCallResult(
correlationId, false, null, errorMsg, context?.ExecutionId, collector?.Snapshot()));
} }
completionTarget.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg)); completionTarget.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
@@ -448,9 +480,14 @@ internal static class ScriptRunLauncher
_ = siteEventLogger?.LogEventAsync( _ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg, ex.ToString()); "script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg, ex.ToString());
EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode,
context?.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds,
parameters, null, errorMsg, triggerExtra);
if (!replyTo.IsNobody()) if (!replyTo.IsNobody())
{ {
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg)); replyTo.Tell(new ScriptCallResult(
correlationId, false, null, errorMsg, context?.ExecutionId, collector?.Snapshot()));
} }
completionTarget.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg)); completionTarget.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
@@ -466,6 +503,59 @@ internal static class ScriptRunLauncher
} }
} }
/// <summary>
/// Flow Debugger (design 2026-09-05 §5.1): emits the <c>ScriptTrace.ScriptRun</c> row for a
/// traced run — collector first (so the inline reply carries it), then the best-effort
/// audit writer. <c>OccurredAtUtc</c> is the run's START so the row sorts before the
/// writes/waits the run made. No-op when untraced; never throws.
/// </summary>
private static void EmitScriptRun(
bool trace, FlowTraceCollector? collector, IAuditWriter? auditWriter, ILogger logger,
string scriptName, string instanceName, string siteId, string? sourceNode,
Guid? executionId, Guid? parentExecutionId, DateTime startedUtc, long elapsedMs,
IReadOnlyDictionary<string, object?>? parameters, object? returnValue, string? error,
ScriptTraceExtra? triggerExtra)
{
if (!trace) return;
try
{
var sourceScript = $"ScriptActor:{scriptName}";
var evt = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.ScriptRun,
error is null ? AuditStatus.Delivered : AuditStatus.Failed,
occurredAtUtc: DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc),
actor: sourceScript, target: scriptName, sourceNode: sourceNode,
executionId: executionId, parentExecutionId: parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(siteId) ? null : siteId,
sourceInstanceId: instanceName, sourceScript: sourceScript,
durationMs: (int)Math.Min(elapsedMs, int.MaxValue),
errorMessage: error,
requestSummary: TraceJson(parameters),
responseSummary: TraceJson(returnValue),
extra: (triggerExtra ?? new ScriptTraceExtra { TriggerKind = "call" }).ToJson());
collector?.Add(evt);
if (auditWriter is null) return;
var t = auditWriter.WriteAsync(evt, CancellationToken.None);
if (!t.IsCompletedSuccessfully)
{
_ = t.ContinueWith(
x => logger.LogWarning(x.Exception, "Flow trace ScriptRun write failed for {Script}", scriptName),
TaskContinuationOptions.OnlyOnFaulted);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Flow trace ScriptRun emission failed for {Script}", scriptName);
}
}
private static string? TraceJson(object? value)
{
if (value is null) return null;
try { return JsonSerializer.Serialize(value, ScriptTraceExtra.JsonOptions); }
catch (Exception) { return null; }
}
private static async Task RunAlarmScriptAsync( private static async Task RunAlarmScriptAsync(
string alarmName, string alarmName,
string instanceName, string instanceName,
@@ -0,0 +1,183 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Flow Debugger design 2026-09-05 §5.1/§5.4: a traced <see cref="ScriptCallRequest"/> makes
/// the launched run emit a <c>ScriptRun</c> row and return its <c>ExecutionId</c> plus the
/// inline trace events on the <see cref="ScriptCallResult"/>; a traced, stamped
/// <see cref="AttributeValueChanged"/> parents the value-change run to the writer. Untraced
/// paths are pinned unchanged.
/// </summary>
public class ScriptActorTraceTests : TestKit, IDisposable
{
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;
}
public AuditEvent? WaitForKind(string action, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
lock (Events)
{
var hit = Events.FirstOrDefault(e => e.Action == action);
if (hit is not null) return hit;
}
Thread.Sleep(25);
}
return null;
}
}
private readonly SharedScriptLibrary _sharedLibrary;
private readonly SiteRuntimeOptions _options;
private readonly ScriptExecutionScheduler _scheduler;
private readonly CapturingAuditWriter _writer = new();
private readonly IServiceProvider _services;
public ScriptActorTraceTests()
{
_sharedLibrary = new SharedScriptLibrary(
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
NullLogger<SharedScriptLibrary>.Instance);
_options = new SiteRuntimeOptions { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = 30 };
_scheduler = new ScriptExecutionScheduler(_options.ScriptExecutionThreadCount);
var sc = new ServiceCollection();
sc.AddSingleton<IAuditWriter>(_writer);
_services = sc.BuildServiceProvider();
}
void IDisposable.Dispose()
{
Shutdown();
_scheduler.Dispose();
}
private static Script<object?> Compile(string code)
{
var script = CSharpScript.Create<object?>(code,
ScriptOptions.Default.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks"),
typeof(ScriptGlobals));
script.Compile();
return script;
}
private IActorRef CreateActor(string name, string code, string? triggerJson = null)
{
var config = new ResolvedScript { CanonicalName = name, Code = code, TriggerType = triggerJson is null ? null : "valuechange", TriggerConfiguration = triggerJson };
return ActorOf(Props.Create(() => new ScriptActor(
name, "Pump1", CreateTestProbe().Ref, Compile(code), config, _sharedLibrary, _options,
NullLogger<ScriptActor>.Instance, null, null, null, _services, _scheduler)));
}
[Fact]
public void Call_WithTrace_ReplyCarriesExecutionId_AndAScriptRunEvent()
{
var actor = CreateActor("Add", "(int)Parameters[\"x\"] + 1");
actor.Tell(new ScriptCallRequest("Add", new Dictionary<string, object?> { ["x"] = 41 }, 0, "cid", Trace: true));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
Assert.Equal(42, result.ReturnValue);
Assert.NotNull(result.ExecutionId);
var evt = Assert.Single(result.TraceEvents!);
var row = evt.AsRow();
Assert.Equal("ScriptTrace.ScriptRun", evt.Action);
Assert.Equal("Delivered", row.Status.ToString());
Assert.Equal(result.ExecutionId, row.ExecutionId);
Assert.Equal("Add", evt.Target);
Assert.Equal("Pump1", row.SourceInstanceId);
Assert.Contains("\"x\":41", row.RequestSummary);
Assert.Equal("42", row.ResponseSummary);
Assert.Equal("call", ScriptTraceExtra.TryParse(row.Extra)!.TriggerKind);
// The same row went through the audit writer.
Assert.NotNull(_writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(5)));
}
[Fact]
public void Call_WithTrace_FailedRun_ReturnsFailedScriptRunEvent()
{
var actor = CreateActor("Boom", "throw new InvalidOperationException(\"kaboom\");");
actor.Tell(new ScriptCallRequest("Boom", null, 0, "cid", Trace: true));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.NotNull(result.ExecutionId);
var row = Assert.Single(result.TraceEvents!).AsRow();
Assert.Equal("Failed", row.Status.ToString());
Assert.Contains("kaboom", row.ErrorMessage);
}
[Fact]
public void Call_WithoutTrace_ReplyHasNoTraceEvents_ButStillCarriesExecutionId()
{
var actor = CreateActor("Answer", "42");
actor.Tell(new ScriptCallRequest("Answer", null, 0, "cid"));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success);
Assert.NotNull(result.ExecutionId);
Assert.Null(result.TraceEvents);
Thread.Sleep(200);
Assert.Empty(_writer.Events);
}
[Fact]
public void ValueChangeTrigger_TracedChange_RunParentsToSourceExecutionId()
{
var actor = CreateActor("OnStatus", "1", "{\"attribute\":\"Status\"}");
var writer = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
actor.Tell(new AttributeValueChanged("Pump1", "Status", "Status", "Running", "Good", now, SourceExecutionId: writer, Trace: true));
var evt = _writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(10));
Assert.NotNull(evt);
var row = evt!.AsRow();
Assert.Equal(writer, row.ParentExecutionId);
Assert.NotEqual(writer, row.ExecutionId);
var extra = ScriptTraceExtra.TryParse(row.Extra)!;
Assert.Equal("valuechange", extra.TriggerKind);
Assert.Equal("Status", extra.TriggerAttribute);
Assert.Equal("Running", extra.TriggerValue);
Assert.Equal("value-change", extra.TriggerSource);
}
[Fact]
public void ValueChangeTrigger_UntracedChange_RunStaysRoot_AndEmitsNothing()
{
var actor = CreateActor("OnStatus", "1", "{\"attribute\":\"Status\"}");
// Stamped but NOT traced (an ordinary script write) — and a plain DCL change.
actor.Tell(new AttributeValueChanged("Pump1", "Status", "Status", "Running", "Good", DateTimeOffset.UtcNow, SourceExecutionId: Guid.NewGuid()));
actor.Tell(new AttributeValueChanged("Pump1", "Status", "Status", "Stopped", "Good", DateTimeOffset.UtcNow));
Assert.Null(_writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(1)));
Assert.Empty(_writer.Events);
}
}