Files
scadalink-design/docs/plans/2026-05-16-expression-trigger.md
2026-05-16 05:25:10 -04:00

13 KiB

Expression Trigger Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Add an "Expression" trigger to template scripts and alarms — a read-only boolean C# expression evaluated on attribute updates that fires the script (edge) or activates the alarm (level).

Architecture: A new restricted read-only globals type (TriggerExpressionGlobals) backed by an in-memory attribute snapshot; the expression is compiled once via the existing Roslyn pipeline and cached on ScriptActor/AlarmActor, which already receive every AttributeValueChanged. The CentralUI trigger editors gain an Expression panel. See the approved design: docs/plans/2026-05-16-expression-trigger-design.md.

Tech Stack: C#/.NET, Akka.NET (site runtime actors), Roslyn C# scripting, Blazor Server (CentralUI), Docker cluster.

Verification note: This repo has no CentralUI/Commons unit-test project; pure-logic correctness is verified by dotnet build + the editor round-trip, and runtime behavior by bash docker/deploy.sh + a browser/CLI walkthrough (the established pattern in this codebase). Steps below follow that.


Task 1: Trigger model + codecs

Files:

  • Modify: src/ScadaLink.Commons/Types/Enums/AlarmTriggerType.cs
  • Modify: src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs
  • Modify: src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs

Step 1: Add the Expression alarm trigger type. In AlarmTriggerType.cs, add Expression as the last enum member (append — do not reorder; the enum is persisted by value):

public enum AlarmTriggerType
{
    ValueMatch,
    RangeViolation,
    RateOfChange,
    HiLo,
    Expression
}

Step 2: Extend ScriptTriggerConfigCodec.

  • Add Expression to ScriptTriggerKind (before Unknown).
  • ParseKind: map "expression"ScriptTriggerKind.Expression.
  • KindToString: Expression"Expression".
  • Add string? Expression to ScriptTriggerModel.
  • Parse: for Expression, read model.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null;
  • Serialize: for Expression, write w.WriteString("expression", model.Expression ?? "");

Step 3: Extend AlarmTriggerConfigCodec.

  • Add string? Expression to AlarmTriggerModel.
  • Parse: case AlarmTriggerType.Expression:model.Expression = TryReadString(root, "expression");
  • Serialize: case AlarmTriggerType.Expression:w.WriteString("expression", model.Expression ?? ""); (note: this codec always writes attributeName first — for Expression that key is unused; leave it written empty, harmless, or guard it. Prefer: skip the attributeName write when type == Expression.)

Step 4: Build. Run: dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj -nologo Expected: Build succeeded.

Step 5: Commit.

git add src/ScadaLink.Commons/Types/Enums/AlarmTriggerType.cs src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs
git commit -m "feat(triggers): add Expression to the script & alarm trigger codecs"

Task 2: Runtime expression evaluation

Files:

  • Create: src/ScadaLink.SiteRuntime/Scripts/TriggerExpressionGlobals.cs
  • Modify: src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs
  • Modify: src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs
  • Modify: src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs

Step 1: Create TriggerExpressionGlobals. A read-only globals type backed by a snapshot dictionary. Exposes only attribute reads — no Instance/Scripts/ExternalSystem/Database/Notify. Mirror the shape of ScopeAccessors but read straight from the dict (no actor Ask). Missing key → null.

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.
/// </summary>
public sealed class TriggerExpressionGlobals
{
    private readonly IReadOnlyDictionary<string, object?> _snapshot;
    public TriggerExpressionGlobals(IReadOnlyDictionary<string, object?> snapshot) => _snapshot = snapshot;

    public ReadOnlyAttributes Attributes => new(_snapshot, "");
    public ReadOnlyChildren Children => new(_snapshot);
    public ReadOnlyComposition? Parent { get; init; }   // set by caller for derived/composed scopes; null at root

    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;
    }

    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);
    }

    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);
    }
}

Note: confirm against ScopeAccessors.cs whether canonical attribute keys are dotted (TempSensor.Reading) — they are; the prefix logic matches AttributeAccessor.Resolve.

Step 2: Add expression compilation to ScriptCompilationService. Add a method that compiles a bare C# boolean expression against TriggerExpressionGlobals, reusing the existing ScriptOptions (references/imports) and the forbidden-API trust check. Return Script<object?> (Roslyn scripting returns the trailing expression's value).

public ScriptCompilationResult CompileTriggerExpression(string name, string expression)
{
    // same ScriptOptions as Compile(), globalsType: typeof(TriggerExpressionGlobals)
    // run the same forbidden-API validation
}

Read the existing Compile (lines ~94-148) and factor the shared option-building + validation rather than duplicating.

Step 3: ScriptActor — ExpressionTriggerConfig + edge evaluation.

  • Add a trigger config record ExpressionTriggerConfig(string Expression) alongside IntervalTriggerConfig/etc.
  • ParseTriggerConfig (~line 262): add "expression" => ParseExpressionTrigger(triggerConfigJson) reading { "expression": "..." }.
  • On actor start (where the trigger is parsed/registered): if the config is ExpressionTriggerConfig, compile via CompileTriggerExpression, cache the Script<object?>, init bool _lastExpressionResult = false.
  • Maintain Dictionary<string,object?> _attributeSnapshot — update it in the AttributeValueChanged handler (~lines 148-168) for every change, before trigger logic.
  • In that handler, for ExpressionTriggerConfig: build new TriggerExpressionGlobals(_attributeSnapshot), run the cached script (RunAsync(globals)), coerce ReturnValue to bool; if result && !_lastExpressionResult → run the script (same path Conditional/ValueChange use to spawn ScriptExecutionActor); set _lastExpressionResult = result.
  • Wrap the evaluation in try/catch — on throw, treat as false and log a site-event-log script error; do not crash.

Step 4: AlarmActor — Expression eval config + level evaluation.

  • ParseEvalConfig (~lines 413-484): add case AlarmTriggerType.Expression: building an ExpressionEvalConfig that holds the compiled Script<object?> (compile here via CompileTriggerExpression).
  • HandleAttributeValueChanged (~lines 127-189): maintain the same _attributeSnapshot; for the Expression case (switch ~lines 141-147) evaluate the compiled expression against TriggerExpressionGlobals → bool; feed that bool into the existing binary Normal↔Active path (the same one ValueMatch/RangeViolation use — raise on →Active, clear on →Normal). Not HiLo.
  • Same try/catch → false + log on throw.

Step 5: Build. Run: dotnet build src/ScadaLink.Host/ScadaLink.Host.csproj -nologo Expected: Build succeeded.

Step 6: Commit.

git add src/ScadaLink.SiteRuntime/
git commit -m "feat(triggers): runtime expression trigger evaluation for scripts and alarms"

Task 3: Trigger editor panels (CentralUI)

Files:

  • Modify: src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerEditor.razor
  • Modify: src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerEditor.razor
  • Reference: src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor, src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor (how the script Code editor is fed SelfAttributes/Children/Parent)

Step 1: ScriptTriggerEditor — Expression panel.

  • The codec already has the Expression kind (Task 1). Add <option value="Expression">Expression — run when a boolean expression becomes true</option> to the type <select>.
  • Add a case ScriptTriggerKind.Expression: to the @switch rendering RenderExpression().
  • RenderExpression() hosts a compact MonacoEditor (Height="120px", Language="csharp", ScriptKind=Template) bound to _model.Expression; ValueChanged → update model + Emit(). Feed it the template attribute metadata for completion (see Step 3).
  • Hint: "Runs once each time this expression becomes true."

Step 2: AlarmTriggerEditor — Expression panel.

  • Add case AlarmTriggerType.Expression: @RenderExpression(); break; to the trigger @switch (~line 72).
  • Same compact MonacoEditor bound to _model.Expression; Emit() on change.
  • Hint: "Alarm is active while this expression is true."

Step 3: Feed attribute metadata for completion. Both editors already receive AvailableAttributes (IReadOnlyList<AlarmAttributeChoice>). MonacoEditor wants SelfAttributes (AttributeShape[]) / Children / Parent. Add a small mapper from AlarmAttributeChoice → the Monaco metadata (Direct/Inherited → SelfAttributes; Composed → Children contexts). Keep it minimal — at least pass SelfAttributes so Attributes["..."] completion works.

Step 4: Build. Run: dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj -nologo Expected: Build succeeded.

Step 5: Commit.

git add src/ScadaLink.CentralUI/Components/Shared/
git commit -m "feat(ui/triggers): expression trigger panel in the script & alarm editors"

Task 4: Pre-deployment validation

Files:

  • Modify: src/ScadaLink.TemplateEngine/.../ValidationService.cs (the file with ValidateScriptTriggerReferences / ExtractAttributeNameFromTriggerConfig)

Step 1: Compile-check expression triggers. In the validation pass, for any script/alarm whose trigger type is Expression, extract expression from TriggerConfiguration and compile-check it. The TemplateEngine project may not reference the SiteRuntime compiler — if so, do a Roslyn syntax/compile check using the same approach, or surface a clear "expression empty / invalid" check at minimum. Confirm the reference graph during execution; prefer reusing CompileTriggerExpression if reachable.

Step 2: Flag unknown attribute references (best-effort). Expression text references Attributes["X"]; extend the existing attribute-reference validation to scan the expression for Attributes["..."] literals and flag keys absent from the flattened config — mirroring ExtractAttributeNameFromTriggerConfig for the structured triggers.

Step 3: Build + commit.

dotnet build src/ScadaLink.Host/ScadaLink.Host.csproj -nologo
git add src/ScadaLink.TemplateEngine/
git commit -m "feat(triggers): validate expression triggers pre-deployment"

Task 5: Build, deploy, verify

Step 1: bash docker/deploy.sh and wait for http://localhost:9000/health/ready.

Step 2 — UI: Log in (multi-role/password), open a template → Scripts → Add Script. Select trigger type Expression; confirm the Monaco expression box renders with attribute completion. Save Attributes["TestDouble"] > 50 → reopen → confirm round-trip. Repeat on the alarm editor.

Step 3 — runtime (script, edge): Deploy an instance; set an attribute so the expression is false, then true → confirm the script runs once on the transition and does not re-run while it stays true; flip false then true again → runs again.

Step 4 — runtime (alarm, level): Expression-triggered alarm raises when the expression becomes true and clears when it becomes false (check the alarm state / Debug View).

Step 5: git push.


Notes for the executor

  • Append the AlarmTriggerType.Expression enum member last — the enum is persisted by integer value.
  • The trigger expression is a bare expression (no return) — Roslyn scripting returns the trailing expression's value.
  • Keep the evaluation try/catch tight; a throwing expression must never crash ScriptActor/AlarmActor.
  • _attributeSnapshot must be updated for every AttributeValueChanged, not just attributes the expression names.