docs(plans): implementation plan for expression triggers
This commit is contained in:
220
docs/plans/2026-05-16-expression-trigger.md
Normal file
220
docs/plans/2026-05-16-expression-trigger.md
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <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).
|
||||||
|
```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<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.**
|
||||||
|
```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 `<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.**
|
||||||
|
```bash
|
||||||
|
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.**
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
11
docs/plans/2026-05-16-expression-trigger.md.tasks.json
Normal file
11
docs/plans/2026-05-16-expression-trigger.md.tasks.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-16-expression-trigger.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 25, "subject": "Task 1: Trigger model + codecs", "status": "pending"},
|
||||||
|
{"id": 26, "subject": "Task 2: Runtime expression evaluation", "status": "pending", "blockedBy": [25]},
|
||||||
|
{"id": 27, "subject": "Task 3: Trigger editor panels", "status": "pending", "blockedBy": [25]},
|
||||||
|
{"id": 28, "subject": "Task 4: Pre-deployment validation", "status": "pending", "blockedBy": [25, 26]},
|
||||||
|
{"id": 29, "subject": "Task 5: Build, deploy, verify", "status": "pending", "blockedBy": [25, 26, 27, 28]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-16"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user