From 558f9ceb396f13f69d22a7e794a1bd42a5b0506f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 19 May 2026 03:54:09 -0400 Subject: [PATCH] feat(notification-outbox): populate SourceScript on outbound notifications FU3: thread the executing script identifier from the script-execution context down to the Notify outbox API so NotifyTarget.Send stamps NotificationSubmit.SourceScript instead of leaving it null. - ScriptRuntimeContext / NotifyHelper / NotifyTarget take an optional sourceScript value, carried through to NotificationSubmit.SourceScript. - ScriptExecutionActor supplies "ScriptActor:", matching the Site Event Logging "Source" convention used for script error events. - AlarmExecutionActor builds the context without the S&F engine, so its Notify API is inert; sourceScript defaults to null there. --- .../Actors/ScriptExecutionActor.cs | 5 ++- .../Scripts/ScriptRuntimeContext.cs | 30 ++++++++++++---- .../Scripts/NotifyHelperTests.cs | 35 ++++++++++++++++++- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs index ce4f6e4..85cea1c 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs @@ -125,7 +125,10 @@ public class ScriptExecutionActor : ReceiveActor databaseGateway, storeAndForward, siteCommunicationActor, - siteId); + siteId, + // Notification Outbox (FU3): stamp the executing script onto outbound + // notifications using the Site Event Logging "Source" convention. + sourceScript: $"ScriptActor:{scriptName}"); var globals = new ScriptGlobals { diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 273e59b..ba00063 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -66,6 +66,15 @@ public class ScriptRuntimeContext /// private readonly string _siteId; + /// + /// Notification Outbox (FU3): identifier of the script currently executing in this + /// context — stamped onto NotificationSubmit.SourceScript for the central + /// audit trail. Uses the Site Event Logging "Source" convention + /// ("ScriptActor:<scriptName>"). Null when no single script owns the + /// context (e.g. alarm on-trigger paths that do not wire the Notify outbox). + /// + private readonly string? _sourceScript; + public ScriptRuntimeContext( IActorRef instanceActor, IActorRef self, @@ -79,7 +88,8 @@ public class ScriptRuntimeContext IDatabaseGateway? databaseGateway = null, StoreAndForwardService? storeAndForward = null, ICanTell? siteCommunicationActor = null, - string siteId = "") + string siteId = "", + string? sourceScript = null) { _instanceActor = instanceActor; _self = self; @@ -94,6 +104,7 @@ public class ScriptRuntimeContext _storeAndForward = storeAndForward; _siteCommunicationActor = siteCommunicationActor; _siteId = siteId; + _sourceScript = sourceScript; } /// @@ -209,7 +220,7 @@ public class ScriptRuntimeContext /// Notify.Status(id) queries the delivery status of that notification. /// public NotifyHelper Notify => new( - _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _askTimeout, _logger); + _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger); /// /// Helper class for Scripts.CallShared() syntax. @@ -356,6 +367,7 @@ public class ScriptRuntimeContext private readonly ICanTell? _siteCommunicationActor; private readonly string _siteId; private readonly string _instanceName; + private readonly string? _sourceScript; private readonly TimeSpan _askTimeout; private readonly ILogger _logger; @@ -364,6 +376,7 @@ public class ScriptRuntimeContext ICanTell? siteCommunicationActor, string siteId, string instanceName, + string? sourceScript, TimeSpan askTimeout, ILogger logger) { @@ -371,6 +384,7 @@ public class ScriptRuntimeContext _siteCommunicationActor = siteCommunicationActor; _siteId = siteId; _instanceName = instanceName; + _sourceScript = sourceScript; _askTimeout = askTimeout; _logger = logger; } @@ -381,7 +395,7 @@ public class ScriptRuntimeContext public NotifyTarget To(string listName) { return new NotifyTarget( - listName, _storeAndForward, _siteId, _instanceName, _logger); + listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger); } /// @@ -453,6 +467,7 @@ public class ScriptRuntimeContext private readonly StoreAndForwardService? _storeAndForward; private readonly string _siteId; private readonly string _instanceName; + private readonly string? _sourceScript; private readonly ILogger _logger; internal NotifyTarget( @@ -460,12 +475,14 @@ public class ScriptRuntimeContext StoreAndForwardService? storeAndForward, string siteId, string instanceName, + string? sourceScript, ILogger logger) { _listName = listName; _storeAndForward = storeAndForward; _siteId = siteId; _instanceName = instanceName; + _sourceScript = sourceScript; _logger = logger; } @@ -504,9 +521,10 @@ public class ScriptRuntimeContext // value is the best-effort site id known to the script runtime. SourceSiteId: _siteId, SourceInstanceId: _instanceName, - // SourceScript: the script runtime does not currently thread the script - // name down to the Notify helper; left null until that wiring exists. - SourceScript: null, + // SourceScript (FU3): identifier of the script that raised this + // notification, threaded down from the script-execution context for the + // central audit trail. Null when no single script owns the context. + SourceScript: _sourceScript, SiteEnqueuedAt: DateTimeOffset.UtcNow); var payloadJson = JsonSerializer.Serialize(payload); diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs index 95cf53b..e85e671 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs @@ -58,13 +58,16 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable base.Dispose(disposing); } - private ScriptRuntimeContext.NotifyHelper CreateHelper(IActorRef siteCommunicationActor) + private ScriptRuntimeContext.NotifyHelper CreateHelper( + IActorRef siteCommunicationActor, + string? sourceScript = null) { return new ScriptRuntimeContext.NotifyHelper( _saf, siteCommunicationActor, "site-7", "Plant.Pump3", + sourceScript, TimeSpan.FromSeconds(3), NullLogger.Instance); } @@ -113,6 +116,36 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal("Plant.Pump3", payload.SourceInstanceId); } + [Fact] + public async Task Send_WhenHelperHasSourceScript_StampsItOnTheNotificationSubmit() + { + var commProbe = CreateTestProbe(); + var notify = CreateHelper(commProbe.Ref, sourceScript: "ScriptActor:MonitorSpeed"); + + var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped"); + + var buffered = await _saf.GetMessageByIdAsync(notificationId); + Assert.NotNull(buffered); + + var payload = JsonSerializer.Deserialize(buffered!.PayloadJson); + Assert.NotNull(payload); + // FU3: the executing script name is threaded down and stamped for the audit trail. + Assert.Equal("ScriptActor:MonitorSpeed", payload!.SourceScript); + } + + [Fact] + public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull() + { + var commProbe = CreateTestProbe(); + var notify = CreateHelper(commProbe.Ref, sourceScript: null); + + var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped"); + + var buffered = await _saf.GetMessageByIdAsync(notificationId); + var payload = JsonSerializer.Deserialize(buffered!.PayloadJson); + Assert.Null(payload!.SourceScript); + } + [Fact] public async Task Status_WhenStillBufferedAtSite_ReportsForwarding() {