using Akka.Actor; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using System.Globalization; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; /// /// Script Actor — coordinator actor, child of Instance Actor. /// Holds compiled script delegate, manages trigger configuration, and launches script /// runs per invocation. Does not block on run completion. /// /// WP3.1: runs are launched directly via rather than /// through a short-lived ScriptExecutionActor child (which had no Receive /// handler, no PostStop, and whose IActorRef was never a message target — pure /// per-run actor-cell overhead). Concurrent runs are bounded by /// ; over the cap the NEWEST /// trigger is shed. Trigger-expression evaluation no longer runs on the blocking script pool /// at all — see . /// /// 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; /// /// Script-execution scheduler seam (#18): the process-wide /// when null, or an injected instance so /// this actor's launched script bodies run on a caller-owned pool instead of the /// shared one. Resolved lazily at each use so the null (host) path stays /// byte-for-byte identical to the previous static call. /// /// WP3.1: trigger-expression evaluation no longer uses this scheduler — see /// . /// private readonly ScriptExecutionScheduler? _scheduler; /// /// WP3.1 (finding #4): the concurrency gate for trigger-expression evaluation, or null /// for the process-wide . Evaluations run as plain /// async work on the shared .NET thread pool behind this gate, NOT on /// — that is what stops an Expression trigger from queueing /// behind blocking script bodies. /// private readonly TriggerEvalGate? _evalGate; /// /// WP3.1: runs launched but not yet completed (queued or executing) for this script. /// Incremented at launch, decremented on , which /// every terminal path emits — including the launch-path catch, so the counter cannot /// leak. Touched only on the actor thread. /// private int _runsInFlight; /// /// WP3.1: when the last shed Warning site event was emitted for this script. Sheds are /// ALWAYS counted on the health collector; the site event is rate-limited to one per /// script per minute so a hot trigger against a saturated cap cannot flood /// site_events. /// private DateTimeOffset _lastShedEventUtc = DateTimeOffset.MinValue; /// Rate limit for the shed site event (the counter still counts every shed). private static readonly TimeSpan ShedEventInterval = TimeSpan.FromMinutes(1); private Script? _compiledScript; private ScriptTriggerConfig? _triggerConfig; private TimeSpan? _minTimeBetweenRuns; /// /// The per-script execution timeout in seconds, or null to use the /// global default. Threaded down to each launched run via /// , which applies perScript ?? global /// (and treats ≤ 0 as "use global"). /// private readonly int? _executionTimeoutSeconds; 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(); // Coalescing guard for off-dispatcher expression evaluation (P1): at most one // evaluation runs on the script scheduler at a time; a change arriving while // one is in flight sets _evalPending so exactly one more runs afterwards // against the latest snapshot. Both flags are touched only on the actor thread. private bool _evalInFlight; private bool _evalPending; // 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"; /// /// 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; } /// Gets or sets the Akka timer scheduler used to schedule interval and WhileTrue triggers. public ITimerScheduler Timers { get; set; } = null!; /// /// Initializes the ScriptActor with its compiled script, trigger configuration, and supporting services. /// /// Name of the script this actor manages. /// Unique name of the owning instance. /// Reference to the parent Instance Actor. /// Pre-compiled Roslyn script delegate, or null when compilation failed. /// Resolved script metadata including trigger type and configuration. /// Library of compiled shared scripts available for inline execution. /// Site runtime configuration options. /// Logger for diagnostics. /// Pre-compiled boolean trigger expression, or null when not an expression trigger. /// Initial attribute snapshot used to seed expression trigger evaluation state. /// Optional health metrics collector. /// Optional DI service provider for script execution context services. /// Optional script-execution scheduler override (#18); null uses the process-wide shared scheduler. /// Optional trigger-expression concurrency gate override (WP3.1); null uses the process-wide shared gate. 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, ScriptExecutionScheduler? scheduler = null, TriggerEvalGate? evalGate = null) { _scriptName = scriptName; _instanceName = instanceName; _instanceActor = instanceActor; _compiledScript = compiledScript; _sharedScriptLibrary = sharedScriptLibrary; _options = options; _logger = logger; _healthCollector = healthCollector; _serviceProvider = serviceProvider; _scheduler = scheduler; _evalGate = evalGate; _minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns; _executionTimeoutSeconds = scriptConfig.ExecutionTimeoutSeconds; _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); // Handle the off-dispatcher trigger-expression evaluation result (P1). Receive(HandleExpressionEvalResult); // Handle a faulted off-dispatcher evaluation task (N2) — clears in-flight // instead of stranding the trigger with an unhandled Status.Failure. Receive(HandleExpressionEvalFailed); } /// 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); } // WP3.1: the Stop-on-failure SupervisorStrategy override is gone with the per-run // ScriptExecutionActor child it supervised. This actor has no children left; a // launch-path throw is caught in SpawnExecution (which replies to the Ask caller and // releases the in-flight slot) so it can never escalate to InstanceActor, which // continues to supervise this actor with Resume exactly as before. /// /// Handles CallScript ask from ScriptRuntimeContext or Instance Actor. /// Launches a run and captures the sender for the eventual reply. /// private void HandleScriptCallRequest(ScriptCallRequest request) { if (_compiledScript == null) { Sender.Tell(new ScriptCallResult( request.CorrelationId, false, null, $"Script '{_scriptName}' is not compiled.")); return; } // (ParentExecutionId): carry any inbound-routed ParentExecutionId // through to the launched run 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) { StartExpressionEvaluation(); } } /// /// Starts an off-dispatcher evaluation of the compiled trigger expression (P1). /// The expression previously ran synchronously on the actor's dispatcher thread /// via RunAsync(...).GetAwaiter().GetResult(), blocking the dispatcher on /// every attribute change. It runs off-thread against a point-in-time snapshot; /// the boolean result is piped back to this actor as an /// so all edge state is applied on the actor /// thread. Bursts coalesce: at most one evaluation is in flight, at most one /// pending, so a storm of changes collapses to the latest snapshot without /// unbounded task fan-out. /// /// WP3.1 (finding #4) changed WHERE it runs and WHEN its clock starts. It used /// to run on the dedicated — the same bounded /// pool as blocking script bodies — so N blocked scripts stalled every Expression /// trigger on the node indefinitely. It now runs as plain async work on the shared /// .NET thread pool behind : trigger expressions are /// non-blocking by construction, so they belong there. And the deadline /// is armed HERE, on the actor thread, before /// the work is queued — so gate-wait time burns the same budget and a saturated gate /// yields a timely false instead of an unbounded stall. /// private void StartExpressionEvaluation() { if (_compiledTriggerExpression == null || _triggerConfig is not ExpressionTriggerConfig) return; if (_evalInFlight) { _evalPending = true; return; } // coalesce bursts: one in flight, one pending _evalInFlight = true; var snapshot = new Dictionary(_attributeSnapshot); // point-in-time copy, actor thread var expression = _compiledTriggerExpression; var self = Self; var gate = _evalGate ?? TriggerEvalGate.Shared(_options); // Clock starts AT ENQUEUE, not at dequeue. var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_options.TriggerEvalTimeoutSeconds)); Task.Run(async () => { try { await gate.WaitAsync(cts.Token).ConfigureAwait(false); try { // 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 // compile-checked pre-deploy. var state = await expression.RunAsync( new TriggerExpressionGlobals(snapshot), cancellationToken: cts.Token); return state.ReturnValue is bool b && b; } finally { gate.Release(); } } catch (Exception ex) { // OperationCanceledException (timeout, INCLUDING a timeout that fired while // still waiting on the gate) falls through here too and is treated as false. // LogExpressionError touches only thread-safe members (_healthCollector // Interlocked, _logger, DI-resolved singleton logger) so it is safe off the // actor thread. LogExpressionError(ex); return false; } finally { cts.Dispose(); } }).PipeTo(self, success: r => new ExpressionEvalResult(r), failure: ex => new ExpressionEvalFailed(ex)); } /// /// Applies an off-dispatcher trigger-expression result on the actor thread: /// runs the OnTrue / WhileTrue edge logic against , /// clears the in-flight flag, and drains a coalesced pending evaluation (against /// the now-current snapshot) if one was requested while this one ran. /// private void HandleExpressionEvalResult(ExpressionEvalResult msg) { _evalInFlight = false; if (_triggerConfig is ExpressionTriggerConfig exprConfig) { if (exprConfig.Mode == TriggerMode.WhileTrue) HandleWhileTrueTransition(msg.Result, _lastExpressionResult); else if (msg.Result && !_lastExpressionResult) TrySpawnExecution(null); _lastExpressionResult = msg.Result; } if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); } } /// /// Applies a faulted off-dispatcher evaluation task (N2): logs the fault via the /// existing error path (which also increments the health counter), then reuses the /// false-result path — clearing in-flight, applying the false edge, and draining any /// pending evaluation — so a transient scheduler fault cannot permanently park the trigger. /// private void HandleExpressionEvalFailed(ExpressionEvalFailed msg) { LogExpressionError(msg.Cause); HandleExpressionEvalResult(new ExpressionEvalResult(false)); } /// /// 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 a script run reports its own 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()); } /// /// Launches a run of this script. Multiple concurrent runs are allowed, up to /// ; beyond that the newest /// trigger is shed (see ). /// /// WP3.1: the run is launched directly on the script-execution scheduler via /// — the actor's mailbox stays on the default dispatcher, /// but the script body runs on the bounded set of dedicated threads, so blocking script /// I/O is contained there and cannot starve the shared .NET thread pool. No per-run child /// actor is created. /// /// The cap governs NEW work only ( 0 — trigger-driven /// runs and depth-0 Ask calls). A nested CallScript is exempt: the calling run is /// itself still counted in while it awaits its callee (the /// slot is released only by , sent after the body /// returns), and a script calling ITSELF routes back to this same actor — so counting the /// nested launch against the cap would refuse legitimate self-recursion at depth /// with a misleading "shed", /// making — the limit that actually /// owns this path, enforced in ScriptRuntimeContext.CallScript — unreachable. /// Nested depth is bounded by MaxScriptCallDepth instead, which is what the cap would /// otherwise be doing badly. /// private void SpawnExecution( IReadOnlyDictionary? parameters, int callDepth, IActorRef replyTo, string correlationId, Guid? parentExecutionId = null) { if (callDepth == 0 && _runsInFlight >= _options.MaxConcurrentRunsPerScript) { ShedRun(replyTo, correlationId); return; } var runId = _executionCounter++; // Incremented BEFORE the launch so the launch-path catch below (which always emits a // ScriptExecutionCompleted) balances it on every path — the counter cannot leak. _runsInFlight++; try { ScriptRunLauncher.LaunchScript( _scriptName, _instanceName, _compiledScript!, parameters, callDepth, _instanceActor, _sharedScriptLibrary, _options, replyTo, correlationId, // Completion target: this actor. Identical delivery to the old // Context.Parent.Tell from the execution actor, and it doubles as the // in-flight release. Self, // Notification Outbox: the site communication actor Notify.Status queries // central through. Resolved here, on the actor thread, and handed to the // launcher (which has no ActorContext of its own). Context.System.ActorSelection("/user/site-communication"), _logger, _scope, runId, _healthCollector, _serviceProvider, // (ParentExecutionId): null for trigger-driven runs; // an inbound-API-routed call supplies the inbound request's id. parentExecutionId, // Per-script timeout override (null = use global). _executionTimeoutSeconds, // Scheduler seam (#18): thread this actor's scheduler override down so // launched script bodies share the same pool (null = process-wide shared). _scheduler); } catch (Exception ex) { // WP3.1 supervision parity: the only failure the removed per-run child could // surface was a constructor throw (e.g. queueing onto a disposed scheduler), // which the old OneForOneStrategy logged and stopped — leaving an Ask caller to // hang with no reply. Same log shape, same "coordinator unaffected" outcome, plus // a reply so the caller fails fast instead of waiting out its Ask timeout. var errorMsg = $"Script '{_scriptName}' on instance '{_instanceName}' could not be launched: {ex.Message}"; _logger.LogWarning(ex, "Script execution launch for {Script} on {Instance} failed, stopping", _scriptName, _instanceName); if (!replyTo.IsNobody()) replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg)); Self.Tell(new ScriptExecutionCompleted(_scriptName, false, errorMsg)); } } /// /// WP3.1 shed policy: refuses the incoming run because /// runs are already in flight. /// The four already queued/running are kept — they are closest to their own deadlines and /// already charged against them — so nothing is ever reordered. A trigger-driven run is /// simply not launched; a depth-0 Ask (an inbound-API route, or a CallScript from /// an unrelated script's run) gets an explicit error so the caller fails fast rather than /// hanging to its Ask timeout. Nested (callDepth > 0) launches never reach here — /// see . /// private void ShedRun(IActorRef replyTo, string correlationId) { _healthCollector?.IncrementScriptRunShed(); var message = $"Script '{_scriptName}' on instance '{_instanceName}': run shed — " + $"{_runsInFlight} runs already in flight (cap {_options.MaxConcurrentRunsPerScript})."; _logger.LogWarning("{Message}", message); // Rate-limited to one event per script per minute: the counter above still counts // every shed, but a hot trigger against a saturated cap must not flood site_events. var now = DateTimeOffset.UtcNow; if (now - _lastShedEventUtc >= ShedEventInterval) { _lastShedEventUtc = now; _ = _serviceProvider?.GetService()?.LogEventAsync( "script", "Warning", _instanceName, $"ScriptActor:{_scriptName}", message); } if (!replyTo.IsNobody()) { replyTo.Tell(new ScriptCallResult( correlationId, false, null, $"shed: {_runsInFlight} runs already in flight")); } } private void HandleExecutionCompleted(ScriptExecutionCompleted msg) { // WP3.1: release the in-flight slot. Every terminal path emits exactly one of these // (success / timeout / failure / launch failure), so the counter tracks reality. if (_runsInFlight > 0) _runsInFlight--; _logger.LogDebug( "Script {Script} execution completed on {Instance}: success={Success}", _scriptName, _instanceName, msg.Success); } /// /// WP3.1: runs launched but not yet completed. Exposed for regression coverage of the /// shed cap — the counter is otherwise invisible from outside the actor. /// internal int RunsInFlight => _runsInFlight; // internal (not private) so the culture-invariance of the non-numeric fallback // can be unit-tested directly on the test thread — the live path evaluates on a // dispatcher thread whose CurrentCulture the test cannot deterministically set. /// /// Evaluates a conditional trigger's operator/threshold against an attribute value, /// converting the value to a culture-invariant double before comparing. /// /// The trigger configuration specifying the operator and threshold. /// The attribute value to evaluate, or null. /// if the value satisfies the configured condition; otherwise . internal static bool EvaluateCondition(ConditionalTriggerConfig config, object? value) { if (value == null) return false; try { // Use InvariantCulture so a string attribute value like "1.5" parses // consistently regardless of the host locale. For // purely-numeric inputs the culture argument is a no-op, but it is // safe and future-proof for string-typed attribute values arriving // from scripts or the data connection layer. var numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture); 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 { // Render the threshold with InvariantCulture to match the invariant numeric // parse two lines up — otherwise a de-DE host renders 1.5 as "1,5" and the // string fallback fires spuriously on locale-formatted values (C6). return string.Equals(value.ToString(), config.Threshold.ToString(CultureInfo.InvariantCulture), 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) { // Share the monitored-attribute parser with InstanceActor's routing map (P2) so both // agree on which attribute this trigger reacts to. return TriggerRouting.TryReadAttributeName(json, out var attr) ? new ValueChangeTriggerConfig(attr) : null; } private static ConditionalTriggerConfig? ParseConditionalTrigger(string? json) { if (string.IsNullOrEmpty(json)) return null; // Attribute name comes from the shared parser (P2); operator/threshold/mode stay local. if (!TriggerRouting.TryReadAttributeName(json, out var attr)) return null; try { var doc = JsonDocument.Parse(json); 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); /// /// Piped back to self from an off-dispatcher trigger-expression evaluation (P1); /// carries the boolean truth value so the OnTrue/WhileTrue edge logic runs on /// the actor thread. /// private sealed record ExpressionEvalResult(bool Result); /// /// Piped back to self when the off-dispatcher evaluation TASK itself faults /// (e.g. ObjectDisposedException from a disposed ScriptExecutionScheduler /// during shutdown) — the inner body's catch never sees that. Without this /// mapping the actor receives an unhandled Status.Failure and _evalInFlight /// stays true forever, permanently parking the expression trigger (N2). /// internal sealed record ExpressionEvalFailed(Exception Cause); } // ── 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;