diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 21bc0fd7..b34b8c4c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -2873,7 +2873,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers // execution can record its spawner. var scriptCall = new ScriptCallRequest( request.ScriptName, request.Parameters, 0, request.CorrelationId, - ParentExecutionId: request.ParentExecutionId); + ParentExecutionId: request.ParentExecutionId, + // Flow Debugger (design 2026-09-05 §5.2): the trace flag rides beside the parent id. + Trace: request.Trace); var sender = Sender; instanceActor.Ask(scriptCall, TimeSpan.FromSeconds(30)) .ContinueWith(t => @@ -2894,7 +2896,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers // the inbound side (which JsonSerializer-serializes it for the // HTTP body / ReturnDefinition validation). NormalizeRoutedReturnValue(result.ReturnValue), - result.ErrorMessage, DateTimeOffset.UtcNow); + result.ErrorMessage, DateTimeOffset.UtcNow, + // Flow Debugger (design §5.4): the site run's id and, for a traced + // run, its ScriptTrace events — returned inline so a Live run is + // instant. Null from an untraced run. + result.ExecutionId, result.TraceEvents); } return new RouteToCallResponse( request.CorrelationId, false, null, @@ -3101,7 +3107,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers // carried on the request as ParentExecutionId. Stamping it // here lets an alarm this write trips chain its on-trigger // script run under the inbound execution. - SourceExecutionId: request.ParentExecutionId), + SourceExecutionId: request.ParentExecutionId, + // Flow Debugger (design §5.3): a traced routed write arms the echo stamp. + Trace: request.Trace), TimeSpan.FromSeconds(30))) .ToArray(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRouteTraceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRouteTraceTests.cs new file mode 100644 index 00000000..a70b54f7 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRouteTraceTests.cs @@ -0,0 +1,129 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; +using System.Text.Json; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; + +/// +/// Flow Debugger design 2026-09-05 §5.2/§5.4: the Deployment Manager's inbound-API route +/// handlers forward the Trace flag and map the routed run's ExecutionId and +/// inline ScriptTrace events onto the . Fixture copied +/// from DeploymentManagerActorTests (real Instance/Script actors end-to-end). +/// +public class DeploymentManagerRouteTraceTests : TestKit, IDisposable +{ + private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly TestLocalDb _localDb; + + public DeploymentManagerRouteTraceTests() + { + _localDb = TestLocalDb.CreateTemp("dm-route-trace-test"); + _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); + _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService(NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger.Instance); + } + + void IDisposable.Dispose() + { + Shutdown(); + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } + + private IActorRef CreateDeploymentManager() => + ActorOf(Props.Create(() => new DeploymentManagerActor( + _storage, _compilationService, _sharedScriptLibrary, null, new SiteRuntimeOptions(), + NullLogger.Instance, + dclManager: null, healthCollector: null, serviceProvider: null, loggerFactory: null, configFetcher: null))); + + private static string ConfigJson(string instance) => JsonSerializer.Serialize(new FlattenedConfiguration + { + InstanceUniqueName = instance, + Attributes = [new ResolvedAttribute { CanonicalName = "Mode", Value = "Auto", DataType = "String" }], + Scripts = [new ResolvedScript { CanonicalName = "DoWork", Code = "await Instance.SetAttribute(\"Mode\", \"Manual\"); return 7;" }], + }); + + private async Task DeployedManager(string instance) + { + var actor = CreateDeploymentManager(); + await Task.Delay(500); + actor.Tell(new DeployInstanceCommand("dep", instance, "sha256:x", ConfigJson(instance), "admin", DateTimeOffset.UtcNow)); + ExpectMsg(TimeSpan.FromSeconds(5)); + await Task.Delay(1000); + return actor; + } + + [Fact] + public async Task RouteCall_WithTrace_ReturnsChildExecutionId_AndInlineTraceEvents() + { + var actor = await DeployedManager("TracedPump"); + var parent = Guid.NewGuid(); + + actor.Tell(new RouteToCallRequest("rc-1", "TracedPump", "DoWork", null, DateTimeOffset.UtcNow, + ParentExecutionId: parent, Trace: true)); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(response.Success, response.ErrorMessage); + Assert.Equal(7, Convert.ToInt32(response.ReturnValue)); + Assert.NotNull(response.ChildExecutionId); + Assert.NotNull(response.TraceEvents); + + var rows = response.TraceEvents!.Select(e => e.AsRow()).ToList(); + // The script wrote an attribute (AttributeWrite) and the run itself (ScriptRun) — both + // stamped with the routed run's id and parented to the inbound request. + Assert.Contains(rows, r => r.Kind == Commons.Types.Enums.AuditKind.AttributeWrite); + var run = Assert.Single(rows, r => r.Kind == Commons.Types.Enums.AuditKind.ScriptRun); + Assert.Equal(response.ChildExecutionId, run.ExecutionId); + Assert.Equal(parent, run.ParentExecutionId); + Assert.All(rows, r => Assert.Equal(response.ChildExecutionId, r.ExecutionId)); + } + + [Fact] + public async Task RouteCall_WithoutTrace_ReturnsExecutionId_ButNoTraceEvents() + { + var actor = await DeployedManager("PlainPump"); + + actor.Tell(new RouteToCallRequest("rc-2", "PlainPump", "DoWork", null, DateTimeOffset.UtcNow)); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(response.Success, response.ErrorMessage); + Assert.NotNull(response.ChildExecutionId); + Assert.Null(response.TraceEvents); + } + + [Fact] + public async Task RouteSetAttributes_ForwardsTraceFlag_OnTheCommand() + { + var actor = await DeployedManager("SetPump"); + var parent = Guid.NewGuid(); + + actor.Tell(new RouteToSetAttributesRequest("rs-1", "SetPump", + new Dictionary { ["Mode"] = "Hand" }, DateTimeOffset.UtcNow, + ParentExecutionId: parent, Trace: true)); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(response.Success, response.ErrorMessage); + + // The write landed with the trace stamp: reading it back through a debug snapshot is + // heavier than needed — the InstanceActorTraceArmTests pin the publish; here we assert + // the routed write succeeded end-to-end with the additive flag set, i.e. the command + // shape the handler builds is accepted by the Instance Actor. + actor.Tell(new RouteToGetAttributesRequest("rg-1", "SetPump", ["Mode"], DateTimeOffset.UtcNow)); + var read = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal("Hand", read.Values["Mode"]?.ToString()); + } +}