13 KiB
Script-log Engine Emit + Scripted-Alarm Runtime — Design
Status: approved 2026-06-10 (brainstorming). Next: implementation plan (
writing-plans). Companion gap analysis:pending.md(repo root, verified againstmaster @ ac1e1dfd).
Goal
Make the Script-log page show real script output, and stand up scripted alarms end-to-end — including real OPC UA Part 9 condition nodes and client-initiated acknowledge/shelve. Delivered in three sequenced layers.
Problem / current state (verified)
- The Script-log page (
ScriptLog.razor) + transport (IInProcessBroadcaster→ScriptLogSignalRBridge→script-logsDPS topic) are fully built, but a healthy script'sctx.Logger.*output never reaches them — the Roslyn evaluators inject a staticSerilogLog.ForContext<…>()into the script context. Only eval failures publish aScriptLogEntry(fromVirtualTagActor). Hence the page note "No script-log entries yet. Engine emit (F8/F9) is pending." ScriptLoggerFactory/ScriptLogCompanionSink/ thescripts-*.logsink exist but are not wired into the Host — the evaluators bypass them.- Scripted alarms do not run at all. The Part-9-complete
ScriptedAlarmEngine+ScriptedAlarmSourceare orphaned (never constructed);DriverHostActorwires only the virtual-tag host. The new runtime materialises a placeholder[active, acknowledged]variable per alarm; real Part 9 nodes are the unstarted F14b / #85 workstream. phase-7-status.mdmarks the alarm runtime "Done", but it describes a superseded architecture (OpcUaApplicationHost/Phase7EngineComposeretc., all 0 hits today). Trust the code, not that doc. Full detail + audit table inpending.md.
Decisions (from brainstorming)
| # | Decision |
|---|---|
| D1 | Full scope: all three layers (shared emit · F9 engine runtime · F14b real Part 9 + client ack). |
| D2 | Build F9 on the heavyweight ScriptedAlarmEngine (Part-9-complete, tested), not the lightweight ScriptedAlarmActor. Retire ScriptedAlarmActor + RoslynScriptedAlarmEvaluator. |
| D3 | A root script logger (file + companion + new topic sink) is the shared emit seam for F8 and F9. Build once at Host startup, inject into both evaluators and the engine's ScriptLoggerFactory. |
| D4 | ScriptLogTopicSink gates at a configurable minimum level, default Information (Debug/Trace stay in the file, off the wire). |
| D5 | Composition carries the alarm predicate source + dependency refs via a new EquipmentScriptedAlarmPlan (parallel to EquipmentVirtualTagPlan), built byte-parity in both compose seams (Phase7Composer + DeploymentArtifact). |
| D6 | Ack plumbing is grouped into Layer 2 — AdminUI ack/shelve and OPC-UA-client ack both route to the same engine.AcknowledgeAsync(...). Layer 1 stays "alarms run + persist + historize + emit". |
No Configuration entity / EF migration change is required: the ScriptedAlarmState
table already exists; the EF store reads/writes it, and the composition enrichment is
in-memory plan types only.
Architecture
The unifying seam is the root script logger:
root script logger ─┬→ scripts-*.log (rolling, all levels)
├→ ScriptLogCompanionSink (Error+ → main opcua-*.log)
└→ ScriptLogTopicSink (≥ min level) → IScriptLogPublisher
→ DPS "script-logs" → ScriptLogSignalRBridge
→ IInProcessBroadcaster → ScriptLog.razor
Inject it into both Roslyn evaluators (F8) and the ScriptedAlarmEngine's
ScriptLoggerFactory (F9); every layer's script logging lights up the page for free.
Layer 0 Shared emit ScriptLogTopicSink + root logger + per-script ForContext [foundation]
Layer 1 F9 engine runtime ScriptedAlarmHostActor wraps ScriptedAlarmEngine [the payoff]
Layer 2 F14b real Part 9 real AlarmConditionState nodes + events + inbound ack [SDK epic]
Layer 0 — Shared script-log emit + F8 live
Outcome: a healthy virtual-tag script's ctx.Logger.Information(...) shows on the
page in ~½s.
New:
ScriptLogTopicSink : Serilog.Core.ILogEventSink(Core.Scripting) — readsScriptId/VirtualTagId/AlarmId/EquipmentIdoff theLogEvent, builds aScriptLogEntry, hands it to an injectedIScriptLogPublisher. Min-level gate (D4).IScriptLogPublisher(Core.Scripting) —void Publish(ScriptLogEntry entry). Keeps Akka out of Core.DpsScriptLogPublisher : IScriptLogPublisher(Runtime/Host) — holds theActorSystemmediator;Publish→Mediator.Tell(new Publish("script-logs", entry)).
Changed:
- Host startup builds the root script logger (file + companion + topic sink), registers it in DI.
RoslynVirtualTagEvaluator+RoslynScriptedAlarmEvaluatortake the root logger instead of the static field; per evaluationForContextthe identity (ScriptId/VirtualTagId/EquipmentId) and inject into the script context. Requires threadingscriptId+equipmentIdto the evaluator per call (small extension;VirtualTagIdalready present — in the live pathscriptId == virtualTagIdtoday).ScriptLoggerFactorygains a binding for the standard property set (so the engine's per-alarm logger carriesAlarmId/EquipmentIdthe topic sink understands).
No regression: VirtualTagActor's existing failure PublishLog stays (catches
compile errors/timeouts the script can't log) — distinct messages, no double-emit.
Tests (xUnit+Shouldly): sink props→entry mapping + min-level gate + null props;
root-logger fan-out (Error→all three sinks, Debug→file only); evaluator emits via a
fake IScriptLogPublisher when a script logs. Live-verify: author a logging vtag in
docker-dev.
Layer 1 — F9 engine runtime
Outcome: scripted alarms run — predicates evaluate against live tags, state persists, transitions drive the alarm node + historian + Alerts page, predicate logs hit the page (via Layer 0).
Host: ScriptedAlarmHostActor (new, Runtime) — child of DriverHostActor, spawned
where VirtualTagHostActor is. Owns one ScriptedAlarmEngine; on ApplyScriptedAlarms
calls engine.LoadAsync(defs); disposes engine on stop.
Supporting pieces:
DependencyMuxTagUpstreamSource : ITagUpstreamSource(new) — host registers interest withDependencyMuxActorfor the union of alarm dep refs; eachDependencyValueChangedpushes into the adapter cache and fires the engine'sSubscribeTagobservers.ReadTag= cache lookup (Bad if absent). Values wrapped asDataValueSnapshot(Good) like the vtag path.EfAlarmConditionStateStore : IAlarmStateStore(new) — persistsAlarmConditionState↔ the existingScriptedAlarmStatetable (enabled/acked/confirmed/shelving +ShelvingExpiresUtc+ LastAck*/LastConfirm*/CommentsJson; ActiveState re-derived, not stored). MirrorsEfAlarmActorStateStore; usesIDbContextFactory<OtOpcUaConfigDbContext>.- Composition enrichment (D5) — new
EquipmentScriptedAlarmPlan(ScriptedAlarmId, EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, PredicateSource, DependencyRefs, HistorizeToAveva, Retain, Enabled).PredicateSourceresolvedPredicateScriptId → Script.SourceCode;DependencyRefs=DependencyExtractor.Extract(source).Reads∪ message-template token paths. Built inPhase7Composer.Compose(live DB) andDeploymentArtifact(artifact encode/decode), byte-parity.DriverHostActor→ApplyScriptedAlarms(composition.EquipmentScriptedAlarms). - Engine→outputs bridge (host
engine.OnEventhandler): mapCondition→(active, acknowledged)→OpcUaPublishActor.WriteAlarmState(AlarmId, …); ifHistorizeToAveva→HistorianAdapterActor; publishAlarmTransitionEventon thealertstopic. Script-log emit is automatic — the host passes Layer 0's root logger into the engine'sScriptLoggerFactory(the Layer 0↔1 join).
Retire: ScriptedAlarmActor, RoslynScriptedAlarmEvaluator, the F9b DI
registration in Program.cs.
deploy → EquipmentScriptedAlarmPlan(source+deps) → ScriptedAlarmHostActor.LoadAsync
tag change → DependencyMux → adapter → engine predicate eval → Part9StateMachine
engine.OnEvent ─┬→ WriteAlarmState (active/ack node)
├→ HistorianAdapterActor (if Historize)
├→ alerts topic (Alerts page)
└→ per-alarm logger → [Layer 0] → Script-log page
Tests: EF store round-trip (in-memory EF); upstream adapter push→observer;
composition enrichment + Phase7Composer↔DeploymentArtifact parity (same discipline
as {{equip}}); host actor TestKit (apply → tag change → asserts WriteAlarmState /
historian / alerts emitted). Engine internals already tested. Live-verify: author an
alarm, drive its tag, watch the node flip + historian queue + predicate logs.
Layer 2 — F14b real Part 9 + client ack
Outcome: alarms become real Part 9 conditions — clients see them in event subscriptions and can Acknowledge/Shelve/Confirm; the placeholder variable is retired.
SDK-heavy epic (issue #85). All SDK address-space work funnels through
OpcUaPublishActor on the pinned dispatcher. SDK specifics (creating
AlarmConditionState, the event model, method-handler wiring) confirmed via the
DeepWiki MCP (OPCFoundation/UA-.NETStandard) during planning, not assumed here.
Components:
- Real condition nodes — materialise a proper
AlarmConditionState(or the subtype perAlarmType: Limit/OffNormal/Discrete) under the equipment node with the standard sub-properties (ActiveState/AckedState/ConfirmedState/EnabledState/ ShelvingState/Severity/Retain/Comment). ReplacesWriteAlarmState's flat variable. - State → condition — the Layer 1 bridge now carries the full
AlarmConditionStateand sets it on the real node. - Event firing —
condition.ReportEvent(...)on each transition so subscribers get the alarm (AlarmsAndConditions). - Inbound method dispatch + ack plumbing (D6) — wire
Acknowledge/Confirm/AddComment/OneShotShelve/TimedShelve/Unshelve→engine.<Op>Async(conditionId, principal, comment, ct)with the authenticated session principal. Both the OPC UA client path andScriptedAlarms.razorroute to the same engine methods; engine transition → persist → emit → node update + event. - Permission gating — method calls gate at the
AlarmAcktier (LDAP-group → OPC-UA permission map); Confirm/Shelve/AddComment equivalently.
OPC UA client ─┐ ┌→ engine updates AlarmConditionState
AdminUI page ──┴→ engine.AcknowledgeAsync ┤ → OpcUaPublishActor → real condition node
(principal, AlarmAck gate)└→ condition.ReportEvent → client event stream
Tests: engine-state→condition-state mapping; method→engine routing (fake engine) + permission gating; SDK node/event behaviour proven by Client.CLI alarm subscribe + ack round-trip in docker-dev. Highest-risk layer; most live-verification dependent.
Cross-cutting
Hard rules (carry into the plan):
- Stage by explicit path — never
git add .. Never stagesql_login.txtorsrc/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 (the
ScriptedAlarmStatetable already exists). - Agent must not sign in to the AdminUI for live verification — the user signs in.
- Razor/JS proven only by live docker-dev
/run; everything else unit-tested (xUnit + Shouldly, in-memory EF, Akka TestKit). No bUnit. - Build on a feature branch off
master; planning docs (this design + the plan +.tasks.json) committed tomasterper established pattern.
Touched code (indicative — plan nails exact files):
- Layer 0:
Core.Scripting/(new sink + publisher iface + factory binding),Host/Program.cs(root logger wiring + retire static loggers),Host/Engines/Roslyn*Evaluator.cs,Runtime(DPS publisher). - Layer 1:
Runtime/ScriptedAlarms/(host actor, upstream adapter, EF store),OpcUaServer/Phase7Composer.cs+Runtime/Drivers/DeploymentArtifact.cs(enriched plan),Runtime/Drivers/DriverHostActor.cs(spawn + apply), retireScriptedAlarmActor+RoslynScriptedAlarmEvaluator. - Layer 2:
OpcUaServer/OtOpcUaNodeManager.cs(real condition nodes + methods),Runtime/OpcUa/OpcUaPublishActor.cs(richer alarm message), security gate,AdminUI/Components/Pages/ScriptedAlarms.razor(ack/shelve control).
Out of scope: virtual-tag historization production sink (Gap 5 / B.6 — separate); Phase-7 Playwright E2E (F.7).
Sequencing: Layer 0 is the low-risk foundation and independently shippable. Layer 1 is the self-contained "alarms work" payoff. Layer 2 is the SDK epic — largest, highest risk, most live-verification dependent.