using Akka.Actor; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Types; using ScadaLink.HealthMonitoring; using ScadaLink.SiteEventLogging; using ScadaLink.SiteRuntime.Scripts; namespace ScadaLink.SiteRuntime.Actors; /// /// WP-15: Script Execution Actor -- short-lived child of Script Actor. /// Receives compiled code, params, Instance Actor ref, and call depth. /// Runs on a dedicated blocking I/O dispatcher. /// Executes the script via Script Runtime API, returns result, then stops. /// /// WP-32: Script failures are logged but do not disable the script. /// Supervision: Stop on unhandled exception (parent ScriptActor decides). /// public class ScriptExecutionActor : ReceiveActor { public ScriptExecutionActor( string scriptName, string instanceName, Script compiledScript, IReadOnlyDictionary? parameters, int callDepth, IActorRef instanceActor, SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, IActorRef replyTo, string correlationId, ILogger logger, Commons.Types.Scripts.ScriptScope scope, ISiteHealthCollector? healthCollector = null, IServiceProvider? serviceProvider = null) { // Immediately begin execution var self = Self; var parent = Context.Parent; ExecuteScript( scriptName, instanceName, compiledScript, parameters, callDepth, instanceActor, sharedScriptLibrary, options, replyTo, correlationId, self, parent, logger, scope, healthCollector, serviceProvider); } private static void ExecuteScript( string scriptName, string instanceName, Script compiledScript, IReadOnlyDictionary? parameters, int callDepth, IActorRef instanceActor, SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, IActorRef replyTo, string correlationId, IActorRef self, IActorRef parent, ILogger logger, Commons.Types.Scripts.ScriptScope scope, ISiteHealthCollector? healthCollector, IServiceProvider? serviceProvider) { var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds); // CTS must be created inside the async lambda so it outlives this method _ = Task.Run(async () => { IServiceScope? serviceScope = null; // ISiteEventLogger is a singleton; resolve from the root provider so // it is available to the catch blocks regardless of scope state. var siteEventLogger = serviceProvider?.GetService(); using var cts = new CancellationTokenSource(timeout); try { // Resolve integration services from DI (scoped lifetime) IExternalSystemClient? externalSystemClient = null; IDatabaseGateway? databaseGateway = null; INotificationDeliveryService? notificationService = null; if (serviceProvider != null) { serviceScope = serviceProvider.CreateScope(); externalSystemClient = serviceScope.ServiceProvider.GetService(); databaseGateway = serviceScope.ServiceProvider.GetService(); notificationService = serviceScope.ServiceProvider.GetService(); } var context = new ScriptRuntimeContext( instanceActor, self, sharedScriptLibrary, callDepth, options.MaxScriptCallDepth, timeout, instanceName, logger, externalSystemClient, databaseGateway, notificationService); var globals = new ScriptGlobals { Instance = context, Parameters = new ScriptParameters(parameters ?? new Dictionary()), CancellationToken = cts.Token, Scope = scope }; var state = await compiledScript.RunAsync(globals, cts.Token); // Send result to requester if this was an Ask-based call if (!replyTo.IsNobody()) { replyTo.Tell(new ScriptCallResult(correlationId, true, state.ReturnValue, null)); } // Notify parent of completion parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, true, null)); } catch (OperationCanceledException) { healthCollector?.IncrementScriptError(); var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s"; logger.LogWarning(errorMsg); // WP-32: Failures recorded to site event log; script NOT disabled after failure. _ = siteEventLogger?.LogEventAsync( "script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg); if (!replyTo.IsNobody()) { replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg)); } parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg)); } catch (Exception ex) { healthCollector?.IncrementScriptError(); // WP-32: Failures recorded to site event log; script NOT disabled after failure. var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' failed: {ex.Message}"; logger.LogError(ex, "Script execution failed: {Script} on {Instance}", scriptName, instanceName); _ = siteEventLogger?.LogEventAsync( "script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg, ex.ToString()); if (!replyTo.IsNobody()) { replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg)); } parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg)); } finally { // Dispose the DI scope (and scoped services) after script execution completes serviceScope?.Dispose(); // Stop self after execution completes self.Tell(PoisonPill.Instance); } }); } }