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:<scriptName>", 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.
This commit is contained in:
Joseph Doherty
2026-05-19 03:54:09 -04:00
parent a5653b4296
commit 558f9ceb39
3 changed files with 62 additions and 8 deletions

View File

@@ -125,7 +125,10 @@ public class ScriptExecutionActor : ReceiveActor
databaseGateway, databaseGateway,
storeAndForward, storeAndForward,
siteCommunicationActor, 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 var globals = new ScriptGlobals
{ {

View File

@@ -66,6 +66,15 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly string _siteId; private readonly string _siteId;
/// <summary>
/// Notification Outbox (FU3): identifier of the script currently executing in this
/// context — stamped onto <c>NotificationSubmit.SourceScript</c> for the central
/// audit trail. Uses the Site Event Logging "Source" convention
/// (<c>"ScriptActor:&lt;scriptName&gt;"</c>). Null when no single script owns the
/// context (e.g. alarm on-trigger paths that do not wire the Notify outbox).
/// </summary>
private readonly string? _sourceScript;
public ScriptRuntimeContext( public ScriptRuntimeContext(
IActorRef instanceActor, IActorRef instanceActor,
IActorRef self, IActorRef self,
@@ -79,7 +88,8 @@ public class ScriptRuntimeContext
IDatabaseGateway? databaseGateway = null, IDatabaseGateway? databaseGateway = null,
StoreAndForwardService? storeAndForward = null, StoreAndForwardService? storeAndForward = null,
ICanTell? siteCommunicationActor = null, ICanTell? siteCommunicationActor = null,
string siteId = "") string siteId = "",
string? sourceScript = null)
{ {
_instanceActor = instanceActor; _instanceActor = instanceActor;
_self = self; _self = self;
@@ -94,6 +104,7 @@ public class ScriptRuntimeContext
_storeAndForward = storeAndForward; _storeAndForward = storeAndForward;
_siteCommunicationActor = siteCommunicationActor; _siteCommunicationActor = siteCommunicationActor;
_siteId = siteId; _siteId = siteId;
_sourceScript = sourceScript;
} }
/// <summary> /// <summary>
@@ -209,7 +220,7 @@ public class ScriptRuntimeContext
/// <c>Notify.Status(id)</c> queries the delivery status of that notification. /// <c>Notify.Status(id)</c> queries the delivery status of that notification.
/// </summary> /// </summary>
public NotifyHelper Notify => new( public NotifyHelper Notify => new(
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _askTimeout, _logger); _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger);
/// <summary> /// <summary>
/// Helper class for Scripts.CallShared() syntax. /// Helper class for Scripts.CallShared() syntax.
@@ -356,6 +367,7 @@ public class ScriptRuntimeContext
private readonly ICanTell? _siteCommunicationActor; private readonly ICanTell? _siteCommunicationActor;
private readonly string _siteId; private readonly string _siteId;
private readonly string _instanceName; private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly TimeSpan _askTimeout; private readonly TimeSpan _askTimeout;
private readonly ILogger _logger; private readonly ILogger _logger;
@@ -364,6 +376,7 @@ public class ScriptRuntimeContext
ICanTell? siteCommunicationActor, ICanTell? siteCommunicationActor,
string siteId, string siteId,
string instanceName, string instanceName,
string? sourceScript,
TimeSpan askTimeout, TimeSpan askTimeout,
ILogger logger) ILogger logger)
{ {
@@ -371,6 +384,7 @@ public class ScriptRuntimeContext
_siteCommunicationActor = siteCommunicationActor; _siteCommunicationActor = siteCommunicationActor;
_siteId = siteId; _siteId = siteId;
_instanceName = instanceName; _instanceName = instanceName;
_sourceScript = sourceScript;
_askTimeout = askTimeout; _askTimeout = askTimeout;
_logger = logger; _logger = logger;
} }
@@ -381,7 +395,7 @@ public class ScriptRuntimeContext
public NotifyTarget To(string listName) public NotifyTarget To(string listName)
{ {
return new NotifyTarget( return new NotifyTarget(
listName, _storeAndForward, _siteId, _instanceName, _logger); listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger);
} }
/// <summary> /// <summary>
@@ -453,6 +467,7 @@ public class ScriptRuntimeContext
private readonly StoreAndForwardService? _storeAndForward; private readonly StoreAndForwardService? _storeAndForward;
private readonly string _siteId; private readonly string _siteId;
private readonly string _instanceName; private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly ILogger _logger; private readonly ILogger _logger;
internal NotifyTarget( internal NotifyTarget(
@@ -460,12 +475,14 @@ public class ScriptRuntimeContext
StoreAndForwardService? storeAndForward, StoreAndForwardService? storeAndForward,
string siteId, string siteId,
string instanceName, string instanceName,
string? sourceScript,
ILogger logger) ILogger logger)
{ {
_listName = listName; _listName = listName;
_storeAndForward = storeAndForward; _storeAndForward = storeAndForward;
_siteId = siteId; _siteId = siteId;
_instanceName = instanceName; _instanceName = instanceName;
_sourceScript = sourceScript;
_logger = logger; _logger = logger;
} }
@@ -504,9 +521,10 @@ public class ScriptRuntimeContext
// value is the best-effort site id known to the script runtime. // value is the best-effort site id known to the script runtime.
SourceSiteId: _siteId, SourceSiteId: _siteId,
SourceInstanceId: _instanceName, SourceInstanceId: _instanceName,
// SourceScript: the script runtime does not currently thread the script // SourceScript (FU3): identifier of the script that raised this
// name down to the Notify helper; left null until that wiring exists. // notification, threaded down from the script-execution context for the
SourceScript: null, // central audit trail. Null when no single script owns the context.
SourceScript: _sourceScript,
SiteEnqueuedAt: DateTimeOffset.UtcNow); SiteEnqueuedAt: DateTimeOffset.UtcNow);
var payloadJson = JsonSerializer.Serialize(payload); var payloadJson = JsonSerializer.Serialize(payload);

View File

@@ -58,13 +58,16 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
base.Dispose(disposing); base.Dispose(disposing);
} }
private ScriptRuntimeContext.NotifyHelper CreateHelper(IActorRef siteCommunicationActor) private ScriptRuntimeContext.NotifyHelper CreateHelper(
IActorRef siteCommunicationActor,
string? sourceScript = null)
{ {
return new ScriptRuntimeContext.NotifyHelper( return new ScriptRuntimeContext.NotifyHelper(
_saf, _saf,
siteCommunicationActor, siteCommunicationActor,
"site-7", "site-7",
"Plant.Pump3", "Plant.Pump3",
sourceScript,
TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3),
NullLogger.Instance); NullLogger.Instance);
} }
@@ -113,6 +116,36 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
Assert.Equal("Plant.Pump3", payload.SourceInstanceId); 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<NotificationSubmit>(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<NotificationSubmit>(buffered!.PayloadJson);
Assert.Null(payload!.SourceScript);
}
[Fact] [Fact]
public async Task Status_WhenStillBufferedAtSite_ReportsForwarding() public async Task Status_WhenStillBufferedAtSite_ReportsForwarding()
{ {