feat(triggers): runtime expression trigger evaluation for scripts and alarms
This commit is contained in:
@@ -50,6 +50,12 @@ public class AlarmActor : ReceiveActor
|
|||||||
private readonly string? _onTriggerScriptName;
|
private readonly string? _onTriggerScriptName;
|
||||||
private readonly Script<object?>? _onTriggerCompiledScript;
|
private readonly Script<object?>? _onTriggerCompiledScript;
|
||||||
|
|
||||||
|
// Expression trigger: compiled expression + the attribute snapshot it
|
||||||
|
// evaluates against. The compiled expression is also held on the
|
||||||
|
// ExpressionEvalConfig; this field caches it for the hot path.
|
||||||
|
private readonly Script<object?>? _compiledTriggerExpression;
|
||||||
|
private readonly Dictionary<string, object?> _attributeSnapshot = new();
|
||||||
|
|
||||||
// Rate of change tracking
|
// Rate of change tracking
|
||||||
private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new();
|
private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new();
|
||||||
private readonly TimeSpan _rateOfChangeWindowDuration;
|
private readonly TimeSpan _rateOfChangeWindowDuration;
|
||||||
@@ -65,6 +71,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
SharedScriptLibrary sharedScriptLibrary,
|
SharedScriptLibrary sharedScriptLibrary,
|
||||||
SiteRuntimeOptions options,
|
SiteRuntimeOptions options,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
|
Script<object?>? compiledTriggerExpression = null,
|
||||||
ISiteHealthCollector? healthCollector = null)
|
ISiteHealthCollector? healthCollector = null)
|
||||||
{
|
{
|
||||||
_alarmName = alarmName;
|
_alarmName = alarmName;
|
||||||
@@ -77,6 +84,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
_priority = alarmConfig.PriorityLevel;
|
_priority = alarmConfig.PriorityLevel;
|
||||||
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
|
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
|
||||||
_onTriggerCompiledScript = onTriggerCompiledScript;
|
_onTriggerCompiledScript = onTriggerCompiledScript;
|
||||||
|
_compiledTriggerExpression = compiledTriggerExpression;
|
||||||
|
|
||||||
// Parse trigger type
|
// Parse trigger type
|
||||||
_triggerType = Enum.TryParse<AlarmTriggerType>(alarmConfig.TriggerType, true, out var tt)
|
_triggerType = Enum.TryParse<AlarmTriggerType>(alarmConfig.TriggerType, true, out var tt)
|
||||||
@@ -125,10 +133,19 @@ public class AlarmActor : ReceiveActor
|
|||||||
/// actor continues (does not crash).
|
/// actor continues (does not crash).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleAttributeValueChanged(AttributeValueChanged changed)
|
private void HandleAttributeValueChanged(AttributeValueChanged changed)
|
||||||
|
{
|
||||||
|
// Expression triggers evaluate against a snapshot of every attribute,
|
||||||
|
// not a single monitored attribute. Keep the snapshot current for every
|
||||||
|
// change before the IsMonitoredAttribute gate (which does not apply).
|
||||||
|
if (_triggerType == AlarmTriggerType.Expression)
|
||||||
|
{
|
||||||
|
_attributeSnapshot[changed.AttributeName] = changed.Value;
|
||||||
|
}
|
||||||
|
else if (!IsMonitoredAttribute(changed.AttributeName))
|
||||||
{
|
{
|
||||||
// Only evaluate if this change is for an attribute we're monitoring
|
// Only evaluate if this change is for an attribute we're monitoring
|
||||||
if (!IsMonitoredAttribute(changed.AttributeName))
|
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -143,6 +160,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value),
|
AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value),
|
||||||
AlarmTriggerType.RangeViolation => EvaluateRangeViolation(changed.Value),
|
AlarmTriggerType.RangeViolation => EvaluateRangeViolation(changed.Value),
|
||||||
AlarmTriggerType.RateOfChange => EvaluateRateOfChange(changed.Value, changed.Timestamp),
|
AlarmTriggerType.RateOfChange => EvaluateRateOfChange(changed.Value, changed.Timestamp),
|
||||||
|
AlarmTriggerType.Expression => EvaluateExpression(),
|
||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -337,6 +355,22 @@ public class AlarmActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates the compiled trigger expression against the current attribute
|
||||||
|
/// snapshot, returning the resulting bool. This bool feeds the existing
|
||||||
|
/// binary Normal↔Active state path — the alarm is active while true. A
|
||||||
|
/// throwing or non-bool expression is treated as false; the exception
|
||||||
|
/// propagates to the caller's catch, which logs it and continues.
|
||||||
|
/// </summary>
|
||||||
|
private bool EvaluateExpression()
|
||||||
|
{
|
||||||
|
if (_compiledTriggerExpression == null) return false;
|
||||||
|
|
||||||
|
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
|
||||||
|
var state = _compiledTriggerExpression.RunAsync(globals).GetAwaiter().GetResult();
|
||||||
|
return state.ReturnValue is bool b && b;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// HiLo level evaluator: returns the most-severe matching band for the
|
/// HiLo level evaluator: returns the most-severe matching band for the
|
||||||
/// given value. Severity order checked from highest to lowest so that a
|
/// given value. Severity order checked from highest to lowest so that a
|
||||||
@@ -473,6 +507,14 @@ public class AlarmActor : ReceiveActor
|
|||||||
HiMessage: TryReadString(root, "hiMessage"),
|
HiMessage: TryReadString(root, "hiMessage"),
|
||||||
HiHiMessage: TryReadString(root, "hiHiMessage")),
|
HiHiMessage: TryReadString(root, "hiHiMessage")),
|
||||||
|
|
||||||
|
// Expression triggers have no single monitored attribute; they
|
||||||
|
// evaluate the compiled expression (passed into the actor) over
|
||||||
|
// the full attribute snapshot. MonitoredAttributeName is unused.
|
||||||
|
AlarmTriggerType.Expression => new ExpressionEvalConfig(
|
||||||
|
"",
|
||||||
|
TryReadString(root, "expression") ?? "",
|
||||||
|
_compiledTriggerExpression),
|
||||||
|
|
||||||
_ => new ValueMatchEvalConfig(attr, null)
|
_ => new ValueMatchEvalConfig(attr, null)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -535,6 +577,17 @@ internal record RateOfChangeEvalConfig(
|
|||||||
TimeSpan WindowDuration,
|
TimeSpan WindowDuration,
|
||||||
RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName);
|
RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expression evaluation config: a read-only boolean C# expression evaluated
|
||||||
|
/// over the full attribute snapshot. Has no single monitored attribute
|
||||||
|
/// (<see cref="AlarmEvalConfig.MonitoredAttributeName"/> is empty); the
|
||||||
|
/// compiled expression is passed into the actor and cached here.
|
||||||
|
/// </summary>
|
||||||
|
internal record ExpressionEvalConfig(
|
||||||
|
string MonitoredAttributeName,
|
||||||
|
string Expression,
|
||||||
|
Script<object?>? CompiledExpression) : AlarmEvalConfig(MonitoredAttributeName);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// HiLo evaluation config: any subset of the four setpoints may be set; null
|
/// HiLo evaluation config: any subset of the four setpoints may be set; null
|
||||||
/// means "don't evaluate that band". Per-setpoint priorities override the
|
/// means "don't evaluate that band". Per-setpoint priorities override the
|
||||||
|
|||||||
@@ -515,6 +515,10 @@ public class InstanceActor : ReceiveActor
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile the trigger expression for Expression-triggered scripts.
|
||||||
|
var triggerExpression = CompileTriggerExpression(
|
||||||
|
script.TriggerType, script.TriggerConfiguration, $"script-trigger-{script.CanonicalName}");
|
||||||
|
|
||||||
var props = Props.Create(() => new ScriptActor(
|
var props = Props.Create(() => new ScriptActor(
|
||||||
script.CanonicalName,
|
script.CanonicalName,
|
||||||
_instanceUniqueName,
|
_instanceUniqueName,
|
||||||
@@ -524,6 +528,7 @@ public class InstanceActor : ReceiveActor
|
|||||||
_sharedScriptLibrary,
|
_sharedScriptLibrary,
|
||||||
_options,
|
_options,
|
||||||
_logger,
|
_logger,
|
||||||
|
triggerExpression,
|
||||||
_healthCollector,
|
_healthCollector,
|
||||||
_serviceProvider));
|
_serviceProvider));
|
||||||
|
|
||||||
@@ -559,6 +564,10 @@ public class InstanceActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile the trigger expression for Expression-triggered alarms.
|
||||||
|
var triggerExpression = CompileTriggerExpression(
|
||||||
|
alarm.TriggerType, alarm.TriggerConfiguration, $"alarm-trigger-expr-{alarm.CanonicalName}");
|
||||||
|
|
||||||
var props = Props.Create(() => new AlarmActor(
|
var props = Props.Create(() => new AlarmActor(
|
||||||
alarm.CanonicalName,
|
alarm.CanonicalName,
|
||||||
_instanceUniqueName,
|
_instanceUniqueName,
|
||||||
@@ -568,6 +577,7 @@ public class InstanceActor : ReceiveActor
|
|||||||
_sharedScriptLibrary,
|
_sharedScriptLibrary,
|
||||||
_options,
|
_options,
|
||||||
_logger,
|
_logger,
|
||||||
|
triggerExpression,
|
||||||
_healthCollector));
|
_healthCollector));
|
||||||
|
|
||||||
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
|
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
|
||||||
@@ -581,6 +591,47 @@ public class InstanceActor : ReceiveActor
|
|||||||
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count);
|
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles the boolean trigger expression for an Expression-triggered
|
||||||
|
/// script or alarm. Returns null for non-Expression triggers, a blank
|
||||||
|
/// expression, or a compilation failure (logged) — in which case the
|
||||||
|
/// trigger is inert and the actor still starts.
|
||||||
|
/// </summary>
|
||||||
|
private Microsoft.CodeAnalysis.Scripting.Script<object?>? CompileTriggerExpression(
|
||||||
|
string? triggerType, string? triggerConfigJson, string compileName)
|
||||||
|
{
|
||||||
|
if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return null;
|
||||||
|
if (string.IsNullOrEmpty(triggerConfigJson))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string? expression;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = JsonSerializer.Deserialize<JsonElement>(triggerConfigJson);
|
||||||
|
expression = doc.TryGetProperty("expression", out var e) ? e.GetString() : null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Failed to read trigger expression config for {Name} on {Instance}",
|
||||||
|
compileName, _instanceUniqueName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(expression))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var result = _compilationService.CompileTriggerExpression(compileName, expression);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
return result.CompiledScript;
|
||||||
|
|
||||||
|
_logger.LogError(
|
||||||
|
"Trigger expression for {Name} on {Instance} failed to compile: {Errors}",
|
||||||
|
compileName, _instanceUniqueName, string.Join("; ", result.Errors));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read-only access to current attribute count (for testing/diagnostics).
|
/// Read-only access to current attribute count (for testing/diagnostics).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.CodeAnalysis.Scripting;
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||||
using ScadaLink.Commons.Messages.Streaming;
|
using ScadaLink.Commons.Messages.Streaming;
|
||||||
using ScadaLink.Commons.Types.Flattening;
|
using ScadaLink.Commons.Types.Flattening;
|
||||||
using ScadaLink.HealthMonitoring;
|
using ScadaLink.HealthMonitoring;
|
||||||
|
using ScadaLink.SiteEventLogging;
|
||||||
using ScadaLink.SiteRuntime.Scripts;
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -40,6 +42,12 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
private int _executionCounter;
|
private int _executionCounter;
|
||||||
private readonly Commons.Types.Scripts.ScriptScope _scope;
|
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<object?>? _compiledTriggerExpression;
|
||||||
|
private bool _lastExpressionResult;
|
||||||
|
private readonly Dictionary<string, object?> _attributeSnapshot = new();
|
||||||
|
|
||||||
public ITimerScheduler Timers { get; set; } = null!;
|
public ITimerScheduler Timers { get; set; } = null!;
|
||||||
|
|
||||||
public ScriptActor(
|
public ScriptActor(
|
||||||
@@ -51,6 +59,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
SharedScriptLibrary sharedScriptLibrary,
|
SharedScriptLibrary sharedScriptLibrary,
|
||||||
SiteRuntimeOptions options,
|
SiteRuntimeOptions options,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
|
Script<object?>? compiledTriggerExpression = null,
|
||||||
ISiteHealthCollector? healthCollector = null,
|
ISiteHealthCollector? healthCollector = null,
|
||||||
IServiceProvider? serviceProvider = null)
|
IServiceProvider? serviceProvider = null)
|
||||||
{
|
{
|
||||||
@@ -65,6 +74,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
|
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
|
||||||
_scope = scriptConfig.Scope;
|
_scope = scriptConfig.Scope;
|
||||||
|
_compiledTriggerExpression = compiledTriggerExpression;
|
||||||
|
|
||||||
// Parse trigger configuration
|
// Parse trigger configuration
|
||||||
_triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration);
|
_triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration);
|
||||||
@@ -143,10 +153,15 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles attribute value changes — triggers script if configured for value-change or conditional.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleAttributeValueChanged(AttributeValueChanged changed)
|
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 (_triggerConfig is ValueChangeTriggerConfig valueTrigger)
|
||||||
{
|
{
|
||||||
if (valueTrigger.AttributeName == changed.AttributeName)
|
if (valueTrigger.AttributeName == changed.AttributeName)
|
||||||
@@ -165,6 +180,55 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (_triggerConfig is ExpressionTriggerConfig)
|
||||||
|
{
|
||||||
|
EvaluateExpressionTrigger();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates the compiled trigger expression against the current attribute
|
||||||
|
/// snapshot and runs the script edge-triggered — once per false→true
|
||||||
|
/// transition. A throwing or non-bool expression is treated as false and
|
||||||
|
/// logged as a script error; the actor never crashes.
|
||||||
|
/// </summary>
|
||||||
|
private void EvaluateExpressionTrigger()
|
||||||
|
{
|
||||||
|
if (_compiledTriggerExpression == null) return;
|
||||||
|
|
||||||
|
bool result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
|
||||||
|
var state = _compiledTriggerExpression.RunAsync(globals).GetAwaiter().GetResult();
|
||||||
|
result = state.ReturnValue is bool b && b;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogExpressionError(ex);
|
||||||
|
result = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result && !_lastExpressionResult)
|
||||||
|
{
|
||||||
|
TrySpawnExecution(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastExpressionResult = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a trigger-expression evaluation failure to the site event log,
|
||||||
|
/// mirroring how ScriptExecutionActor reports script errors.
|
||||||
|
/// </summary>
|
||||||
|
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<ISiteEventLogger>()?.LogEventAsync(
|
||||||
|
"script", "Error", _instanceName, $"ScriptActor:{_scriptName}", errorMsg, ex.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -264,11 +328,26 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
"interval" => ParseIntervalTrigger(triggerConfigJson),
|
"interval" => ParseIntervalTrigger(triggerConfigJson),
|
||||||
"valuechange" => ParseValueChangeTrigger(triggerConfigJson),
|
"valuechange" => ParseValueChangeTrigger(triggerConfigJson),
|
||||||
"conditional" => ParseConditionalTrigger(triggerConfigJson),
|
"conditional" => ParseConditionalTrigger(triggerConfigJson),
|
||||||
|
"expression" => ParseExpressionTrigger(triggerConfigJson),
|
||||||
"call" => null, // No automatic trigger — invoked only via Instance.CallScript()
|
"call" => null, // No automatic trigger — invoked only via Instance.CallScript()
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ExpressionTriggerConfig? ParseExpressionTrigger(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(json)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = JsonDocument.Parse(json);
|
||||||
|
var expr = doc.RootElement.TryGetProperty("expression", out var e)
|
||||||
|
? e.GetString()
|
||||||
|
: null;
|
||||||
|
return string.IsNullOrWhiteSpace(expr) ? null : new ExpressionTriggerConfig(expr);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
private static IntervalTriggerConfig? ParseIntervalTrigger(string? json)
|
private static IntervalTriggerConfig? ParseIntervalTrigger(string? json)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(json)) return null;
|
if (string.IsNullOrEmpty(json)) return null;
|
||||||
@@ -323,4 +402,5 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig;
|
internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig;
|
||||||
internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig;
|
internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig;
|
||||||
internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold) : ScriptTriggerConfig;
|
internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold) : ScriptTriggerConfig;
|
||||||
|
internal record ExpressionTriggerConfig(string Expression) : ScriptTriggerConfig;
|
||||||
internal abstract record ScriptTriggerConfig;
|
internal abstract record ScriptTriggerConfig;
|
||||||
|
|||||||
@@ -88,24 +88,10 @@ public class ScriptCompilationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
|
/// Shared Roslyn scripting options (references + imports) used by both full
|
||||||
/// and parameters dictionary, and returns an object? result.
|
/// script compilation and trigger-expression compilation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ScriptCompilationResult Compile(string scriptName, string code)
|
private static ScriptOptions BuildScriptOptions() => ScriptOptions.Default
|
||||||
{
|
|
||||||
// Validate trust model
|
|
||||||
var violations = ValidateTrustModel(code);
|
|
||||||
if (violations.Count > 0)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Script {Script} failed trust validation: {Violations}",
|
|
||||||
scriptName, string.Join("; ", violations));
|
|
||||||
return ScriptCompilationResult.Failed(violations);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var scriptOptions = ScriptOptions.Default
|
|
||||||
.WithReferences(
|
.WithReferences(
|
||||||
typeof(object).Assembly,
|
typeof(object).Assembly,
|
||||||
typeof(Enumerable).Assembly,
|
typeof(Enumerable).Assembly,
|
||||||
@@ -118,10 +104,45 @@ public class ScriptCompilationService
|
|||||||
"System.Linq",
|
"System.Linq",
|
||||||
"System.Threading.Tasks");
|
"System.Threading.Tasks");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
|
||||||
|
/// and parameters dictionary, and returns an object? result.
|
||||||
|
/// </summary>
|
||||||
|
public ScriptCompilationResult Compile(string scriptName, string code)
|
||||||
|
=> CompileCore(scriptName, code, typeof(ScriptGlobals));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles a bare C# boolean trigger expression against the restricted
|
||||||
|
/// read-only <see cref="TriggerExpressionGlobals"/>. The expression is a
|
||||||
|
/// trailing expression (no <c>return</c>); Roslyn scripting yields its
|
||||||
|
/// value, which the caller coerces to <c>bool</c>. Reuses the same script
|
||||||
|
/// options and forbidden-API trust validation as <see cref="Compile"/>.
|
||||||
|
/// </summary>
|
||||||
|
public ScriptCompilationResult CompileTriggerExpression(string name, string expression)
|
||||||
|
=> CompileCore(name, expression, typeof(TriggerExpressionGlobals));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared compilation path: validates the trust model, builds the script
|
||||||
|
/// against the given globals type, and returns the compiled result.
|
||||||
|
/// </summary>
|
||||||
|
private ScriptCompilationResult CompileCore(string name, string code, Type globalsType)
|
||||||
|
{
|
||||||
|
// Validate trust model
|
||||||
|
var violations = ValidateTrustModel(code);
|
||||||
|
if (violations.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Script {Script} failed trust validation: {Violations}",
|
||||||
|
name, string.Join("; ", violations));
|
||||||
|
return ScriptCompilationResult.Failed(violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
var script = CSharpScript.Create<object?>(
|
var script = CSharpScript.Create<object?>(
|
||||||
code,
|
code,
|
||||||
scriptOptions,
|
BuildScriptOptions(),
|
||||||
globalsType: typeof(ScriptGlobals));
|
globalsType: globalsType);
|
||||||
|
|
||||||
var diagnostics = script.Compile();
|
var diagnostics = script.Compile();
|
||||||
var errors = diagnostics
|
var errors = diagnostics
|
||||||
@@ -133,16 +154,16 @@ public class ScriptCompilationService
|
|||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Script {Script} compilation failed: {Errors}",
|
"Script {Script} compilation failed: {Errors}",
|
||||||
scriptName, string.Join("; ", errors));
|
name, string.Join("; ", errors));
|
||||||
return ScriptCompilationResult.Failed(errors);
|
return ScriptCompilationResult.Failed(errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("Script {Script} compiled successfully", scriptName);
|
_logger.LogDebug("Script {Script} compiled successfully", name);
|
||||||
return ScriptCompilationResult.Succeeded(script);
|
return ScriptCompilationResult.Succeeded(script);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Unexpected error compiling script {Script}", scriptName);
|
_logger.LogError(ex, "Unexpected error compiling script {Script}", name);
|
||||||
return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]);
|
return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
namespace ScadaLink.SiteRuntime.Scripts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only globals a trigger expression is compiled against. Exposes only
|
||||||
|
/// attribute reads, backed by an in-memory snapshot — no I/O, no actor Ask,
|
||||||
|
/// no side-effecting APIs. A missing attribute key reads as <c>null</c> and
|
||||||
|
/// never throws.
|
||||||
|
///
|
||||||
|
/// Canonical attribute keys are dotted (e.g. "TempSensor.Reading"); the prefix
|
||||||
|
/// logic here mirrors <see cref="AttributeAccessor.Resolve"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TriggerExpressionGlobals
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<string, object?> _snapshot;
|
||||||
|
|
||||||
|
public TriggerExpressionGlobals(IReadOnlyDictionary<string, object?> snapshot)
|
||||||
|
=> _snapshot = snapshot;
|
||||||
|
|
||||||
|
/// <summary>Attributes in the expression's own scope (root prefix).</summary>
|
||||||
|
public ReadOnlyAttributes Attributes => new(_snapshot, "");
|
||||||
|
|
||||||
|
/// <summary>Indexed access to child compositions' attributes.</summary>
|
||||||
|
public ReadOnlyChildren Children => new(_snapshot);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parent composition (null at root). Set by the caller for derived/composed
|
||||||
|
/// scopes; the runtime actors evaluate at root scope, so this stays null.
|
||||||
|
/// </summary>
|
||||||
|
public ReadOnlyComposition? Parent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only attribute view anchored at a canonical-name prefix. Indexing
|
||||||
|
/// resolves to the canonical key ("" → key, "TempSensor" → "TempSensor.key").
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReadOnlyAttributes
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<string, object?> _s;
|
||||||
|
private readonly string _prefix;
|
||||||
|
|
||||||
|
public ReadOnlyAttributes(IReadOnlyDictionary<string, object?> s, string prefix)
|
||||||
|
{
|
||||||
|
_s = s;
|
||||||
|
_prefix = prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? this[string key] =>
|
||||||
|
_s.TryGetValue(_prefix.Length == 0 ? key : _prefix + "." + key, out var v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A read-only view of one composition at a canonical-name path.</summary>
|
||||||
|
public sealed class ReadOnlyComposition
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<string, object?> _s;
|
||||||
|
private readonly string _path;
|
||||||
|
|
||||||
|
public ReadOnlyComposition(IReadOnlyDictionary<string, object?> s, string path)
|
||||||
|
{
|
||||||
|
_s = s;
|
||||||
|
_path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReadOnlyAttributes Attributes => new(_s, _path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dictionary-style accessor for child compositions.</summary>
|
||||||
|
public sealed class ReadOnlyChildren
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<string, object?> _s;
|
||||||
|
|
||||||
|
public ReadOnlyChildren(IReadOnlyDictionary<string, object?> s) => _s = s;
|
||||||
|
|
||||||
|
public ReadOnlyComposition this[string compositionName] => new(_s, compositionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user