feat(triggers): add WhileTrue fire mode for Conditional/Expression script triggers
Conditional and Expression script triggers gain an optional `mode` field in their TriggerConfiguration JSON: - OnTrue (default): unchanged edge/per-change firing. An absent mode field parses as OnTrue, so every existing trigger config behaves identically. - WhileTrue: fires on the false->true edge, then re-fires on a periodic timer while the condition holds; stops on the true->false edge. The re-fire cadence is the script's MinTimeBetweenRuns; with none configured the trigger degrades to a single edge fire and logs a warning. ScriptActor tracks condition truth state and manages a dedicated "whiletrue-trigger" timer. ScriptTriggerConfigCodec and ScriptTriggerEditor round-trip the mode and expose an OnTrue/WhileTrue selector for the two trigger kinds. Design: docs/plans/2026-05-18-whiletrue-trigger-mode-design.md Tests: 7 ScriptActor runtime tests (edge fire, timer re-fire, stop, re-arm, no-MinTimeBetweenRuns degrade, OnTrue regressions) + 14 codec / editor tests. SiteRuntime suite 206 green, CentralUI suite 295 green.
This commit is contained in:
@@ -20,7 +20,11 @@ namespace ScadaLink.SiteRuntime.Actors;
|
||||
/// Trigger types:
|
||||
/// - Interval: uses Akka timers to fire periodically
|
||||
/// - ValueChange: receives attribute change notifications from Instance Actor
|
||||
/// - Conditional: evaluates a condition on attribute change
|
||||
/// - Conditional: evaluates a threshold comparison on attribute change
|
||||
/// - Expression: evaluates a compiled boolean expression on attribute change
|
||||
/// Conditional and Expression triggers carry a <see cref="TriggerMode"/>:
|
||||
/// 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).
|
||||
/// </summary>
|
||||
@@ -48,6 +52,14 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
private bool _lastExpressionResult;
|
||||
private readonly Dictionary<string, object?> _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;
|
||||
|
||||
/// <summary>Timer key for the WhileTrue re-fire timer (cadence = MinTimeBetweenRuns).</summary>
|
||||
private const string WhileTrueTimerKey = "whiletrue-trigger";
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-017: the exact dictionary instance this actor was seeded from
|
||||
/// at construction. The Instance Actor must pass a private snapshot here, not
|
||||
@@ -108,6 +120,9 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
// Handle interval tick
|
||||
Receive<IntervalTick>(_ => TrySpawnExecution(null));
|
||||
|
||||
// Handle WhileTrue re-fire tick
|
||||
Receive<WhileTrueTick>(_ => FireWhileTrueTick());
|
||||
|
||||
// Handle execution completion (for logging/metrics)
|
||||
Receive<ScriptExecutionCompleted>(HandleExecutionCompleted);
|
||||
}
|
||||
@@ -193,9 +208,17 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
if (conditional.AttributeName == changed.AttributeName)
|
||||
{
|
||||
// Evaluate condition
|
||||
if (EvaluateCondition(conditional, changed.Value))
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -208,13 +231,16 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
|
||||
/// <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.
|
||||
/// snapshot. In <see cref="TriggerMode.OnTrue"/> mode the script runs once
|
||||
/// per false→true transition; in <see cref="TriggerMode.WhileTrue"/> 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.
|
||||
/// </summary>
|
||||
private void EvaluateExpressionTrigger()
|
||||
{
|
||||
if (_compiledTriggerExpression == null) return;
|
||||
if (_triggerConfig is not ExpressionTriggerConfig exprConfig) return;
|
||||
|
||||
bool result;
|
||||
try
|
||||
@@ -239,7 +265,11 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
result = false;
|
||||
}
|
||||
|
||||
if (result && !_lastExpressionResult)
|
||||
if (exprConfig.Mode == TriggerMode.WhileTrue)
|
||||
{
|
||||
HandleWhileTrueTransition(result, _lastExpressionResult);
|
||||
}
|
||||
else if (result && !_lastExpressionResult)
|
||||
{
|
||||
TrySpawnExecution(null);
|
||||
}
|
||||
@@ -247,6 +277,63 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
_lastExpressionResult = result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void HandleWhileTrueTransition(bool nowTrue, bool wasTrue)
|
||||
{
|
||||
if (nowTrue && !wasTrue)
|
||||
{
|
||||
TrySpawnExecution(null);
|
||||
StartWhileTrueTimer();
|
||||
}
|
||||
else if (!nowTrue && wasTrue)
|
||||
{
|
||||
StopWhileTrueTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the periodic WhileTrue re-fire timer. The cadence is the script's
|
||||
/// <c>MinTimeBetweenRuns</c>; with none configured the trigger cannot
|
||||
/// re-fire, so it degrades to the single edge fire and logs a warning.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Cancels the WhileTrue re-fire timer (a no-op if it is not running).</summary>
|
||||
private void StopWhileTrueTimer() => Timers.Cancel(WhileTrueTimerKey);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private void FireWhileTrueTick()
|
||||
{
|
||||
if (_compiledScript == null) return;
|
||||
|
||||
_lastExecutionTime = DateTimeOffset.UtcNow;
|
||||
SpawnExecution(null, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a trigger-expression evaluation failure to the site event log,
|
||||
/// mirroring how ScriptExecutionActor reports script errors.
|
||||
@@ -368,7 +455,31 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
private static ExpressionTriggerConfig? ParseExpressionTrigger(string? json)
|
||||
{
|
||||
var expr = TriggerExpressionGlobals.ExtractExpression(json);
|
||||
return expr == null ? null : new ExpressionTriggerConfig(expr);
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the optional <c>mode</c> field (Conditional + Expression triggers).
|
||||
/// An absent or unrecognized value (case-insensitive) yields
|
||||
/// <see cref="TriggerMode.OnTrue"/>, so pre-WhileTrue configs are unchanged.
|
||||
/// </summary>
|
||||
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)
|
||||
@@ -404,7 +515,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
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);
|
||||
return new ConditionalTriggerConfig(
|
||||
attr, op, threshold, ParseTriggerMode(doc.RootElement));
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
@@ -417,13 +529,26 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
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 ──
|
||||
|
||||
/// <summary>
|
||||
/// When a Conditional/Expression trigger fires. <see cref="OnTrue"/> fires once
|
||||
/// as the condition becomes true; <see cref="WhileTrue"/> additionally re-fires
|
||||
/// on a timer (cadence = the script's MinTimeBetweenRuns) until it goes false.
|
||||
/// </summary>
|
||||
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) : ScriptTriggerConfig;
|
||||
internal record ExpressionTriggerConfig(string Expression) : 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;
|
||||
|
||||
Reference in New Issue
Block a user