using Akka.Actor; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Messages.Streaming; using ScadaLink.Commons.Types.Flattening; using ScadaLink.HealthMonitoring; using ScadaLink.SiteEventLogging; using ScadaLink.SiteRuntime.Scripts; using System.Text.Json; namespace ScadaLink.SiteRuntime.Actors; /// /// WP-15: Script Actor — coordinator actor, child of Instance Actor. /// Holds compiled script delegate, manages trigger configuration, and spawns /// ScriptExecutionActor children per invocation. Does not block on child completion. /// /// Trigger types: /// - Interval: uses Akka timers to fire periodically /// - ValueChange: receives attribute change notifications from Instance Actor /// - Conditional: evaluates a threshold comparison on attribute change /// - Expression: evaluates a compiled boolean expression on attribute change /// Conditional and Expression triggers carry a : /// OnTrue fires as the condition becomes true; WhileTrue additionally re-fires /// on a timer (cadence = MinTimeBetweenRuns) while the condition stays true. /// /// Supervision strategy: Resume on exception (coordinator preserves state). /// public class ScriptActor : ReceiveActor, IWithTimers { private readonly string _scriptName; private readonly string _instanceName; private readonly IActorRef _instanceActor; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; private readonly ILogger _logger; private readonly ISiteHealthCollector? _healthCollector; private readonly IServiceProvider? _serviceProvider; private Script? _compiledScript; private ScriptTriggerConfig? _triggerConfig; private TimeSpan? _minTimeBetweenRuns; private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue; private int _executionCounter; private readonly Commons.Types.Scripts.ScriptScope _scope; // Expression trigger state: compiled expression, edge-tracking, and the // attribute snapshot the expression evaluates against. private readonly Script? _compiledTriggerExpression; private bool _lastExpressionResult; private readonly Dictionary _attributeSnapshot = new(); // WhileTrue trigger state: the most recent truth value of a Conditional // trigger's comparison, used to detect false->true / true->false edges. // (Expression triggers reuse _lastExpressionResult for the same purpose.) private bool _conditionState; /// Timer key for the WhileTrue re-fire timer (cadence = MinTimeBetweenRuns). private const string WhileTrueTimerKey = "whiletrue-trigger"; /// /// SiteRuntime-017: the exact dictionary instance this actor was seeded from /// at construction. The Instance Actor must pass a private snapshot here, not /// its live _attributes field — sharing the live dictionary lets this /// constructor enumerate it while the Instance Actor mutates it on another /// thread. Exposed for regression coverage of that isolation contract. /// internal IReadOnlyDictionary? SeedAttributesReference { get; } public ITimerScheduler Timers { get; set; } = null!; public ScriptActor( string scriptName, string instanceName, IActorRef instanceActor, Script? compiledScript, ResolvedScript scriptConfig, SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, ILogger logger, Script? compiledTriggerExpression = null, IReadOnlyDictionary? initialAttributes = null, ISiteHealthCollector? healthCollector = null, IServiceProvider? serviceProvider = null) { _scriptName = scriptName; _instanceName = instanceName; _instanceActor = instanceActor; _compiledScript = compiledScript; _sharedScriptLibrary = sharedScriptLibrary; _options = options; _logger = logger; _healthCollector = healthCollector; _serviceProvider = serviceProvider; _minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns; _scope = scriptConfig.Scope; _compiledTriggerExpression = compiledTriggerExpression; // Seed the trigger-expression attribute snapshot from the instance's // initial attribute set so static attributes (which never re-emit an // AttributeValueChanged after deploy) evaluate correctly at startup. SeedAttributesReference = initialAttributes; if (initialAttributes != null) { foreach (var kvp in initialAttributes) _attributeSnapshot[kvp.Key] = kvp.Value; } // Parse trigger configuration _triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration); // Handle script call requests (Ask pattern from Instance Actor or ScriptRuntimeContext) Receive(HandleScriptCallRequest); // Handle attribute value changes for value-change and conditional triggers Receive(HandleAttributeValueChanged); // Handle interval tick Receive(_ => TrySpawnExecution(null)); // Handle WhileTrue re-fire tick Receive(_ => FireWhileTrueTick()); // Handle execution completion (for logging/metrics) Receive(HandleExecutionCompleted); } protected override void PreStart() { base.PreStart(); // Set up interval trigger if configured if (_triggerConfig is IntervalTriggerConfig interval) { Timers.StartPeriodicTimer( "interval-trigger", IntervalTick.Instance, interval.Interval, interval.Interval); _logger.LogDebug( "ScriptActor {Script} on {Instance}: interval trigger set to {Interval}", _scriptName, _instanceName, interval.Interval); } _logger.LogInformation( "ScriptActor {Script} started on instance {Instance}", _scriptName, _instanceName); } /// /// Supervision: Resume on exception — coordinator preserves state. /// ScriptExecutionActors are stopped on unhandled exceptions. /// protected override SupervisorStrategy SupervisorStrategy() { return new OneForOneStrategy( maxNrOfRetries: -1, withinTimeRange: TimeSpan.FromMinutes(1), decider: Decider.From(ex => { _logger.LogWarning(ex, "ScriptExecutionActor for {Script} on {Instance} failed, stopping", _scriptName, _instanceName); return Directive.Stop; })); } /// /// Handles CallScript ask from ScriptRuntimeContext or Instance Actor. /// Spawns a ScriptExecutionActor and forwards the sender for reply. /// private void HandleScriptCallRequest(ScriptCallRequest request) { if (_compiledScript == null) { Sender.Tell(new ScriptCallResult( request.CorrelationId, false, null, $"Script '{_scriptName}' is not compiled.")); return; } // Audit Log #23 (ParentExecutionId): carry any inbound-routed // ParentExecutionId through to the ScriptExecutionActor so the routed // script's ScriptRuntimeContext can record its spawner. Null for normal // (tag-change / timer) runs and nested Script.Call invocations. SpawnExecution( request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId, request.ParentExecutionId); } /// /// Handles attribute value changes — triggers script if configured for /// value-change, conditional, or expression. The attribute snapshot is /// updated for every change before any trigger logic runs. /// private void HandleAttributeValueChanged(AttributeValueChanged changed) { // Keep the snapshot current for every change, regardless of trigger type. _attributeSnapshot[changed.AttributeName] = changed.Value; if (_triggerConfig is ValueChangeTriggerConfig valueTrigger) { if (valueTrigger.AttributeName == changed.AttributeName) { TrySpawnExecution(null); } } else if (_triggerConfig is ConditionalTriggerConfig conditional) { if (conditional.AttributeName == changed.AttributeName) { var conditionMet = EvaluateCondition(conditional, changed.Value); if (conditional.Mode == TriggerMode.WhileTrue) { // Edge-detect against the prior truth value; the timer does // the repeated firing while the condition stays true. HandleWhileTrueTransition(conditionMet, _conditionState); _conditionState = conditionMet; } else if (conditionMet) { // OnTrue: fire on each matching change (existing behavior). TrySpawnExecution(null); } } } else if (_triggerConfig is ExpressionTriggerConfig) { EvaluateExpressionTrigger(); } } /// /// Evaluates the compiled trigger expression against the current attribute /// snapshot. In mode the script runs once /// per false→true transition; in mode it /// fires on the edge and the re-fire timer is started/stopped with the /// expression's truth value. A throwing or non-bool expression is treated as /// false and logged as a script error; the actor never crashes. /// private void EvaluateExpressionTrigger() { if (_compiledTriggerExpression == null) return; if (_triggerConfig is not ExpressionTriggerConfig exprConfig) return; bool result; try { var globals = new TriggerExpressionGlobals(_attributeSnapshot); // Bound evaluation with a short timeout. The CancellationToken // covers cooperative/async cases; a pathological CPU-bound // expression is not fully interruptible. Acceptable because // trigger expressions are authored by trusted Design-role users // and are compile-checked pre-deployment. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var state = _compiledTriggerExpression .RunAsync(globals, cancellationToken: cts.Token) .GetAwaiter().GetResult(); result = state.ReturnValue is bool b && b; } catch (Exception ex) { // OperationCanceledException (timeout) falls through here too, // and is correctly treated as false. LogExpressionError(ex); result = false; } if (exprConfig.Mode == TriggerMode.WhileTrue) { HandleWhileTrueTransition(result, _lastExpressionResult); } else if (result && !_lastExpressionResult) { TrySpawnExecution(null); } _lastExpressionResult = result; } /// /// Applies a WhileTrue trigger's condition-state transition: on the /// false→true edge, fire once and start the re-fire timer; on the /// true→false edge, stop the timer. While the state is unchanged, the /// already-running timer continues to drive re-firing. /// private void HandleWhileTrueTransition(bool nowTrue, bool wasTrue) { if (nowTrue && !wasTrue) { TrySpawnExecution(null); StartWhileTrueTimer(); } else if (!nowTrue && wasTrue) { StopWhileTrueTimer(); } } /// /// Starts the periodic WhileTrue re-fire timer. The cadence is the script's /// MinTimeBetweenRuns; with none configured the trigger cannot /// re-fire, so it degrades to the single edge fire and logs a warning. /// private void StartWhileTrueTimer() { if (_compiledScript == null) return; if (_minTimeBetweenRuns is not { } interval) { _logger.LogWarning( "ScriptActor {Script} on {Instance}: WhileTrue trigger has no MinTimeBetweenRuns — " + "firing once on the edge only, no re-fire timer.", _scriptName, _instanceName); return; } Timers.StartPeriodicTimer(WhileTrueTimerKey, WhileTrueTick.Instance, interval, interval); } /// Cancels the WhileTrue re-fire timer (a no-op if it is not running). private void StopWhileTrueTimer() => Timers.Cancel(WhileTrueTimerKey); /// /// Fires the script for a WhileTrue re-fire tick. The timer interval is /// itself the cadence, so this spawns directly — bypassing the /// MinTimeBetweenRuns skip-check that gates change-driven spawns (which /// could otherwise drop a tick to sub-millisecond timing jitter). /// private void FireWhileTrueTick() { if (_compiledScript == null) return; _lastExecutionTime = DateTimeOffset.UtcNow; SpawnExecution(null, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString()); } /// /// Records a trigger-expression evaluation failure to the site event log, /// mirroring how ScriptExecutionActor reports script errors. /// private void LogExpressionError(Exception ex) { _healthCollector?.IncrementScriptError(); var errorMsg = $"Trigger expression for script '{_scriptName}' on instance '{_instanceName}' failed: {ex.Message}"; _logger.LogError(ex, "Trigger expression evaluation failed: {Script} on {Instance}", _scriptName, _instanceName); _ = _serviceProvider?.GetService()?.LogEventAsync( "script", "Error", _instanceName, $"ScriptActor:{_scriptName}", errorMsg, ex.ToString()); } /// /// Attempts to spawn a script execution, respecting MinTimeBetweenRuns. /// private void TrySpawnExecution(IReadOnlyDictionary? parameters) { if (_compiledScript == null) return; if (_minTimeBetweenRuns.HasValue) { var elapsed = DateTimeOffset.UtcNow - _lastExecutionTime; if (elapsed < _minTimeBetweenRuns.Value) { _logger.LogDebug( "Script {Script} on {Instance}: skipping execution, min time between runs not elapsed ({Elapsed} < {Min})", _scriptName, _instanceName, elapsed, _minTimeBetweenRuns.Value); return; } } _lastExecutionTime = DateTimeOffset.UtcNow; SpawnExecution(parameters, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString()); } /// /// Spawns a new ScriptExecutionActor child for this invocation. /// Multiple concurrent executions are allowed. /// private void SpawnExecution( IReadOnlyDictionary? parameters, int callDepth, IActorRef replyTo, string correlationId, Guid? parentExecutionId = null) { var executionId = $"{_scriptName}-exec-{_executionCounter++}"; // SiteRuntime-009: the actor's mailbox stays on the default dispatcher, but the // script body itself runs on the dedicated ScriptExecutionScheduler (a bounded // set of dedicated threads), so blocking script I/O is contained there and // cannot starve the shared .NET thread pool. var props = Props.Create(() => new ScriptExecutionActor( _scriptName, _instanceName, _compiledScript!, parameters, callDepth, _instanceActor, _sharedScriptLibrary, _options, replyTo, correlationId, _logger, _scope, _healthCollector, _serviceProvider, // Audit Log #23 (ParentExecutionId): null for trigger-driven runs; // an inbound-API-routed call supplies the inbound request's id. parentExecutionId)); Context.ActorOf(props, executionId); } private void HandleExecutionCompleted(ScriptExecutionCompleted msg) { _logger.LogDebug( "Script {Script} execution completed on {Instance}: success={Success}", _scriptName, _instanceName, msg.Success); } private static bool EvaluateCondition(ConditionalTriggerConfig config, object? value) { if (value == null) return false; try { var numericValue = Convert.ToDouble(value); return config.Operator switch { ">" => numericValue > config.Threshold, ">=" => numericValue >= config.Threshold, "<" => numericValue < config.Threshold, "<=" => numericValue <= config.Threshold, "==" => Math.Abs(numericValue - config.Threshold) < 0.0001, "!=" => Math.Abs(numericValue - config.Threshold) >= 0.0001, _ => false }; } catch { return string.Equals(value.ToString(), config.Threshold.ToString(), StringComparison.Ordinal); } } private static ScriptTriggerConfig? ParseTriggerConfig(string? triggerType, string? triggerConfigJson) { if (string.IsNullOrEmpty(triggerType)) return null; return triggerType.ToLowerInvariant() switch { "interval" => ParseIntervalTrigger(triggerConfigJson), "valuechange" => ParseValueChangeTrigger(triggerConfigJson), "conditional" => ParseConditionalTrigger(triggerConfigJson), "expression" => ParseExpressionTrigger(triggerConfigJson), "call" => null, // No automatic trigger — invoked only via Instance.CallScript() _ => null }; } private static ExpressionTriggerConfig? ParseExpressionTrigger(string? json) { var expr = TriggerExpressionGlobals.ExtractExpression(json); if (expr == null) return null; // ExtractExpression already proved the JSON parses; read the mode too. var mode = TriggerMode.OnTrue; try { using var doc = JsonDocument.Parse(json!); mode = ParseTriggerMode(doc.RootElement); } catch (JsonException) { /* keep OnTrue */ } return new ExpressionTriggerConfig(expr, mode); } /// /// Reads the optional mode field (Conditional + Expression triggers). /// An absent or unrecognized value (case-insensitive) yields /// , so pre-WhileTrue configs are unchanged. /// private static TriggerMode ParseTriggerMode(JsonElement root) { var raw = root.TryGetProperty("mode", out var m) ? m.GetString() : null; return string.Equals(raw?.Trim(), "WhileTrue", StringComparison.OrdinalIgnoreCase) ? TriggerMode.WhileTrue : TriggerMode.OnTrue; } private static IntervalTriggerConfig? ParseIntervalTrigger(string? json) { if (string.IsNullOrEmpty(json)) return null; try { var doc = JsonDocument.Parse(json); var ms = doc.RootElement.GetProperty("intervalMs").GetInt64(); return new IntervalTriggerConfig(TimeSpan.FromMilliseconds(ms)); } catch { return null; } } private static ValueChangeTriggerConfig? ParseValueChangeTrigger(string? json) { if (string.IsNullOrEmpty(json)) return null; try { var doc = JsonDocument.Parse(json); var attr = doc.RootElement.GetProperty("attributeName").GetString()!; return new ValueChangeTriggerConfig(attr); } catch { return null; } } private static ConditionalTriggerConfig? ParseConditionalTrigger(string? json) { if (string.IsNullOrEmpty(json)) return null; try { var doc = JsonDocument.Parse(json); var attr = doc.RootElement.GetProperty("attributeName").GetString()!; var op = doc.RootElement.GetProperty("operator").GetString()!; var threshold = doc.RootElement.GetProperty("threshold").GetDouble(); return new ConditionalTriggerConfig( attr, op, threshold, ParseTriggerMode(doc.RootElement)); } catch { return null; } } // ── Internal messages ── internal sealed class IntervalTick { public static readonly IntervalTick Instance = new(); private IntervalTick() { } } internal sealed class WhileTrueTick { public static readonly WhileTrueTick Instance = new(); private WhileTrueTick() { } } internal record ScriptExecutionCompleted(string ScriptName, bool Success, string? Error); } // ── Trigger config types ── /// /// When a Conditional/Expression trigger fires. fires once /// as the condition becomes true; additionally re-fires /// on a timer (cadence = the script's MinTimeBetweenRuns) until it goes false. /// internal enum TriggerMode { OnTrue, WhileTrue } internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig; internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig; internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold, TriggerMode Mode) : ScriptTriggerConfig; internal record ExpressionTriggerConfig(string Expression, TriggerMode Mode) : ScriptTriggerConfig; internal abstract record ScriptTriggerConfig;