using System.Text.Json; using Akka.Actor; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Instance; using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Enums; using ScadaLink.StoreAndForward; namespace ScadaLink.SiteRuntime.Scripts; /// /// WP-18: Script Runtime API — injected into Script/Alarm Execution Actors. /// Provides the API surface that user scripts interact with: /// Instance.GetAttribute("name") /// Instance.SetAttribute("name", value) /// Instance.CallScript("scriptName", params) /// Scripts.CallShared("scriptName", params) /// /// WP-13 (Phase 7): Integration surface APIs: /// ExternalSystem.Call("systemName", "methodName", params) /// ExternalSystem.CachedCall("systemName", "methodName", params) /// Database.Connection("name") /// Database.CachedWrite("name", "sql", params) /// Notify.To("listName").Send("subject", "message") /// /// WP-20: Recursion Limit — call depth tracked and enforced. /// public class ScriptRuntimeContext { private readonly IActorRef _instanceActor; private readonly IActorRef _self; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly int _currentCallDepth; private readonly int _maxCallDepth; private readonly TimeSpan _askTimeout; private readonly ILogger _logger; private readonly string _instanceName; /// /// WP-13: External system client for ExternalSystem.Call/CachedCall. /// private readonly IExternalSystemClient? _externalSystemClient; /// /// WP-13: Database gateway for Database.Connection/CachedWrite. /// private readonly IDatabaseGateway? _databaseGateway; /// /// Notification Outbox: the site Store-and-Forward Engine that Notify.Send /// enqueues notifications into. The S&F engine forwards them to central. /// private readonly StoreAndForwardService? _storeAndForward; /// /// Notification Outbox: the site communication actor that Notify.Status /// queries central through (via the ClusterClient command/control transport). /// private readonly ICanTell? _siteCommunicationActor; /// /// Notification Outbox: this site's identifier, stamped on enqueued notifications. /// private readonly string _siteId; public ScriptRuntimeContext( IActorRef instanceActor, IActorRef self, SharedScriptLibrary sharedScriptLibrary, int currentCallDepth, int maxCallDepth, TimeSpan askTimeout, string instanceName, ILogger logger, IExternalSystemClient? externalSystemClient = null, IDatabaseGateway? databaseGateway = null, StoreAndForwardService? storeAndForward = null, ICanTell? siteCommunicationActor = null, string siteId = "") { _instanceActor = instanceActor; _self = self; _sharedScriptLibrary = sharedScriptLibrary; _currentCallDepth = currentCallDepth; _maxCallDepth = maxCallDepth; _askTimeout = askTimeout; _instanceName = instanceName; _logger = logger; _externalSystemClient = externalSystemClient; _databaseGateway = databaseGateway; _storeAndForward = storeAndForward; _siteCommunicationActor = siteCommunicationActor; _siteId = siteId; } /// /// Gets the current value of an attribute from the Instance Actor. /// Uses Ask pattern (system boundary between script execution and instance state). /// public async Task GetAttribute(string attributeName) { var correlationId = Guid.NewGuid().ToString(); var request = new GetAttributeRequest( correlationId, _instanceName, attributeName, DateTimeOffset.UtcNow); var response = await _instanceActor.Ask(request, _askTimeout); if (!response.Found) { _logger.LogWarning( "GetAttribute: attribute '{Attribute}' not found on instance '{Instance}'", attributeName, _instanceName); } return response.Value; } /// /// Sets an attribute value. For data-connected attributes the Instance Actor /// forwards the write to the DCL, which writes the physical device; the /// in-memory value is not optimistically updated. For static attributes the /// Instance Actor updates the in-memory value and persists the override to /// SQLite. All mutations are serialized through the Instance Actor mailbox. /// /// The write is awaited so that a device-write failure on a data-connected /// attribute is surfaced synchronously to the calling script as an /// . /// public async Task SetAttribute(string attributeName, string value) { var correlationId = Guid.NewGuid().ToString(); var command = new SetStaticAttributeCommand( correlationId, _instanceName, attributeName, value, DateTimeOffset.UtcNow); // Ask — mutation serialized through the Instance Actor mailbox; the reply // carries the device-write outcome for data-connected attributes. var response = await _instanceActor.Ask(command, _askTimeout); if (!response.Success) { throw new InvalidOperationException( $"SetAttribute('{attributeName}') failed: {response.ErrorMessage}"); } } /// /// Calls a sibling script on the same instance by name (Ask pattern). /// WP-20: Enforces recursion limit. /// WP-22: Uses Ask pattern for CallScript. /// may be a dictionary or an anonymous object /// (new { name = "Bob" }) — see . /// public async Task CallScript(string scriptName, object? parameters = null) { var nextDepth = _currentCallDepth + 1; if (nextDepth > _maxCallDepth) { var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " + $"CallScript('{scriptName}') rejected at depth {nextDepth}."; _logger.LogError(msg); throw new InvalidOperationException(msg); } var correlationId = Guid.NewGuid().ToString(); var request = new ScriptCallRequest( scriptName, ScriptArgs.Normalize(parameters), nextDepth, correlationId); // Ask the Instance Actor, which routes to the appropriate Script Actor var result = await _instanceActor.Ask(request, _askTimeout); if (!result.Success) { throw new InvalidOperationException( $"CallScript('{scriptName}') failed: {result.ErrorMessage}"); } return result.ReturnValue; } /// /// Provides access to shared script execution via the Scripts property. /// public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger); /// /// WP-13: Provides access to external system calls. /// ExternalSystem.Call("systemName", "methodName", params) /// ExternalSystem.CachedCall("systemName", "methodName", params) /// public ExternalSystemHelper ExternalSystem => new(_externalSystemClient, _instanceName, _logger); /// /// WP-13: Provides access to database operations. /// Database.Connection("name") /// Database.CachedWrite("name", "sql", params) /// public DatabaseHelper Database => new(_databaseGateway, _instanceName, _logger); /// /// Provides access to the Notification Outbox API. /// Notify.To("listName").Send("subject", "message") enqueues a notification /// for central delivery and returns its NotificationId; /// Notify.Status(id) queries the delivery status of that notification. /// public NotifyHelper Notify => new( _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _askTimeout, _logger); /// /// Helper class for Scripts.CallShared() syntax. /// public class ScriptCallHelper { private readonly SharedScriptLibrary _library; private readonly ScriptRuntimeContext _context; private readonly int _currentCallDepth; private readonly int _maxCallDepth; private readonly ILogger _logger; internal ScriptCallHelper( SharedScriptLibrary library, ScriptRuntimeContext context, int currentCallDepth, int maxCallDepth, ILogger logger) { _library = library; _context = context; _currentCallDepth = currentCallDepth; _maxCallDepth = maxCallDepth; _logger = logger; } /// /// WP-17: Executes a shared script inline (direct method call, not actor message). /// WP-20: Enforces recursion limit. /// may be a dictionary or an anonymous /// object (new { name = "Bob" }) — see . /// public async Task CallShared( string scriptName, object? parameters = null, CancellationToken cancellationToken = default) { var nextDepth = _currentCallDepth + 1; if (nextDepth > _maxCallDepth) { var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " + $"CallShared('{scriptName}') rejected at depth {nextDepth}."; _logger.LogError(msg); throw new InvalidOperationException(msg); } return await _library.ExecuteAsync( scriptName, _context, ScriptArgs.Normalize(parameters), cancellationToken); } } /// /// WP-13: Helper for ExternalSystem.Call/CachedCall syntax. /// public class ExternalSystemHelper { private readonly IExternalSystemClient? _client; private readonly string _instanceName; private readonly ILogger _logger; internal ExternalSystemHelper(IExternalSystemClient? client, string instanceName, ILogger logger) { _client = client; _instanceName = instanceName; _logger = logger; } public async Task Call( string systemName, string methodName, IReadOnlyDictionary? parameters = null, CancellationToken cancellationToken = default) { if (_client == null) throw new InvalidOperationException("External system client not available"); return await _client.CallAsync(systemName, methodName, parameters, cancellationToken); } public async Task CachedCall( string systemName, string methodName, IReadOnlyDictionary? parameters = null, CancellationToken cancellationToken = default) { if (_client == null) throw new InvalidOperationException("External system client not available"); return await _client.CachedCallAsync(systemName, methodName, parameters, _instanceName, cancellationToken); } } /// /// WP-13: Helper for Database.Connection/CachedWrite syntax. /// public class DatabaseHelper { private readonly IDatabaseGateway? _gateway; private readonly string _instanceName; private readonly ILogger _logger; internal DatabaseHelper(IDatabaseGateway? gateway, string instanceName, ILogger logger) { _gateway = gateway; _instanceName = instanceName; _logger = logger; } public async Task Connection( string name, CancellationToken cancellationToken = default) { if (_gateway == null) throw new InvalidOperationException("Database gateway not available"); return await _gateway.GetConnectionAsync(name, cancellationToken); } public async Task CachedWrite( string name, string sql, IReadOnlyDictionary? parameters = null, CancellationToken cancellationToken = default) { if (_gateway == null) throw new InvalidOperationException("Database gateway not available"); await _gateway.CachedWriteAsync(name, sql, parameters, _instanceName, cancellationToken); } } /// /// Notification Outbox: helper for the Notify script API. /// /// In the outbox design the site no longer delivers notification email inline. /// Notify.To("listName").Send(...) enqueues the notification into the site /// Store-and-Forward Engine — which forwards it to central — and returns a /// NotificationId handle immediately. Notify.Status(id) later queries /// the delivery status of that notification. /// public class NotifyHelper { private readonly StoreAndForwardService? _storeAndForward; private readonly ICanTell? _siteCommunicationActor; private readonly string _siteId; private readonly string _instanceName; private readonly TimeSpan _askTimeout; private readonly ILogger _logger; internal NotifyHelper( StoreAndForwardService? storeAndForward, ICanTell? siteCommunicationActor, string siteId, string instanceName, TimeSpan askTimeout, ILogger logger) { _storeAndForward = storeAndForward; _siteCommunicationActor = siteCommunicationActor; _siteId = siteId; _instanceName = instanceName; _askTimeout = askTimeout; _logger = logger; } /// /// Selects the notification list to send to. /// public NotifyTarget To(string listName) { return new NotifyTarget( listName, _storeAndForward, _siteId, _instanceName, _logger); } /// /// Queries the delivery status of a previously-sent notification. /// /// The query is issued to central via the site communication actor. While the /// notification is still buffered in the site Store-and-Forward Engine — central /// has no row for it yet (Found: false) but the buffer still holds the id — /// the status is reported as the site-local Forwarding state. If central /// has a row, its status is mapped through verbatim. If central does not know the /// id and it is not buffered locally, the status is Unknown. /// public async Task Status(string notificationId) { if (_siteCommunicationActor == null) throw new InvalidOperationException( "Notification status query is not available — site communication actor not wired"); var correlationId = Guid.NewGuid().ToString(); var query = new NotificationStatusQuery(correlationId, notificationId); NotificationStatusResponse response; try { response = await _siteCommunicationActor .Ask(query, _askTimeout); } catch (Exception ex) { // Central could not be reached. Fall through to the buffer check: if the // notification is still in the local S&F buffer it is Forwarding. _logger.LogWarning(ex, "Notification status query for {NotificationId} did not reach central", notificationId); response = new NotificationStatusResponse( correlationId, Found: false, Status: "Unknown", RetryCount: 0, LastError: null, DeliveredAt: null); } if (response.Found) { return new NotificationDeliveryStatus( response.Status, response.RetryCount, response.LastError, response.DeliveredAt); } // Central has no row. If the notification is still buffered at the site it // is in transit — report the site-local Forwarding state. Otherwise it is // genuinely unknown (never sent, or already forwarded and central lost it). if (_storeAndForward != null) { var buffered = await _storeAndForward.GetMessageByIdAsync(notificationId); if (buffered != null) { return new NotificationDeliveryStatus( "Forwarding", buffered.RetryCount, buffered.LastError, DeliveredAt: null); } } return new NotificationDeliveryStatus("Unknown", 0, null, null); } } /// /// Notification Outbox: target for Notify.To("listName").Send(...). /// public class NotifyTarget { private readonly string _listName; private readonly StoreAndForwardService? _storeAndForward; private readonly string _siteId; private readonly string _instanceName; private readonly ILogger _logger; internal NotifyTarget( string listName, StoreAndForwardService? storeAndForward, string siteId, string instanceName, ILogger logger) { _listName = listName; _storeAndForward = storeAndForward; _siteId = siteId; _instanceName = instanceName; _logger = logger; } /// /// Enqueues a notification for central delivery and returns its /// NotificationId immediately. /// /// The notification is buffered into the site Store-and-Forward Engine under the /// category; the S&F /// engine's NotificationForwarder forwards it to central and treats /// central's ack as the delivery outcome. The returned NotificationId is /// the single idempotency key end-to-end: it is the S&F message id, it is /// carried inside the buffered payload, and it is the id the forwarder submits to /// central. Pass it to to track delivery. /// public async Task Send( string subject, string message, CancellationToken cancellationToken = default) { if (_storeAndForward == null) throw new InvalidOperationException( "Notification store-and-forward engine not available"); // The script controls the idempotency key: generate the NotificationId here, // use it as the S&F message id, and carry it inside the buffered payload so // the forwarder submits the same id to central on every retry. var notificationId = Guid.NewGuid().ToString("N"); var payload = new NotificationSubmit( NotificationId: notificationId, ListName: _listName, Subject: subject, Body: message, // SourceSiteId is re-stamped by the forwarder from its own site id; this // 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, SiteEnqueuedAt: DateTimeOffset.UtcNow); var payloadJson = JsonSerializer.Serialize(payload); // The S&F engine assigns its own GUID to the message; pin the message id to // the NotificationId so the buffer can be queried by it (Notify.Status) and // the forwarder's idempotency key matches the buffered row. await _storeAndForward.EnqueueAsync( StoreAndForwardCategory.Notification, target: _listName, payloadJson: payloadJson, originInstanceName: _instanceName, messageId: notificationId); _logger.LogDebug( "Notify enqueued notification {NotificationId} to list '{List}' for central delivery", notificationId, _listName); return notificationId; } } } /// /// Notification Outbox: the delivery status of a notification, as returned to a /// script by Notify.Status(id). /// /// is either a central status (Pending, Retrying, /// Delivered, Parked, Discarded), the site-local Forwarding /// state (the notification is still buffered at the site and has not yet been /// forwarded/acked), or Unknown (no central row and not buffered locally). /// public record NotificationDeliveryStatus( string Status, int RetryCount, string? LastError, DateTimeOffset? DeliveredAt);