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
ExpressiontoScriptTriggerKind(beforeUnknown). ParseKind: map"expression"→ScriptTriggerKind.Expression.KindToString:Expression→"Expression".- Add
string? ExpressiontoScriptTriggerModel. Parse: forExpression, readmodel.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null;Serialize: forExpression, writew.WriteString("expression", model.Expression ?? "");
Step 3: Extend AlarmTriggerConfigCodec.
- Add
string? ExpressiontoAlarmTriggerModel. Parse:case AlarmTriggerType.Expression:→model.Expression = TryReadString(root, "expression");Serialize:case AlarmTriggerType.Expression:→w.WriteString("expression", model.Expression ?? "");(note: this codec always writesattributeNamefirst — for Expression that key is unused; leave it written empty, harmless, or guard it. Prefer: skip theattributeNamewrite whentype == 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)alongsideIntervalTriggerConfig/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 viaCompileTriggerExpression, cache theScript<object?>, initbool _lastExpressionResult = false. - Maintain
Dictionary<string,object?> _attributeSnapshot— update it in theAttributeValueChangedhandler (~lines 148-168) for every change, before trigger logic. - In that handler, for
ExpressionTriggerConfig: buildnew TriggerExpressionGlobals(_attributeSnapshot), run the cached script (RunAsync(globals)), coerceReturnValueto bool; ifresult && !_lastExpressionResult→ run the script (same pathConditional/ValueChangeuse to spawnScriptExecutionActor); set_lastExpressionResult = result. - Wrap the evaluation in try/catch — on throw, treat as
falseand log a site-event-log script error; do not crash.
Step 4: AlarmActor — Expression eval config + level evaluation.
ParseEvalConfig(~lines 413-484): addcase AlarmTriggerType.Expression:building anExpressionEvalConfigthat holds the compiledScript<object?>(compile here viaCompileTriggerExpression).HandleAttributeValueChanged(~lines 127-189): maintain the same_attributeSnapshot; for theExpressioncase (switch ~lines 141-147) evaluate the compiled expression againstTriggerExpressionGlobals→ bool; feed that bool into the existing binary Normal↔Active path (the same oneValueMatch/RangeViolationuse — 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 fedSelfAttributes/Children/Parent)
Step 1: ScriptTriggerEditor — Expression panel.
- The codec already has the
Expressionkind (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@switchrenderingRenderExpression(). RenderExpression()hosts a compactMonacoEditor(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
MonacoEditorbound 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 withValidateScriptTriggerReferences/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.Expressionenum 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. _attributeSnapshotmust be updated for everyAttributeValueChanged, not just attributes the expression names.