Files
lmxopcua/docs/v2/phase-7-status.md
Joseph Doherty 419eda256b feat(server): route OPC UA Part 9 AddComment to ScriptedAlarmEngine
RouteScriptedAlarmMethodCalls now handles ConditionType.AddComment
alongside Acknowledge/Confirm, dispatching to engine.AddCommentAsync.
An empty comment is rejected by the Part 9 state machine and surfaced
as BadInvalidArgument. MapCallOperation gates AddComment at the
AlarmAcknowledge tier — there is no dedicated AddComment permission bit.

Closes phase-7-status.md Gap 1: all Part 9 alarm methods now route to
the engine. Adds 3 unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:43:03 -04:00

20 KiB
Raw Blame History

Phase 7 Status — Scripting Runtime, Virtual Tags, Scripted Alarms, Historian Sink

Reconciliation date: 2026-05-18 Based on: docs/v2/implementation/phase-7-scripting-and-alarming.md (the plan) and docs/v2/implementation/exit-gate-phase-7.md (the exit-gate audit) cross-checked against the actual repository files. See "Evidence sources" at the bottom.

Summary verdict

Phase 7 core is fully shipped and the exit gate is closed. All eight plan streams (AH, where H = exit gate) plus the three deferred follow-ups (tasks #239 / #240 / #241) landed before the 2026-04-23 exit-gate audit. The v2-release-readiness.md note "Phase 7 — out of scope for v2 GA" is a stale label: the work shipped after that doc was last updated. The four Core.* Phase 7 projects exist, have tests, and are wired into the running server. Five targeted gaps remain open (see section below).


Work-item status by plan stream

Stream A — Core.Scripting (Roslyn engine, sandbox, AST inference, logger)

Plan item Status Evidence
A.1 — Project scaffold + ScriptContext base class (GetTag / SetVirtualTag / Logger / Now / Deadband) Done src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs, ScriptGlobals.cs
A.2 — DependencyExtractor : CSharpSyntaxWalker — literal-only path check, Inputs + Outputs sets Done DependencyExtractor.cs; literal-reject logic exercised by 7 test files in Core.Scripting.Tests
A.3 — Compile cache keyed on SHA-256(source) Done CompiledScriptCache.cs (ConcurrentDictionary<string, Lazy<ScriptEvaluator<...>>>)
A.4 — Per-evaluation timeout (250 ms default) Done TimedScriptEvaluator.cs; TimedScriptEvaluatorTests.cs
A.5 — Serilog sink wiring; scripts-*.log companion mirror to main log at WARN on ERROR Done ScriptLoggerFactory.cs, ScriptLogCompanionSink.cs; ScriptLogCompanionSinkTests.cs
A.6 — Tests (AST extraction, sandbox escape, exception isolation, timeout, logger binding) Done ScriptSandboxTests.cs, DependencyExtractorTests.cs, CompiledScriptCacheTests.cs, ScriptLoggerFactoryTests.cs, TimedScriptEvaluatorTests.cs — 7 test files

Shipped as PRs #177#179 (63 tests).

Stream B — Virtual tag engine

Plan item Status Evidence
B.1 — VirtualTagEngine + DependencyGraph Done VirtualTagEngine.cs, DependencyGraph.cs
B.2 — ChangeTriggerDispatcher (subscribe to referenced driver tags via ITagUpstreamSource) Done VirtualTagEngine.OnUpstreamChange internal subscriber path
B.3 — TimerTriggerDispatcher (per-tag IntervalMs via timer-wheel) Done TimerTriggerScheduler.cs
B.4 — EvaluationPipeline (serial, per-tag isolation, _evalGate semaphore) Done VirtualTagEngine.EvaluateInternalAsync; _evalGate SemaphoreSlim(1,1)
B.5 — IVirtualTagSource implementing IReadable + ISubscribable Done VirtualTagSource.cs
B.6 — History routing (IHistoryWriter.Record when Historize=true) Partial IHistoryWriter.cs + NullHistoryWriter present; no production writer is wired into the virtual-tag path. docs/VirtualTags.md §"Upstream reads + history" explicitly notes: "no production writer is currently wired for virtual tags". Virtual-tag historization is functional at the engine level but has no live sink.
B.7 — Tests: dependency graph, cascade, timer, change+timer combined, error propagation, historize Done DependencyGraphTests.cs, VirtualTagEngineTests.cs, TimerTriggerSchedulerTests.cs, VirtualTagSourceTests.cs — 5 test files

Shipped as PR #180 (36 tests).

Stream C — Scripted alarm engine + Part 9 state machine + template messages

Plan item Status Evidence
C.1 — ScriptedAlarmEngine skeleton + alarm config model Done ScriptedAlarmEngine.cs, ScriptedAlarmDefinition.cs
C.2 — Part9StateMachine (Enable/Disable/Active/Ack/Confirm/Shelve/Unshelve/Comment/ShelvingCheck) Done Part9StateMachine.cs; Part9StateMachineTests.cs
C.3 — Predicate evaluation on input change; activate/clear transitions Done ScriptedAlarmEngine.ReevaluateAsync; _alarmsReferencing inverse index
C.4 — Startup recovery (ActiveState re-derived; Enabled/Ack/Confirm/Shelve loaded from store) Done ScriptedAlarmEngine.LoadAsync; IAlarmStateStore.LoadAsync
C.5 — Template substitution ({TagPath} tokens resolved at emission time) Done MessageTemplate.cs; MessageTemplateTests.cs
C.6 — OPC UA method binding (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) Partial Engine methods exist and are tested. ScriptedAlarmSource.AcknowledgeAsync defaults the user to "opcua-client". The plan's Stream G wiring of these methods to OPC UA MethodCall dispatch on the condition nodes (so OPC UA client method calls reach the engine with the authenticated principal) is noted in the e2e smoke doc as "not yet wired through DriverNodeManager.MethodCall dispatch." Operators acknowledge through Admin UI today; the Part 9 method-call path is a follow-up.
C.7 — IAlarmSource implementation / fan-out registration Done ScriptedAlarmSource.cs
C.8 — Tests: all state transitions, startup recovery, template substitution, shelving timer expiry Done Part9StateMachineTests.cs, ScriptedAlarmEngineTests.cs, ScriptedAlarmSourceTests.cs, MessageTemplateTests.cs — 5 test files

Shipped as PR #181 (47 tests).

Stream D — Historian alarm sink (SQLite store-and-forward + Wonderware IPC)

Plan item Status Evidence
D.1 — Core.AlarmHistorian project; IAlarmHistorianSink; SqliteStoreAndForwardSink (backoff, dead-letter, capacity) Done IAlarmHistorianSink.cs, SqliteStoreAndForwardSink.cs; SqliteStoreAndForwardSinkTests.cs
D.2 — Live-historian smoke against dev box Aveva Historian; document the exact SDK entry point Partial The smoke (docs/v2/implementation/phase-7-e2e-smoke.md) ran but the IPC path via Galaxy.Host to aahClientManaged was the original plan. That path changed: the production implementation uses Driver.Historian.Wonderware.Client (WonderwareHistorianClient.WriteBatchAsync) over a named-pipe sidecar, not Galaxy.Host. There is no separate docs/v2/historian-alarm-api.md artifact documenting the SDK entry point as the plan called for; the implementation detail is in WonderwareHistorianClient.cs inline.
D.3 — Driver.Galaxy.Shared contract additions (HistorianAlarmEventRequest / Response / ConnectivityStatusNotification) Changed The plan routed alarm writes through Galaxy.Host IPC. The shipped implementation uses Driver.Historian.Wonderware.Client (a standalone sidecar project) instead. HistorianAlarmEventRequest / HistorianAlarmEventResponse as named protos never shipped; the equivalent contract is the AlarmHistorianEventDto / WriteAlarmEventsRequest / WriteAlarmEventsReply MessagePack DTOs in Driver.Historian.Wonderware.Client/Ipc/. Galaxy.Host still exists as the mxaccessgw entry point but does not carry historian writes.
D.4 — Driver.Galaxy.Host handler for alarm writes Changed Not shipped via Galaxy.Host. The sidecar (Driver.Historian.Wonderware.Client) is the production path. IAlarmHistorianWriter is implemented by WonderwareHistorianClient, not by a Galaxy.Host frame handler.
D.5 — Drain worker in main server (poll SQLite queue, batch 100 events, exponential backoff) Done SqliteStoreAndForwardSink.StartDrainLoop; backoff ladder 1s → 2s → 5s → 15s → 60s; Phase7Composer.ResolveHistorianSink starts it with a 2-second drain cadence
D.6 — Per-alarm HistorizeToAveva toggle; AlarmHistorizationPolicy per source Done ScriptedAlarm.HistorizeToAveva column (default true); Phase7EngineComposer.RouteToHistorianAsync checks it; Galaxy defaults false
D.7 — /alarms/historian diagnostics view in Admin (queue depth, drain rate, last error, retry dead-lettered) Done AlarmsHistorian.razor; HistorianDiagnosticsService.cs
D.8 — Tests Done SqliteStoreAndForwardSinkTests.cs; Phase7ComposerWriterSelectionTests.cs covers historian-writer resolution

Shipped as PR #182 (14 tests). Architecture deviated from the plan (Wonderware sidecar instead of Galaxy.Host IPC) but the functional goals are met.

Stream E — Config DB schema + generation-sealed cache extensions

Plan item Status Evidence
E.1 — EF migration for Script / VirtualTag / ScriptedAlarm / ScriptedAlarmState tables Done Migration 20260420231641_AddPhase7ScriptingTables.cs; entities in Configuration/Entities/
E.2 — sp_PublishGeneration extension (sealed-cache snapshot includes Phase 7 rows) Done Migration 20260420232000_ExtendComputeGenerationDiffWithPhase7.cs
E.3 — CRUD services: VirtualTagService, ScriptedAlarmService, ScriptService, ScriptedAlarmStateService Done All four exist in Admin/Services/; GetStateAsync on ScriptedAlarmService serves the state query
E.4 — Tests: migration up/down; publish atomicity; audit trail Done Phase7ServicesTests.cs (13 tests covering CRUD + hash behavior + harness)

Shipped as PR #183 (12 tests in configuration; 13 more in Admin.Tests).

Stream F — Admin UI scripting tab

Plan item Status Evidence
F.1 — Monaco editor Razor component (CDN bundle + textarea fallback) Done ScriptEditor.razor (textarea with Monaco JS interop, otOpcUaScriptEditor.attach)
F.2 — /virtual-tags tab (list view, edit pane, dependency preview, publish gate) Partial The ScriptsTab.razor is the single tab covering script CRUD, dependency preview, and harness. There is no separate /virtual-tags tab UI — virtual tags are managed through the script service alone; no VirtualTag list/edit form exists in the Admin UI. The per-tag fields (EquipmentId, DataType, ChangeTriggered, TimerIntervalMs, Historize) are accessible via the VirtualTagService backend but have no corresponding UI form.
F.3 — /scripted-alarms tab (alarm type, severity, message template, HistorizeToAveva, detail page with shelve/ack state read-only) Partial No dedicated scripted-alarms tab razor page exists (confirmed by Glob + Grep searches). Scripted alarm CRUD (ScriptedAlarmService) exists as a service but has no Admin UI page.
F.4 — Test harness (modal, synthetic inputs, output + logger display) Partial ScriptTestHarnessService.cs is complete and tested. ScriptsTab.razor calls Harness.RunVirtualTagAsync with zero-value synthetic inputs derived from the extractor. A full interactive input-form modal was not shipped — the harness zeroes all inputs automatically rather than prompting the operator per-tag.
F.5 — Script log viewer (SignalR tail of scripts-*.log filtered by ScriptName, load-more) Not started No SignalR stream of the scripts log is wired in the Admin UI. The AlertHub / FleetStatusHub exist but there is no ScriptLogHub.
F.6 — /alarms/historian diagnostics view Done AlarmsHistorian.razor + HistorianDiagnosticsService.cs
F.7 — Playwright smoke (author calc tag, verify in equipment tree; author alarm, verify in AlarmsAndConditions) Not started tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ exists but its UnsTabDragDropE2ETests.cs is the only Playwright test; no Phase 7 Admin UI playwright scenario.

Shipped as PR #185 (13 Admin service tests; UI completeness is partial — see gaps section).

Stream G — Address-space integration

Plan item Status Evidence
G.1 — EquipmentNodeWalker extension emits NodeSourceKind.Virtual + NodeSourceKind.ScriptedAlarm variables Done PR #184; NodeSourceKind discriminator confirmed in exit gate
G.2 — DriverNodeManager dispatch routes reads by source; writes to non-Driver rejected with BadUserAccessDenied Done PR #186 follow-up; OpcUaApplicationHost.SetPhase7Sources threads _virtualReadable + _scriptedAlarmReadable into the node manager
G.3 — AlarmTracker composition (ScriptedAlarmEngine registers as additional IAlarmSource) Done ScriptedAlarmSource adapts engine to IAlarmSource; Phase7EngineComposer.Compose wires it
G.4 — Tests: mixed equipment folder browsable via Client.CLI; read/subscribe round-trip; alarm transitions in event stream Done Phase7ComposerMappingTests.cs, Phase7EngineComposerTests.cs, ScriptedAlarmReadableTests.cs, CachedTagUpstreamSourceTests.cs, DriverSubscriptionBridgeTests.cs — 6 test files in Server.Tests/Phase7/
OPC UA method binding for alarm Ack/Confirm/Shelve Not started Noted explicitly in phase-7-e2e-smoke.md §"Known limitations": DriverNodeManager.MethodCall dispatch for scripted alarm methods is not wired. Engine has the methods; the OPC UA call path does not reach them.

Shipped across PRs #184 + #186 (5 + 7 tests).

Stream H — Exit gate

Plan item Status Evidence
H.1 — Compliance script real-checks Done scripts/compliance/phase-7-compliance.ps1
H.2 — Full solution dotnet test baseline Done Exit gate records ~197 new tests + solution baseline
H.3 — plan.md Migration Strategy §6 update Not verified Not explicitly confirmed; minor — the plan doc is not the primary status artifact
H.4 — Phase-status memory update Done Memory updated (see project_alarms_over_gateway_epic.md + project_server_history_alarm_subsystems.md)
H.5 — Merge v2/phase-7-scripting-and-alarmingv2 Done All PRs (#177#186) merged

Post-gate follow-ups (tasks #239 / #240 / #241)

All three are verified closed in the 2026-04-23 exit-gate audit:

Task Item Status
#239 SealedBootstrap composition root — Phase7Composer.PrepareAsync + OpcUaServerService wiring Done
#240 Live OPC UA e2e smoke — scripts/e2e/test-phase7-virtualtags.ps1 Done (partial pass: 3/7 stages reach PASS; writer/subscribe/alarm stages blocked by live Galaxy attribute activity + Historian SDK environment)
#241 sp_ComputeGenerationDiff extension for Script / VirtualTag / ScriptedAlarm diff sections Done — migration 20260420232000_ExtendComputeGenerationDiffWithPhase7

What genuinely remains

These are real open items, not issues with the plan reconciliation.

Gap 1 — OPC UA method-call dispatch for scripted alarm methods (Stream G / C.6) — CLOSED

All Part 9 alarm methods now route to the ScriptedAlarmEngine. Acknowledge / Confirm / AddComment route via DriverNodeManager.RouteScriptedAlarmMethodCalls (task #24 + follow-up); AddComment gates at the AlarmAcknowledge tier. OneShotShelve / TimedShelve / Unshelve route via the native AlarmConditionState.OnShelve / OnTimedUnshelve hooks wired in MarkAsAlarmCondition, with the per-instance shelve method NodeIds indexed so the Call gate resolves them to OpcUaOperation.AlarmShelve.

Gap 2 — Admin UI: no /virtual-tags tab or form (Stream F.2)

VirtualTagService CRUD is fully tested but no razor page exposes it. Operators must author virtual tags through direct SQL or Admin API calls. ScriptsTab.razor covers script CRUD only; virtual-tag fields (EquipmentId, DataType, trigger config, Historize) have no UI form.

Gap 3 — Admin UI: no /scripted-alarms tab or form (Stream F.3)

ScriptedAlarmService CRUD is fully tested but no razor page exists. Only ScriptsTab.razor under the cluster detail view is present; there is no ScriptedAlarmsTab.razor or equivalent.

Gap 4 — Script log viewer not shipped (Stream F.5)

The SignalR tail of scripts-*.log filtered by ScriptName was not implemented. ScriptsTab.razor shows script output from the in-process harness but has no live-log panel for production emissions.

Gap 5 — Virtual-tag historization has no production sink (Stream B.6)

IHistoryWriter + NullHistoryWriter are present; VirtualTagEngine calls IHistoryWriter.Record per evaluation when Historize=true. Phase7EngineComposer.Compose passes NullHistoryWriter — no live writer is wired. Virtual-tag values are computed and served correctly but never persisted to any historian. Explicitly documented in docs/VirtualTags.md §"Upstream reads + history".


What is definitely done

  • All four Core.* projects (Core.Scripting, Core.VirtualTags, Core.ScriptedAlarms, Core.AlarmHistorian) ship with full implementation and test coverage.
  • Roslyn sandbox (allow-list + ForbiddenTypeAnalyzer defense-in-depth + 250 ms timeout + per-script Serilog sink + compile cache) is complete.
  • Virtual tag engine: dependency graph with iterative Tarjan SCC, topo-sort, change-trigger cascade, timer trigger, IReadable + ISubscribable adapter, per-tag error isolation.
  • Scripted alarm engine: full Part 9 state machine, startup recovery, template substitution, IAlarmSource fan-out, 5-second shelving timer, IAlarmStateStore (in-memory default; DB-backed via Config DB entities).
  • SQLite store-and-forward historian sink: drain loop with exponential backoff, dead-letter retention, bounded capacity, RetryDeadLettered operator action.
  • Config DB schema: Script, VirtualTag, ScriptedAlarm, ScriptedAlarmState tables with EF migrations and generation-diff extension.
  • Admin services: ScriptService, VirtualTagService, ScriptedAlarmService, ScriptTestHarnessService, HistorianDiagnosticsService — all backed by unit tests.
  • Admin UI: ScriptsTab.razor (Monaco-backed editor, dependency preview, test harness), AlarmsHistorian.razor (queue depth, drain state, retry dead-lettered).
  • Server-side composition: Phase7Composer, Phase7EngineComposer, CachedTagUpstreamSource, DriverSubscriptionBridge, ScriptedAlarmReadable — fully wired into OpcUaServerService startup sequence before OpcUaApplicationHost.StartAsync.
  • EquipmentNodeWalker emits NodeSourceKind.Virtual and NodeSourceKind.ScriptedAlarm variables; DriverNodeManager dispatches reads and rejects writes to virtual nodes.
  • WonderwareHistorianClient.WriteBatchAsync implements IAlarmHistorianWriter as the alarm-event write path (deviation from plan's Galaxy.Host route, but functionally equivalent).
  • Compliance script scripts/compliance/phase-7-compliance.ps1 and e2e smoke scripts/e2e/test-phase7-virtualtags.ps1 both present.

Evidence sources

Source Path
Phase 7 plan docs/v2/implementation/phase-7-scripting-and-alarming.md
Phase 7 exit gate docs/v2/implementation/exit-gate-phase-7.md
E2E smoke runbook docs/v2/implementation/phase-7-e2e-smoke.md
Virtual tags reference doc docs/VirtualTags.md
Scripted alarms reference doc docs/ScriptedAlarms.md
Core.Scripting sources src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/
Core.VirtualTags sources src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/
Core.ScriptedAlarms sources src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/
Core.AlarmHistorian sources src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/
Server Phase7 composition src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/
Admin services src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/Script*.cs, VirtualTagService.cs, HistorianDiagnosticsService.cs
Admin UI pages src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor, AlarmsHistorian.razor
Historian sidecar writer src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs
EF migrations src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.cs, 20260420232000_ExtendComputeGenerationDiffWithPhase7.cs