From 8050a1996f8b5dc117884e3ee15723ae9c0329bd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 16 May 2026 05:25:10 -0400 Subject: [PATCH] docs(plans): implementation plan for expression triggers --- docs/plans/2026-05-16-expression-trigger.md | 220 ++++++++++++++++++ ...026-05-16-expression-trigger.md.tasks.json | 11 + 2 files changed, 231 insertions(+) create mode 100644 docs/plans/2026-05-16-expression-trigger.md create mode 100644 docs/plans/2026-05-16-expression-trigger.md.tasks.json diff --git a/docs/plans/2026-05-16-expression-trigger.md b/docs/plans/2026-05-16-expression-trigger.md new file mode 100644 index 0000000..90c8754 --- /dev/null +++ b/docs/plans/2026-05-16-expression-trigger.md @@ -0,0 +1,220 @@ +# 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): +```csharp +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.** +```bash +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`. +```csharp +namespace ScadaLink.SiteRuntime.Scripts; + +/// +/// 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. +/// +public sealed class TriggerExpressionGlobals +{ + private readonly IReadOnlyDictionary _snapshot; + public TriggerExpressionGlobals(IReadOnlyDictionary 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 _s; + private readonly string _prefix; + public ReadOnlyAttributes(IReadOnlyDictionary 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 _s; + private readonly string _path; + public ReadOnlyComposition(IReadOnlyDictionary s, string path) { _s = s; _path = path; } + public ReadOnlyAttributes Attributes => new(_s, _path); + } + + public sealed class ReadOnlyChildren + { + private readonly IReadOnlyDictionary _s; + public ReadOnlyChildren(IReadOnlyDictionary 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` (Roslyn scripting returns the trailing expression's value). +```csharp +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`, init `bool _lastExpressionResult = false`. +- Maintain `Dictionary _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` (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.** +```bash +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 `` to the type `