Files
lmxopcua/docs/plans/2026-06-10-script-log-and-scripted-alarm-runtime.md
T

22 KiB
Raw Blame History

Script-log Engine Emit + Scripted-Alarm Runtime — Implementation Plan

For Claude: REQUIRED SUB-SKILL: use superpowers-extended-cc:subagent-driven-development (or executing-plans) to implement this plan task-by-task.

Goal: Make the Script-log page tail real script output, and stand up scripted alarms end-to-end including real OPC UA Part 9 condition nodes + client ack.

Architecture: Three sequenced layers off one shared seam (a root script logger fanning to file + companion + a new DPS topic sink). Layer 0 = emit (F8 live). Layer 1 = F9 engine runtime on the Akka equipment-namespace runtime. Layer 2 = F14b real Part 9 nodes + events + inbound ack. Design: docs/plans/2026-06-10-script-log-and-scripted-alarm-runtime-design.md. Verified gap analysis: pending.md.

Tech: .NET 10, Akka.NET, EF Core (SQL prod / InMemory tests), Serilog, OPC Foundation UA .NET Standard, xUnit + Shouldly, Akka TestKit. No bUnit.

Hard rules (every task): stage by explicit path — never git add .; never stage sql_login.txt or src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/; never echo the gateway API key into a new tracked file; no force-push, no --no-verify. No Configuration entity / EF migration change (ScriptedAlarmState table already exists). Agent does not sign in to the AdminUI — the user drives live /run.

Branch: feat/scriptlog-alarm-runtime off master @ df4c2657 (design committed there).

Reference patterns to mirror: VirtualTagHostActor (host actor shape), EfAlarmActorStateStore (EF store shape), the {{equip}} two-seam parity work (Phase7ComposerDeploymentArtifact), RoslynVirtualTagEvaluator (evaluator).


LAYER 0 — Shared script-log emit + F8 live

Task 0: Branch + test-project check

Classification: small · ~2 min · Parallelizable with: none

Files: none created (branch + verification only)

Steps:

  1. git switch -c feat/scriptlog-alarm-runtime (off master @ df4c2657).
  2. Confirm tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ exists and is in the .slnx (it does — ScriptLoggerFactoryTests.cs lives there). New Layer-0 tests land here. Confirm tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ for Layer-1 tests.
  3. dotnet build ZB.MOM.WW.OtOpcUa.slnx — green baseline. Commit nothing.

Task 1: IScriptLogPublisher + ScriptLogTopicSink

Classification: standard · ~4 min · Parallelizable with: none

Files:

  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/IScriptLogPublisher.cs
  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogTopicSink.cs
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptLogTopicSinkTests.cs
  • Maybe modify: Core.Scripting.csproj (add ProjectReference to Commons for ScriptLogEntry if not already referenced — verify first).

Step 1 — failing tests (ScriptLogTopicSinkTests):

  • A LogEvent (Information) with properties ScriptId="S1", VirtualTagId="V1", EquipmentId="EQ1", message "hello" → publisher receives one ScriptLogEntry with those fields, Level=="Information", Message=="hello".
  • AlarmId property maps to ScriptLogEntry.AlarmId; absent properties → null fields.
  • A Debug event with default minLevel=Information → publisher receives nothing.
  • Template message renders ("v={V}" + prop V=3 → "v=3"). Use a fake IScriptLogPublisher capturing entries.

Step 2 — run, expect fail (types don't exist).

Step 3 — implement:

public interface IScriptLogPublisher { void Publish(ScriptLogEntry entry); }

public sealed class ScriptLogTopicSink : ILogEventSink
{
    private readonly IScriptLogPublisher _publisher;
    private readonly LogEventLevel _min;
    public ScriptLogTopicSink(IScriptLogPublisher publisher,
        LogEventLevel min = LogEventLevel.Information) { _publisher = publisher; _min = min; }
    public void Emit(LogEvent e)
    {
        if (e is null || e.Level < _min) return;
        string? P(string k) => e.Properties.TryGetValue(k, out var v)
            && v is ScalarValue { Value: string s } ? s : null;
        _publisher.Publish(new ScriptLogEntry(
            ScriptId: P("ScriptId") ?? P("ScriptName") ?? "unknown",
            Level: e.Level.ToString(),
            Message: e.RenderMessage(),
            TimestampUtc: e.Timestamp.UtcDateTime,
            VirtualTagId: P("VirtualTagId"), AlarmId: P("AlarmId"), EquipmentId: P("EquipmentId")));
    }
}

(Property-name constants — reuse/extend ScriptLoggerFactory's ScriptNameProperty; add ScriptIdProperty/VirtualTagIdProperty/AlarmIdProperty/EquipmentIdProperty.)

Step 4 — run tests, expect pass. Step 5 — commit (git add the 3 files by path).


Task 2: Root script logger + DpsScriptLogPublisher + Host wiring

Classification: standard · ~5 min · Parallelizable with: none (depends T1)

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Scripting/DpsScriptLogPublisher.cs (or Host — wherever the ActorSystem/Mediator is reachable at construction).
  • Create: src/Server/ZB.MOM.WW.OtOpcUa.Host/Logging/ScriptRootLoggerFactory.cs (builds the root ILogger: rolling scripts-*.log + ScriptLogCompanionSink + ScriptLogTopicSink).
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs (build + register root logger; register IScriptLogPublisher).
  • Test: tests/.../Core.Scripting.Tests/ScriptRootLoggerFanoutTests.cs (or Host.Tests).

Steps (TDD):

  1. Failing test: a logger built by ScriptRootLoggerFactory with a fake publisher + in-memory companion → an Error event reaches the companion mirror AND the topic publisher; a Debug event reaches neither topic nor companion (file only). (Assert via fakes; don't assert the physical file.)
  2. Implement DpsScriptLogPublisher — ctor takes the DPS mediator IActorRef (or ActorSystem); Publishmediator.Tell(new Publish("script-logs", entry)) (topic constant VirtualTagActor.ScriptLogsTopic).
  3. Implement ScriptRootLoggerFactory.Build(IScriptLogPublisher, config)LoggerConfiguration().WriteTo.File(...).WriteTo.Sink(new ScriptLogCompanionSink(Log.Logger)) .WriteTo.Sink(new ScriptLogTopicSink(publisher, minLevel)).CreateLogger().
  4. Program.cs: resolve the mediator after the ActorSystem is up; register IScriptLogPublisher (singleton) + the root ILogger (keyed/named for scripts). Min-level from config (Scripting:LogTopicMinLevel, default Information).
  5. Run + commit by path.

Task 3: Rewire evaluators to the root script logger

Classification: standard · ~5 min · Parallelizable with: none (depends T1, T2)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs
  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs (bind the full property set, not just ScriptName).
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs (inject root logger into the evaluators).
  • Test: tests/.../Core.Scripting.Tests/ — evaluator emits via fake publisher.

Steps:

  1. Failing test: a RoslynVirtualTagEvaluator built with a root logger wired to a fake publisher; evaluate a script ctx.Logger.Information("hi"); return 1; → publisher gets one entry with ScriptId/VirtualTagId bound and Message=="hi".
  2. Replace the static ScriptLogger field with a ctor-injected root ILogger. Per evaluation, var log = _root.ForContext("ScriptId", id).ForContext("VirtualTagId", virtualTagId) (+ EquipmentId when available) and pass into the VirtualTagContext. Same for the alarm evaluator (binds AlarmId).
  3. ScriptLoggerFactory: add a Create(scriptId, virtualTagId?, alarmId?, equipmentId?) overload binding the standard properties (keep the old Create(scriptName) for compatibility).
  4. Program.cs: pass the root logger to both evaluator registrations.
  5. Run + commit by path.

Note: IVirtualTagEvaluator.Evaluate carries virtualTagId; in the live path scriptId == virtualTagId, so Layer 0 binds both from it. Threading a distinct EquipmentId (nice-to-have on the page) is optional here — if it requires an interface change, defer it to a Layer-1 follow-up rather than expanding T3.


Task 4: Live-verify Layer 0

Classification: verification · Parallelizable with: none (depends T2, T3)

Steps:

  1. Rebuild docker-dev central nodes (user-driven /run). Author a virtual tag whose script calls ctx.Logger.Information(...).
  2. Open /script-log; drive the dependency so the script evaluates; confirm the line appears live with the right ScriptId/level. Confirm Debug stays off the page, Information+ shows.
  3. Agent does not sign in — user signs in and drives. Record outcome. No code unless a defect surfaces (→ new fix task).

LAYER 1 — F9 engine runtime

Task 5: EquipmentScriptedAlarmPlan + Phase7Composer enrichment

Classification: standard · ~5 min · Parallelizable with: Task 7, Task 8

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs (new record + build the enriched list from ScriptedAlarm + Script rows).
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ (or wherever Phase7Composer is tested) — new Phase7ComposerScriptedAlarmTests.cs.

Steps:

  1. Failing test: compose two equipments each with a scripted alarm referencing a script; assert each EquipmentScriptedAlarmPlan carries the resolved PredicateSource, extracted DependencyRefs (via DependencyExtractor), AlarmType, Severity, MessageTemplate, HistorizeToAveva, Retain, Enabled, Name.
  2. Add public sealed record EquipmentScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string Name, string AlarmType, int Severity, string MessageTemplate, string PredicateScriptId, string PredicateSource, IReadOnlyList<string> DependencyRefs, bool HistorizeToAveva, bool Retain, bool Enabled);
  3. In Compose: join ScriptedAlarm.PredicateScriptId → Script.SourceCode; run DependencyExtractor.Extract(source).Reads ( MessageTemplate token paths) for DependencyRefs; project into the new list on the composition result. Skip Enabled=false alarms (or carry the flag — carry it; host decides). Drop alarms whose script is missing with a structured warning (don't throw the whole compose).
  4. Run + commit by path.

Task 6: DeploymentArtifact parity for the alarm plan

Classification: standard · ~5 min · Parallelizable with: Task 7, Task 8 (depends T5)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs (encode/decode EquipmentScriptedAlarmPlan; add Phase7CompositionResult.EquipmentScriptedAlarms; filter-by-equipment like EquipmentVirtualTags at :263).
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ — artifact round-trip + parity with the Composer for the same input.

Steps:

  1. Failing test: build a composition via Phase7Composer, serialize to artifact, parse back → EquipmentScriptedAlarms is byte-identical (same discipline as the {{equip}} parity tests). Equipment-filter test (only alarms for resident equipment survive).
  2. Add the field to Phase7CompositionResult; mirror the EquipmentVirtualTags encode/decode/filter exactly (:202, :263).
  3. Run + commit by path.

Task 7: DependencyMuxTagUpstreamSource

Classification: standard · ~4 min · Parallelizable with: Task 5, Task 6, Task 8

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs (implements Core.ScriptedAlarms/Core.VirtualTags ITagUpstreamSource).
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs

Steps:

  1. Failing tests: Push(path, snapshot) updates cache so ReadTag(path) returns it; SubscribeTag(path, obs)obs fires on the next Push; ReadTag for an unknown path returns a Bad-quality snapshot; dispose removes the observer.
  2. Implement: a thread-safe cache (ConcurrentDictionary<string, DataValueSnapshot>) + per-path observer list; Push updates cache then invokes observers; ReadTag reads cache (Bad if absent); SubscribeTag returns an IDisposable that deregisters. The host actor calls Push from its DependencyValueChanged handler. Value wrap: new DataValueSnapshot(value, StatusCode:0, ts, ts).
  3. Run + commit by path.

Task 8: EfAlarmConditionStateStore : IAlarmStateStore

Classification: standard · ~5 min · Parallelizable with: Task 5, Task 6, Task 7

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs (in-memory EF).

Steps:

  1. Failing tests (in-memory OtOpcUaConfigDbContext): SaveAsync(state) then LoadAsync(alarmId) round-trips Enabled/Acked/Confirmed/Shelving(+UnshelveAtUtc)/ LastAck*/LastConfirm*/Comments; LoadAsync of an unknown id → null; ActiveState is not persisted (a saved state's Active is ignored on load — load returns the stored operator state, Active defaults). Comments JSON round-trips.
  2. Implement mapping AlarmConditionStateScriptedAlarmState entity (mirror EfAlarmActorStateStore's IDbContextFactory upsert pattern; serialize ImmutableList<AlarmComment>CommentsJson). Map enum states ↔ the entity's string columns.
  3. Run + commit by path.

Task 9: ScriptedAlarmHostActor

Classification: high-risk · ~5 min · Parallelizable with: none (depends T6, T7, T8; needs Layer 0 T2/T3 root logger)

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs (Akka TestKit + a real engine with the fake upstream, or a fake engine seam).

Design: mirrors VirtualTagHostActor. Owns one ScriptedAlarmEngine (built with the DependencyMuxTagUpstreamSource, the EfAlarmConditionStateStore, a ScriptLoggerFactory wrapping the Layer 0 root logger, and the engine's root logger). Message ApplyScriptedAlarms(IReadOnlyList<EquipmentScriptedAlarmPlan> Plans).

Steps:

  1. Failing TestKit tests:
    • ApplyScriptedAlarms with one alarm → engine loaded (assert via a probe/seam); registers interest with the (probe) mux for the alarm's dep refs.
    • A DependencyValueChanged that makes the predicate true → the host tells the (probe) OpcUaPublishActor a WriteAlarmState(alarmId, active:true, …), tells the (probe) historian an AlarmHistorianEvent (when HistorizeToAveva), and publishes an AlarmTransitionEvent on alerts.
    • Re-ApplyScriptedAlarms with a different set reloads the engine (LoadAsync replace).
  2. Implement: on ApplyScriptedAlarms, build ScriptedAlarmDefinitions from the plans (map AlarmTypeAlarmKind, SeverityAlarmSeverity, EquipmentIdEquipmentPath), engine.LoadAsync; register mux interest for DependencyRefs; on DependencyValueChanged_upstream.Push(...). Subscribe engine.OnEvent once → map ScriptedAlarmEvent.Condition to (active, acknowledged)OpcUaPublishActor.WriteAlarmState; map → AlarmHistorianEvent → historian (if Historize); publish AlarmTransitionEvent on alerts. Dispose engine in PostStop.
  3. Run targeted tests (dotnet test --filter ScriptedAlarmHostActor). Commit by path.

Task 10: Spawn + apply in DriverHostActor

Classification: standard · ~4 min · Parallelizable with: none (depends T9)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs (spawn ScriptedAlarmHostActor next to VirtualTagHostActor ~:197; tell ApplyScriptedAlarms(composition.EquipmentScriptedAlarms) next to the vtag apply ~:532; add an override field for tests like _virtualTagHostOverride).
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs if the host needs new injected deps (EF store factory, root logger, historian ref).
  • Test: extend DriverHostActorTests — apply pushes ApplyScriptedAlarms.

Steps: mirror the VirtualTag spawn/apply exactly; thread _opcUaPublishActor, _dependencyMux, the EF store, the root logger, the historian actor ref. Run + commit.


Task 11: Retire the orphaned actor + F9b evaluator

Classification: small · ~3 min · Parallelizable with: none (depends T9, T10)

Files:

  • Delete: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs (+ its tests) and src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs.
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs (remove F9b DI registration, lines ~110-114) and any IScriptedAlarmEvaluator references. Keep EfAlarmActorStateStore only if nothing else uses it — otherwise delete with the actor.

Steps: delete, fix build, run full dotnet test for the touched projects, commit by path. (If something unexpectedly depends on these, stop and surface — don't expand scope.)


Task 12: Live-verify Layer 1

Classification: verification · Parallelizable with: none (depends T10, T11)

Steps: rebuild docker-dev; author a scripted alarm whose predicate references a live tag; drive the tag; confirm the alarm node flips active/clear, the historian queue advances (/alarms/historian), the alerts/Alerts page shows it, and predicate ctx.Logger output appears on /script-log. User drives sign-in. Defects → new tasks.


LAYER 2 — F14b real Part 9 + client ack

Task 13: SDK research spike (DeepWiki)

Classification: small (research) · ~5 min · Parallelizable with: Layer-1 tasks

Steps: Use the DeepWiki MCP (OPCFoundation/UA-.NETStandard) to confirm: how to create + add an AlarmConditionState (and Limit/OffNormal/Discrete subtypes) under a parent in a CustomNodeManager2; how to set ActiveState/AckedState/ConfirmedState/ ShelvingState/Severity/Retain; how transitions fire events (ReportEvent); how inbound Acknowledge/Shelve/Confirm method calls are dispatched + where to hook them. Write findings to docs/v2/f14b-part9-sdk-notes.md (committed). This de-risks T14-T17.


Task 14: Real condition-node materialisation

Classification: high-risk · ~5 min · Parallelizable with: none (depends T13)

Files: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs (replace the placeholder [active, ack] variable in WriteAlarmState / add a MaterialiseAlarmCondition path per AlarmType); Phase7Applier.cs (call the new materialiser); tests where the SDK allows (node existence/type assertions).

Steps: create real condition nodes on materialise; keep WriteAlarmState as a thin shim during transition or replace its callers. Run + commit. (SDK threading: all via the pinned OpcUaPublishActor dispatcher.)


Task 15: Richer alarm-state bridge

Classification: standard · ~4 min · Parallelizable with: Task 17 (depends T14, T9)

Files: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs (new message carrying the full AlarmConditionState, not 2 bools); ScriptedAlarmHostActor bridge (send the richer message); OtOpcUaNodeManager (apply full state to the condition). Tests: message mapping.


Task 16: Event firing on transition

Classification: high-risk · ~5 min · Parallelizable with: none (depends T14, T15)

Files: OtOpcUaNodeManager.cs (condition.ReportEvent(...) on state change). Tests: mapping/coverage where feasible; behaviour proven in T19.


Task 17: Inbound method dispatch + ack plumbing

Classification: high-risk · ~5 min · Parallelizable with: Task 15 (depends T14)

Files: OtOpcUaNodeManager.cs (wire Acknowledge/Confirm/AddComment/OneShotShelve/ TimedShelve/Unshelve handlers → route to a control-plane message → ScriptedAlarmHostActorengine.<Op>Async(conditionId, principal, comment, ct)); the security gate at the AlarmAck tier (reuse the LDAP-group→OPC-UA-permission map). Tests: method→engine routing with a fake engine; permission gate allows/denies by tier.


Task 18: AdminUI ack/shelve control

Classification: standard · ~4 min · Parallelizable with: none (depends T17)

Files: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor (+ a control-plane command service) — ack/shelve buttons route to the same engine ops as T17. Tests: the control-plane command service (no bUnit; live-verify the razor in T19).


Task 19: Live-verify Layer 2 (Client.CLI)

Classification: verification · Parallelizable with: none (depends T16, T17, T18)

Steps: with docker-dev up, use Client.CLI alarms + subscribe to confirm a real condition appears, fires events on transition, and a client Acknowledge round-trips (state flips, event fires, persists). Confirm AdminUI ack does the same. User drives.


Task 20: Docs + finish

Classification: small · ~5 min · Parallelizable with: none (depends all)

Files: update docs/ScriptedAlarms.md, docs/VirtualTags.md, docs/v2/Runtime.md (F8/F9 now wired), correct the stale docs/v2/phase-7-status.md alarm-runtime status, add a CLAUDE.md note for the script-log emit + scripted-alarm runtime. Delete/condense pending.md (its content now lives in the design + these docs). Then run superpowers-extended-cc:finishing-a-development-branch (full dotnet test, merge to master).


Execution notes

  • Parallel dispatch: Layer 0 is serial (T1→T2→T3→T4). Layer 1: T5→T6 serial (composer→artifact parity); T7, T8 parallel with T5/T6 (disjoint files); T9 waits on T6/T7/T8; T10→T11→T12 serial. Layer 2: T13 first; T15 ∥ T17 after T14; T16 after T15; T18 after T17; T19/T20 last.
  • One writer at a time within a shared file (Program.cs touched by T2/T3/T11; OtOpcUaNodeManager by T14/T15/T16/T17 — serialize those).
  • Layer boundaries are natural checkpoints — Layer 0 is independently shippable; pause for review after T4 and after T12 before committing to the Layer 2 SDK epic.