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:
Joseph Doherty
2026-05-18 10:44:11 -04:00
parent 19870d1f8f
commit 437fe154e7
9 changed files with 625 additions and 21 deletions

View File

@@ -12,6 +12,13 @@ namespace ScadaLink.CentralUI.Components.Shared;
/// </summary>
internal enum ScriptTriggerKind { None, Interval, ValueChange, Conditional, Call, Expression, Unknown }
/// <summary>
/// When a Conditional/Expression trigger fires. <see cref="OnTrue"/> fires once
/// as the condition becomes true; <see cref="WhileTrue"/> re-fires on a timer
/// (cadence = the script's MinTimeBetweenRuns) while the condition stays true.
/// </summary>
internal enum ScriptTriggerMode { OnTrue, WhileTrue }
/// <summary>A script's trigger as the editor emits it: a type string + config JSON.</summary>
public sealed record ScriptTriggerValue(string? TriggerType, string? Config);
@@ -32,6 +39,9 @@ internal sealed class ScriptTriggerModel
/// <summary>Boolean C# expression (Expression).</summary>
public string? Expression { get; set; }
/// <summary>Fire mode (Conditional + Expression). Defaults to <see cref="ScriptTriggerMode.OnTrue"/>.</summary>
public ScriptTriggerMode Mode { get; set; } = ScriptTriggerMode.OnTrue;
}
/// <summary>
@@ -41,9 +51,12 @@ internal sealed class ScriptTriggerModel
/// Serialized config shapes:
/// Interval { intervalMs }
/// ValueChange { attributeName }
/// Conditional { attributeName, operator, threshold }
/// Conditional { attributeName, operator, threshold, mode }
/// Call { }
/// Expression { expression }
/// Expression { expression, mode }
///
/// <c>mode</c> (Conditional + Expression) is <c>OnTrue</c> or <c>WhileTrue</c>;
/// an absent or unrecognized value parses as <c>OnTrue</c>.
///
/// Parsing also accepts the legacy aliases <c>attribute</c> and <c>value</c> so
/// older configs survive a round-trip through the editor.
@@ -109,10 +122,12 @@ internal static class ScriptTriggerConfigCodec
var op = root.TryGetProperty("operator", out var o) ? o.GetString() : null;
model.Operator = NormalizeOperator(op);
model.Threshold = TryReadDouble(root, "threshold") ?? TryReadDouble(root, "value");
model.Mode = ReadMode(root);
break;
case ScriptTriggerKind.Expression:
model.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null;
model.Mode = ReadMode(root);
break;
}
}
@@ -152,10 +167,12 @@ internal static class ScriptTriggerConfigCodec
w.WriteString("operator", model.Operator);
if (model.Threshold.HasValue)
w.WriteNumber("threshold", model.Threshold.Value);
w.WriteString("mode", model.Mode.ToString());
break;
case ScriptTriggerKind.Expression:
w.WriteString("expression", model.Expression ?? "");
w.WriteString("mode", model.Mode.ToString());
break;
// Call → empty object.
@@ -165,6 +182,18 @@ internal static class ScriptTriggerConfigCodec
return Encoding.UTF8.GetString(stream.ToArray());
}
/// <summary>
/// Reads the optional <c>mode</c> property; an absent or unrecognized value
/// (case-insensitive) yields <see cref="ScriptTriggerMode.OnTrue"/>.
/// </summary>
private static ScriptTriggerMode ReadMode(JsonElement root)
{
var raw = root.TryGetProperty("mode", out var m) ? m.GetString() : null;
return string.Equals(raw?.Trim(), "WhileTrue", StringComparison.OrdinalIgnoreCase)
? ScriptTriggerMode.WhileTrue
: ScriptTriggerMode.OnTrue;
}
/// <summary>Returns <paramref name="raw"/> if it is a recognized operator, else "&gt;".</summary>
internal static string NormalizeOperator(string? raw)
{

View File

@@ -7,7 +7,8 @@
ScriptActor.ParseTriggerConfig consumes:
Interval { intervalMs }
ValueChange { attributeName }
Conditional { attributeName, operator, threshold }
Conditional { attributeName, operator, threshold, mode }
Expression { expression, mode }
Call { } *@
<div class="border rounded bg-white p-3">
@@ -65,6 +66,12 @@
break;
}
@* ── Fire mode (Conditional + Expression) ──────────────────────────── *@
@if (_kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression)
{
@RenderMode();
}
@* ── Hint ──────────────────────────────────────────────────────────── *@
@if (_kind is ScriptTriggerKind.Interval or ScriptTriggerKind.ValueChange
or ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression)
@@ -273,6 +280,37 @@
await Emit();
}
// ── Fire mode (Conditional + Expression) ───────────────────────────────
private RenderFragment RenderMode() => __builder =>
{
<div class="mt-3">
<label for="script-trigger-mode" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Fire mode
</label>
<select id="script-trigger-mode" class="form-select form-select-sm"
style="max-width: 420px;"
@bind="_model.Mode" @bind:after="OnModeChanged">
<option value="@ScriptTriggerMode.OnTrue">
Once — when the condition becomes true
</option>
<option value="@ScriptTriggerMode.WhileTrue">
Repeatedly — while the condition stays true
</option>
</select>
@if (_model.Mode == ScriptTriggerMode.WhileTrue)
{
<div class="form-text">
Re-runs on a timer while the condition holds, at the script's
<strong>Min time between runs</strong> interval — set that field below
the script editor, or the trigger fires only once.
</div>
}
</div>
};
private async Task OnModeChanged() => await Emit();
// ── Attribute picker (ValueChange + Conditional) ───────────────────────
private RenderFragment RenderAttributePicker(string label) => __builder =>
@@ -341,11 +379,15 @@
ScriptTriggerKind.Conditional =>
_model.Threshold is { } t
? $"Runs when {attr} changes, if {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}."
? (_model.Mode == ScriptTriggerMode.WhileTrue
? $"Re-runs while {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}."
: $"Runs when {attr} changes, if {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}.")
: $"Runs when {attr} changes and meets the configured condition — set a threshold above.",
ScriptTriggerKind.Expression =>
"Runs once each time this expression becomes true.",
_model.Mode == ScriptTriggerMode.WhileTrue
? "Re-runs while this expression stays true."
: "Runs once each time this expression becomes true.",
_ => string.Empty
};