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

198 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-alarming``v2` | **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` |