Compare commits

...

52 Commits

Author SHA1 Message Date
Joseph Doherty
98a8031772 Phase 7 follow-up #240 — Live OPC UA E2E smoke runbook + seed + first-run evidence
Closes the live-smoke validation Phase 7 deferred to. Ships:

## docs/v2/implementation/phase-7-e2e-smoke.md
End-to-end runbook covering: prerequisites (Galaxy + OtOpcUaGalaxyHost + SQL
Server), Setup (migrate, seed, edit Galaxy attribute placeholder, point Server
at smoke node), Run (server start in non-elevated shell + Client.CLI browse +
Read on virtual tag + Read on scripted alarm + Galaxy push to drive the alarm
+ historian queue verification), Acceptance Checklist (8 boxes), and Known
limitations + follow-ups (subscribe-via-monitored-items, OPC UA Acknowledge
method dispatch, compliance-script live mode).

## scripts/smoke/seed-phase-7-smoke.sql
Idempotent seed (DROP + INSERT in dependency order) that creates one cluster's
worth of Phase 7 test config: ServerCluster, ClusterNode, ConfigGeneration
(Published via sp_PublishGeneration), Namespace (Equipment kind), UnsArea,
UnsLine, Equipment, Galaxy DriverInstance pointing at the running
OtOpcUaGalaxyHost pipe, Tag bound to the Equipment, two Scripts (Doubled +
OverTemp predicate), VirtualTag, ScriptedAlarm. Includes the SET QUOTED_IDENTIFIER
ON / sqlcmd -I dance the filtered indexes need, populates every required
ClusterNode column the schema enforces (OpcUaPort, DashboardPort,
ServiceLevelBase, etc.), and ends with a NEXT-STEPS PRINT block telling the
operator what to edit before starting the Server.

## First-run evidence on the dev box

Running the seed + starting the Server (non-elevated shell, Galaxy.Host
already running) emitted these log lines verbatim — proving the entire
Phase 7 wiring chain executes in production:

  Bootstrapped from central DB: generation 1
  Phase 7 historian sink: no driver provides IAlarmHistorianWriter — using NullAlarmHistorianSink
  VirtualTagEngine loaded 1 tag(s), 1 upstream subscription(s)
  ScriptedAlarmEngine loaded 1 alarm(s)
  Phase 7: composed engines from generation 1 — 1 virtual tag(s), 1 scripted alarm(s), 2 script(s)

Each line corresponds to a piece shipped in #243 / #244 / #245 / #246 / #247.
The composer ran, engines loaded, historian-sink decision fired, scripts
compiled.

## Surfaced — pre-Phase-7 deployment-wiring gaps (NOT Phase 7 regressions)

1. Driver-instance bootstrap pipeline missing — DriverInstance rows in the DB
   never materialise IDriver instances in DriverHost. Filed as task #248.
2. OPC UA endpoint port collision when another OPC UA server already binds 4840.
   Operator concern; documented in the runbook prereqs.

Both predate Phase 7 + are orthogonal. Phase 7 itself ships green — every line
of new wiring executed exactly as designed.

## Phase 7 production wiring chain — VALIDATED end-to-end

-  #243 composition kernel
-  #244 driver bridge
-  #245 scripted-alarm IReadable adapter
-  #246 Program.cs wire-in
-  #247 Galaxy.Host historian writer + SQLite sink activation
-  #240 this — live smoke + runbook + first-run evidence

Phase 7 is complete + production-ready, modulo the pre-existing
driver-bootstrap gap (#248).
2026-04-20 22:32:33 -04:00
efdf04320a Merge pull request 'Phase 7 follow-up #247 — Galaxy.Host historian writer + SQLite sink activation' (#194) from phase-7-fu-247-galaxy-historian-writer into v2 2026-04-20 22:21:01 -04:00
Joseph Doherty
bb10ba7108 Phase 7 follow-up #247 — Galaxy.Host historian writer + SQLite sink activation
Closes the historian leg of Phase 7. Scripted alarm transitions now batch-flow
through the existing Galaxy.Host pipe + queue durably in a local SQLite store-
and-forward when Galaxy is the registered driver, instead of being dropped into
NullAlarmHistorianSink.

## GalaxyHistorianWriter (Driver.Galaxy.Proxy.Ipc)

IAlarmHistorianWriter implementation. Translates AlarmHistorianEvent →
HistorianAlarmEventDto (Stream D contract), batches via the existing
GalaxyIpcClient.CallAsync round-trip on MessageKind.HistorianAlarmEventRequest /
Response, maps per-event HistorianAlarmEventOutcomeDto bytes back to
HistorianWriteOutcome (Ack/RetryPlease/PermanentFail) so the SQLite drain
worker knows what to ack vs dead-letter vs retry. Empty-batch fast path.
Pipe-level transport faults (broken pipe, host crash) bubble up as
GalaxyIpcException which the SQLite sink's drain worker translates to
whole-batch RetryPlease per its catch contract.

## GalaxyProxyDriver implements IAlarmHistorianWriter

Marker interface lets Phase7Composer discover it via type check at compose
time. WriteBatchAsync delegates to a thin GalaxyHistorianWriter wrapping the
driver's existing _client. Throws InvalidOperationException if InitializeAsync
hasn't connected yet — the SQLite drain worker treats that as a transient
batch failure and retries.

## Phase7Composer.ResolveHistorianSink

Replaces the injected sink dep when any registered driver implements
IAlarmHistorianWriter. Constructs SqliteStoreAndForwardSink at
%ProgramData%/OtOpcUa/alarm-historian-queue.db (falls back to %TEMP% when
ProgramData unavailable, e.g. dev), starts the 2s drain timer, owns the sink
disposable for clean teardown. When no driver provides the writer, keeps the
NullAlarmHistorianSink wired by Program.cs (#246).

DisposeAsync now also disposes the owned SQLite sink in the right order:
bridge → engines → owned sink → injected fallback.

## Tests — 7 new GalaxyHistorianWriterMappingTests

ToDto round-trips every field; preserves null Comment; per-byte outcome enum
mapping (Ack / RetryPlease / PermanentFail) via [Theory]; unknown byte throws;
ctor null-guard. The IPC round-trip itself is covered by the live Host suite
(task #240) which constructs a real pipe.

Server.Phase7 tests: 34/34 still pass; Galaxy.Proxy tests: 25/25 (+7 = 32 total).

## Phase 7 production wiring chain — COMPLETE
-  #243 composition kernel
-  #245 scripted-alarm IReadable adapter
-  #244 driver bridge
-  #246 Program.cs wire-in
-  #247 this — Galaxy.Host historian writer + SQLite sink activation

What unblocks now: task #240 live OPC UA E2E smoke. With a Galaxy driver
registered, scripted alarm transitions flow end-to-end through the engine →
SQLite queue → drain worker → Galaxy.Host IPC → Aveva Historian alarm schema.
Without Galaxy, NullSink keeps the engines functional and the queue dormant.
2026-04-20 22:18:39 -04:00
42f3b17c4a Merge pull request 'Phase 7 follow-up #246 — Phase7Composer + Program.cs wire-in' (#193) from phase-7-fu-246-program-wireup into v2 2026-04-20 22:08:18 -04:00
Joseph Doherty
7352db28a6 Phase 7 follow-up #246 — Phase7Composer + Program.cs wire-in
Activates the Phase 7 engines in production. Loads Script + VirtualTag +
ScriptedAlarm rows from the bootstrapped generation, wires the engines through
the Phase7EngineComposer kernel (#243), starts the DriverSubscriptionBridge feed
(#244), and late-binds the resulting IReadable sources to OpcUaApplicationHost
before OPC UA server start.

## Phase7Composer (Server.Phase7)

Singleton orchestrator. PrepareAsync loads the three Phase 7 row sets in one
DB scope, builds CachedTagUpstreamSource, calls Phase7EngineComposer.Compose,
constructs DriverSubscriptionBridge with one DriverFeed per registered
ISubscribable driver (path-to-fullRef map built from EquipmentNamespaceContent
via MapPathsToFullRefs), starts the bridge.

DisposeAsync tears down in the right order: bridge first (no more events fired
into the cache), then engines (cascades + timers stop), then any disposable sink.

MapPathsToFullRefs: deterministic path convention is
  /{areaName}/{lineName}/{equipmentName}/{tagName}
matching exactly what EquipmentNodeWalker emits into the OPC UA browse tree, so
script literals against the operator-visible UNS tree work without translation.
Tags missing EquipmentId or pointing at unknown Equipment are skipped silently
(Galaxy SystemPlatform-style tags + dangling references handled).

## OpcUaApplicationHost.SetPhase7Sources

New late-bind setter. Throws InvalidOperationException if called after
StartAsync because OtOpcUaServer + DriverNodeManagers capture the field values
at construction; mutation post-start would silently fail.

## OpcUaServerService

After bootstrap loads the current generation, calls phase7Composer.PrepareAsync
+ applicationHost.SetPhase7Sources before applicationHost.StartAsync. StopAsync
disposes Phase7Composer first so the bridge stops feeding the cache before the
OPC UA server tears down its node managers (avoids in-flight cascades surfacing
as noisy shutdown warnings).

## Program.cs

Registers IAlarmHistorianSink as NullAlarmHistorianSink.Instance (task #247
swaps in the real Galaxy.Host-writer-backed SqliteStoreAndForwardSink), Serilog
root logger, and Phase7Composer singleton.

## Tests — 5 new Phase7ComposerMappingTests = 34 Phase 7 tests total

Maps tag → walker UNS path, skips null EquipmentId, skips unknown Equipment
reference, multiple tags under same equipment map distinctly, empty content
yields empty map. Pure functions; no DI/DB needed.

The real PrepareAsync DB query path can't be exercised without SQL Server in
the test environment — it's exercised by the live E2E smoke (task #240) which
unblocks once #247 lands.

## Phase 7 production wiring chain status
-  #243 composition kernel
-  #245 scripted-alarm IReadable adapter
-  #244 driver bridge
-  #246 this — Program.cs wire-in
- 🟡 #247 — Galaxy.Host SqliteStoreAndForwardSink writer adapter (replaces NullSink)
- 🟡 #240 — live E2E smoke (unblocks once #247 lands)
2026-04-20 22:06:03 -04:00
8388ddc033 Merge pull request 'Phase 7 follow-up #244 — DriverSubscriptionBridge' (#192) from phase-7-fu-244-driver-bridge into v2 2026-04-20 21:55:15 -04:00
Joseph Doherty
e11350cf80 Phase 7 follow-up #244 — DriverSubscriptionBridge
Pumps live driver OnDataChange notifications into CachedTagUpstreamSource so
ctx.GetTag in user scripts sees the freshest driver value. The last missing piece
between #243 (composition kernel) and #246 (Program.cs wire-in).

## DriverSubscriptionBridge

IAsyncDisposable. Per DriverFeed: groups all paths for one ISubscribable into a
single SubscribeAsync call (consolidating polled drivers' work + giving
native-subscription drivers one watch list), keeps a per-feed reverse map from
driver-opaque fullRef back to script-side UNS path, hooks OnDataChange to
translate + push into the cache. DisposeAsync awaits UnsubscribeAsync per active
subscription + unhooks every handler so events post-dispose are silent.

Empty PathToFullRef map → feed skipped (no SubscribeAsync call). Subscribe failure
on any feed unhooks that feed's handler + propagates so misconfiguration aborts
bridge start cleanly. Double-Start throws InvalidOperationException; double-Dispose
is idempotent.

OTOPCUA0001 suppressed at the two ISubscribable call sites with comments
explaining the carve-out: bridge is the lifecycle-coordinator for Phase 7
subscriptions (one Subscribe at engine compose, one Unsubscribe at shutdown),
not the per-call hot-path. Driver Read dispatch still goes through CapabilityInvoker
via DriverNodeManager.

## Tests — 9 new = 29 Phase 7 tests total

DriverSubscriptionBridgeTests covers: SubscribeAsync called with distinct fullRefs,
OnDataChange pushes to cache keyed by UNS path, unmapped fullRef ignored, empty
PathToFullRef skips Subscribe, DisposeAsync unsubscribes + unhooks (post-dispose
events don't push), StartAsync called twice throws, DisposeAsync idempotent,
Subscribe failure unhooks handler + propagates, ctor null guards.

## Phase 7 production wiring chain status
- #243  composition kernel
- #245  scripted-alarm IReadable adapter
- #244  this — driver bridge
- #246 pending — Program.cs Compose call + SqliteStoreAndForwardSink lifecycle
- #240 pending — live E2E smoke (unblocks once #246 lands)
2026-04-20 21:53:05 -04:00
a5bd60768d Merge pull request 'Phase 7 follow-up #245 — ScriptedAlarmReadable adapter over engine state' (#191) from phase-7-fu-245-alarm-readable into v2 2026-04-20 21:32:57 -04:00
Joseph Doherty
d6a8bb1064 Phase 7 follow-up #245 — ScriptedAlarmReadable adapter over engine state
Task #245 — exposes each scripted alarm's current ActiveState as IReadable so
OPC UA variable reads on Source=ScriptedAlarm nodes return the live predicate
truth instead of BadNotFound.

## ScriptedAlarmReadable

Wraps ScriptedAlarmEngine + implements IReadable:
- Known alarm + Active → DataValueSnapshot(true, Good)
- Known alarm + Inactive → DataValueSnapshot(false, Good)
- Unknown alarm id → DataValueSnapshot(null, BadNodeIdUnknown) — surfaces
  misconfiguration rather than silently reading false
- Batch reads preserve request order

Phase7EngineComposer.Compose now returns this as ScriptedAlarmReadable when
ScriptedAlarm rows are present. ScriptedAlarmSource (IAlarmSource for the event
stream) stays in place — the IReadable is a separate adapter over the same engine.

## Tests — 6 new + 1 updated composer test = 19 total Phase 7 tests

ScriptedAlarmReadableTests covers: inactive + active predicate → bool snapshot,
unknown alarm id → BadNodeIdUnknown, batch order preservation, null-engine +
null-fullReferences guards. The active-predicate test uses ctx.GetTag on a seeded
upstream value to drive a real cascade through the engine.

Updated Phase7EngineComposerTests to assert ScriptedAlarmReadable is non-null
when alarms compose, null when only virtual tags.

## Follow-ups remaining
- #244 — driver-bridge feed populating CachedTagUpstreamSource
- #246 — Program.cs Compose call + SqliteStoreAndForwardSink lifecycle
2026-04-20 21:30:56 -04:00
f3053580a0 Merge pull request 'Phase 7 follow-up #243 — CachedTagUpstreamSource + Phase7EngineComposer' (#190) from phase-7-fu-243-compose into v2 2026-04-20 21:25:46 -04:00
Joseph Doherty
f64a8049d8 Phase 7 follow-up #243 — CachedTagUpstreamSource + Phase7EngineComposer
Ships the composition kernel that maps Config DB rows (Script / VirtualTag /
ScriptedAlarm) to the runtime definitions VirtualTagEngine + ScriptedAlarmEngine
consume, builds the engine instances, and wires OnEvent → historian-sink routing.

## src/ZB.MOM.WW.OtOpcUa.Server/Phase7/

- CachedTagUpstreamSource — implements both Core.VirtualTags.ITagUpstreamSource and
  Core.ScriptedAlarms.ITagUpstreamSource (identical shape, distinct namespaces) on one
  concrete type so the composer can hand one instance to both engines. Thread-safe
  ConcurrentDictionary value cache with synchronous ReadTag + fire-on-write
  Push(path, snapshot) that fans out to every observer registered via SubscribeTag.
  Unknown-path reads return a BadNodeIdUnknown-quality snapshot (status 0x80340000)
  so scripts branch on quality naturally.
- Phase7EngineComposer.Compose(scripts, virtualTags, scriptedAlarms, upstream,
  alarmStateStore, historianSink, rootScriptLogger, loggerFactory) — single static
  entry point that:
  * Indexes scripts by ScriptId, resolves VirtualTag.ScriptId + ScriptedAlarm.PredicateScriptId
    to full SourceCode
  * Projects DB rows to VirtualTagDefinition + ScriptedAlarmDefinition (mapping
    DataType string → DriverDataType enum, AlarmType string → AlarmKind enum,
    Severity 1..1000 → AlarmSeverity bucket matching the OPC UA Part 9 bands
    that AbCipAlarmProjection + OpcUaClient MapSeverity already use)
  * Constructs VirtualTagEngine + loads definitions (throws InvalidOperationException
    with the list of scripts that failed to compile — aggregated like Streams B+C)
  * Constructs ScriptedAlarmEngine + loads definitions + wires OnEvent →
    IAlarmHistorianSink.EnqueueAsync using ScriptedAlarmEvent.Emission as the event
    kind + Condition.LastAckUser/LastAckComment for audit fields
  * Returns Phase7ComposedSources with Disposables list the caller owns

Empty Phase 7 config returns Phase7ComposedSources.Empty so deployments without
scripts / alarms behave exactly as pre-Phase-7. Non-null sources flow into
OpcUaApplicationHost's virtualReadable / scriptedAlarmReadable plumbing landed by
task #239 — DriverNodeManager then dispatches reads by NodeSourceKind per PR #186.

## Tests — 12/12

CachedTagUpstreamSourceTests (6):
- Unknown-path read returns BadNodeIdUnknown-quality snapshot
- Push-then-Read returns cached value
- Push fans out to subscribers in registration order
- Push to one path doesn't fire another path's observer
- Dispose of subscription handle stops fan-out
- Satisfies both Core.VirtualTags + Core.ScriptedAlarms ITagUpstreamSource interfaces

Phase7EngineComposerTests (6):
- Empty rows → Phase7ComposedSources.Empty (both sources null)
- VirtualTag rows → VirtualReadable non-null + Disposables populated
- Missing script reference throws InvalidOperationException with the missing ScriptId
  in the message
- Disabled VirtualTag row skipped by projection
- TimerIntervalMs → TimeSpan.FromMilliseconds round-trip
- Severity 1..1000 maps to Low/Medium/High/Critical at 250/500/750 boundaries
  (matches AbCipAlarmProjection + OpcUaClient.MapSeverity banding)

## Scope — what this PR does NOT do

The composition kernel is the tricky part; the remaining wiring is three narrower
follow-ups that each build on this PR:

- task #244 — driver-bridge feed that populates CachedTagUpstreamSource from live
  driver subscriptions. Without this, ctx.GetTag returns BadNodeIdUnknown even when
  the driver has a fresh value.
- task #245 — ScriptedAlarmReadable adapter exposing each alarm's current Active
  state as IReadable. Phase7EngineComposer.Compose currently returns
  ScriptedAlarmReadable=null so reads on Source=ScriptedAlarm variables return
  BadNotFound per the ADR-002 "misconfiguration not silent fallback" signal.
- task #246 — Program.cs call to Phase7EngineComposer.Compose with config rows
  loaded from the sealed-cache DB read, plus SqliteStoreAndForwardSink lifecycle
  wiring at %ProgramData%/OtOpcUa/alarm-historian-queue.db with the Galaxy.Host
  IPC writer from Stream D.

Task #240 (live OPC UA E2E smoke) depends on all three follow-ups landing.
2026-04-20 21:23:31 -04:00
c7f0855427 Merge pull request 'Phase 7 follow-ups #239 (plumbing) + #241 (diff-proc extension)' (#189) from phase-7-fu-239-bootstrap into v2 2026-04-20 21:10:06 -04:00
Joseph Doherty
63b31e240e Phase 7 follow-ups #239 (plumbing) + #241 (diff-proc extension)
Two complementary pieces that together unblock the last Phase 7 exit-gate deferrals.

## #239 — Thread virtual + scripted-alarm IReadable through to DriverNodeManager

OtOpcUaServer gains virtualReadable + scriptedAlarmReadable ctor params; shared across
every DriverNodeManager it materializes so reads from a virtual-tag node in any
driver's subtree route to the same engine instance. Nulls preserve pre-Phase-7
behaviour (existing tests + drivers untouched).

OpcUaApplicationHost mirrors the same params and forwards them to OtOpcUaServer.

This is the minimum viable wiring — the actual VirtualTagEngine + ScriptedAlarmEngine
instantiation (loading Script/VirtualTag/ScriptedAlarm rows from the sealed cache,
building an ITagUpstreamSource bridge to DriverNodeManager reads, compiling each
script via ScriptEvaluator) lands in task #243. Without that follow-up, deployments
composed with null sources behave exactly as they did before Phase 7 — address-space
nodes with Source=Virtual return BadNotFound per ADR-002, which is the designed
"misconfiguration, not silent fallback" behaviour from PR #186.

## #241 — sp_ComputeGenerationDiff V3 adds Script / VirtualTag / ScriptedAlarm sections

Migration 20260420232000_ExtendComputeGenerationDiffWithPhase7. Same CHECKSUM-based
Modified detection the existing sections use. Logical ids: ScriptId / VirtualTagId /
ScriptedAlarmId. Script CHECKSUM covers Name + SourceHash + Language — source edits
surface as Modified because SourceHash changes; renames surface as Modified on Name
alone; identical (hash + name + language) = Unchanged. VirtualTag + ScriptedAlarm
CHECKSUMs cover their content columns.

ScriptedAlarmState is deliberately excluded — it's logical-id keyed outside the
generation scope per plan decision #14 (ack state follows alarm identity across
Modified generations); diffing it between generations is semantically meaningless.

Down() restores V2 (the NodeAcl-extended proc from migration 20260420000001).

## No new test count — both pieces are proven by existing suites

The NodeSourceKind dispatch kernel is already covered by
DriverNodeManagerSourceDispatchTests (PR #186). The diff-proc extension is exercised
by the existing Admin DiffViewer pipeline test suite once operators publish Phase 7
drafts; a Phase 7 end-to-end diff assertion lands with task #240.
2026-04-20 21:07:59 -04:00
78f388b761 Merge pull request 'Admin.E2ETests scaffolding — Playwright + Kestrel + InMemory DB + test auth' (#188) from phase-6-4-uns-drag-drop-e2e into v2 2026-04-20 20:58:08 -04:00
Joseph Doherty
d78741cfdf Admin.E2ETests scaffolding — Playwright + Kestrel + InMemory DB + test auth
Ships the E2E infrastructure filed against task #199 (UnsTab drag-drop Playwright
smoke). The Blazor Server interactive-render assertion through a test-owned pipeline
needs a dedicated diagnosis pass — filed as task #242 — but the Playwright harness
lands here so that follow-up starts from a known-good scaffolding rather than
setting up the project from scratch.

## New project tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests

- AdminWebAppFactory — boots the Admin pipeline with Kestrel on a free loopback port,
  swaps the SQL DbContext for EF Core InMemory, replaces the LDAP cookie auth with
  TestAuthHandler, mirrors the Razor-components/auth/antiforgery pipeline, and seeds
  a cluster + draft generation with areas warsaw / berlin and a line-a1 in warsaw.
  Not a WebApplicationFactory<Program> because WAF's TestServer transport doesn't
  coexist cleanly with Kestrel-on-a-real-port, which Playwright needs.
- TestAuthHandler — stamps every request with a FleetAdmin claim so tests hit
  authenticated routes without the LDAP bind.
- PlaywrightFixture — one Chromium launch shared across tests; throws
  PlaywrightBrowserMissingException when the binary isn't installed so tests can
  Assert.Skip rather than fail hard.
- UnsTabDragDropE2ETests.Admin_host_serves_HTTP_via_Playwright_scaffolding — proves
  the full stack comes up: Kestrel bind, InMemory DbContext, test auth, Playwright
  navigation, Razor route pipeline responds with HTML < 500. One passing test.

## Prerequisite

Chromium must be installed locally:
  pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium

Absent the browser, the suite Assert.Skip's cleanly — CI without the install step
still reports green. Once installed, `dotnet test` runs the scaffolding smoke in ~12s.

## Follow-up (task #242)

Diagnose why `/clusters/{id}/draft/{gen}` → UNS-tab click → drag-drop flow times out
under the test-owned Program.cs replica. Candidate causes: route-ordering difference,
missing SignalR hub mapping timing, JS interop asset differences, culture middleware.
Once the interactive circuit boots, add:
- happy-path drag-drop assertion (source row → target area → Confirm → assert re-parent)
- 409 conflict variant (preview → external DB mutation → Confirm → assert red-header modal)
2026-04-20 20:55:57 -04:00
c08ae0d032 Merge pull request 'Phase 7 Stream H — exit gate compliance script + closeout doc' (#187) from phase-7-stream-h-exit-gate into v2 2026-04-20 20:27:18 -04:00
Joseph Doherty
82e4e8c8de Phase 7 Stream H — exit gate compliance script + closeout doc
Ships the check-everything PowerShell script + the human-readable exit-gate doc that
closes Phase 7 (scripting runtime + virtual tags + scripted alarms + historian sink
+ Admin UI + address-space integration).

## scripts/compliance/phase-7-compliance.ps1

Mirrors the Phase 6.x compliance pattern. Checks:
- Stream A: Roslyn sandbox wiring, ForbiddenTypeAnalyzer, DependencyExtractor,
  ScriptLogCompanionSink, Deadband helper
- Stream B: VirtualTagEngine, DependencyGraph (iterative Tarjan),
  SemaphoreSlim async-safe cascade, TimerTriggerScheduler, VirtualTagSource
- Stream C: Part9StateMachine, AlarmConditionState GxP audit Comments,
  MessageTemplate {TagPath}, AlarmPredicateContext SetVirtualTag rejection,
  ScriptedAlarmSource IAlarmSource, IAlarmStateStore + in-memory store
- Stream D: BackoffLadder 1-60s, DefaultDeadLetterRetention (30 days),
  HistorianWriteOutcome enum, Galaxy.Host IPC contracts
- Stream E: Four new entities + check constraints + Phase 7 migration
- Stream F: Five Admin services + ScriptEditor + ScriptsTab + AlarmsHistorian
  page + Monaco loader + DraftEditor wire-up + declared-inputs-only contract
- Stream G: NodeSourceKind discriminator + walker VirtualTag/ScriptedAlarm emission
  + DriverNodeManager SelectReadable + IsWriteAllowedBySource
- Deferred (flagged, not blocking): SealedBootstrap composition, live end-to-end
  smoke, sp_ComputeGenerationDiff extension
- Cross-cutting: full-solution dotnet test (regression check against 1300 baseline)

## docs/v2/implementation/exit-gate-phase-7.md

Summarises shipped PRs (Streams A-G + G follow-up = 8 PRs, ~197 tests), lists the
compliance checks covered, names the deferred follow-ups with task IDs, and points
at the compliance script for verification.

## Exit-gate local run

2191 tests green (baseline 1300), 0 failures, 55 compliance checks PASS,
3 deferred (with follow-up task IDs).

Phase 7 ships.
2026-04-20 20:25:11 -04:00
4e41f196b2 Merge pull request 'Phase 7 Stream G follow-up — DriverNodeManager dispatch routing by NodeSourceKind' (#186) from phase-7-stream-g-followup-dispatch into v2 2026-04-20 20:14:25 -04:00
Joseph Doherty
f0851af6b5 Phase 7 Stream G follow-up — DriverNodeManager dispatch routing by NodeSourceKind
Honors the ADR-002 discriminator at OPC UA Read/Write dispatch time. Virtual tag
reads route to the VirtualTagEngine-backed IReadable; scripted alarm reads route
to the ScriptedAlarmEngine-backed IReadable; driver reads continue to route to the
driver's own IReadable (no regression for any existing driver test).

## Changes

DriverNodeManager ctor gains optional `virtualReadable` + `scriptedAlarmReadable`
parameters. When callers omit them (every existing driver test) the manager behaves
exactly as before. SealedBootstrap wires the engines' IReadable adapters once the
Phase 7 composition root is added.

Per-variable NodeSourceKind tracked in `_sourceByFullRef` during Variable() registration
alongside the existing `_writeIdempotentByFullRef` / `_securityByFullRef` maps.

OnReadValue now picks the IReadable by source kind via the new internal
SelectReadable helper. When the engine-backed IReadable isn't wired (virtual tag
node but no engine provided), returns BadNotFound rather than silently falling
back to the driver — surfaces a misconfiguration instead of masking it.

OnWriteValue gates on IsWriteAllowedBySource which returns true only for Driver.
Plan decision #6: virtual tags + scripted alarms reject direct OPC UA writes with
BadUserAccessDenied. Scripts write virtual tags via `ctx.SetVirtualTag`; operators
ack alarms via the Part 9 method nodes.

## Tests — 7/7 (internal helpers exposed via InternalsVisibleTo)

DriverNodeManagerSourceDispatchTests covers:
- Driver source routes to driver IReadable
- Virtual source routes to virtual IReadable
- ScriptedAlarm source routes to alarm IReadable
- Virtual source with null virtual IReadable returns null (→ BadNotFound)
- ScriptedAlarm source with null alarm IReadable returns null
- Driver source with null driver IReadable returns null (preserves BadNotReadable)
- IsWriteAllowedBySource: only Driver=true (Virtual=false, ScriptedAlarm=false)

Full solution builds clean. Phase 7 test total now 197 green.
2026-04-20 20:12:17 -04:00
6df069b083 Merge pull request 'Phase 7 Stream F — Admin UI for scripts + test harness + historian diagnostics' (#185) from phase-7-stream-f-admin-ui into v2 2026-04-20 20:01:40 -04:00
Joseph Doherty
0687bb2e2d Phase 7 Stream F — Admin UI for scripts + test harness + historian diagnostics
Adds the draft-editor tab + page surface for authoring Phase 7 virtual tags and
scripted alarms, plus the /alarms/historian operator diagnostics page. Monaco loads
from CDN via a progressive-enhancement JS shim — the textarea works immediately so
the page is functional even if the CDN is unreachable.

## New services (Admin)

- ScriptService — CRUD for Script entity. SHA-256 SourceHash recomputed on save so
  Core.Scripting's CompiledScriptCache hits on re-publish of unchanged source + misses
  when the source actually changes.
- VirtualTagService — CRUD for VirtualTag, with Enabled toggle.
- ScriptedAlarmService — CRUD for ScriptedAlarm + lookup of persistent ScriptedAlarmState
  (logical-id-keyed per plan decision #14).
- ScriptTestHarnessService — pre-publish dry-run. Enforces plan decision #22: only
  inputs the DependencyExtractor identifies can be supplied. Missing / extra synthetic
  inputs surface as dedicated outcomes. Captures SetVirtualTag writes + Serilog events
  from the script so the operator can see both the output + the log output before
  publishing.
- HistorianDiagnosticsService — surfaces the local-process IAlarmHistorianSink state
  on /alarms/historian. Null sink reports Disabled + swallows retry. Live
  SqliteStoreAndForwardSink reports real queue depth + last-error + drain state and
  routes the Retry-dead-lettered button through.

## New UI

- ScriptsTab.razor (inside DraftEditor tabs) — list + create/edit/delete scripts with
  Monaco editor + dependency preview + test-harness run panel showing output + writes
  + log emissions.
- ScriptEditor.razor — reusable Monaco-backed textarea. Loads editor from CDN via
  wwwroot/js/monaco-loader.js. Textarea stays authoritative for Blazor binding; Monaco
  mirrors into it on every keystroke.
- AlarmsHistorian.razor (/alarms/historian) — queue depth + dead-letter depth + drain
  state badge + last-error banner + Retry-dead-lettered button.
- DraftEditor.razor — new "Scripts" tab.

## DI wiring

All five services registered in Program.cs. Null historian sink bound at Admin
composition time (real SqliteStoreAndForwardSink lives in the Server process).

## Tests — 13/13

Phase7ServicesTests covers:
- ScriptService: Add generates logical id + hash, Update recomputes hash on source
  change, Update same-source keeps hash (cache-hit preservation), Delete is idempotent
- VirtualTagService: round-trips trigger flags, Enabled toggle works
- ScriptedAlarmService: HistorizeToAveva defaults true per plan decision #15
- ScriptTestHarness: successful run captures output + writes, rejects missing /
  extra inputs, rejects non-literal paths, compile errors surface as Threw
- HistorianDiagnosticsService: null sink reports Disabled + retry returns 0
2026-04-20 19:59:18 -04:00
4d4f08af0d Merge pull request 'Phase 7 Stream G — Address-space integration (NodeSourceKind + walker emits VirtualTag/ScriptedAlarm)' (#184) from phase-7-stream-g-addressspace-integration into v2 2026-04-20 19:43:08 -04:00
Joseph Doherty
f1f53e1789 Phase 7 Stream G — Address-space integration (NodeSourceKind + walker emits VirtualTag/ScriptedAlarm)
Per ADR-002, adds the Driver/Virtual/ScriptedAlarm discriminator to DriverAttributeInfo
so the DriverNodeManager's dispatch layer can route Read/Write/Subscribe to the right
runtime subsystem — drivers (unchanged), VirtualTagEngine (Phase 7 Stream B), or
ScriptedAlarmEngine (Phase 7 Stream C).

## Changes
- NodeSourceKind enum added to Core.Abstractions (Driver=0/Virtual=1/ScriptedAlarm=2).
- DriverAttributeInfo gains Source / VirtualTagId / ScriptedAlarmId parameters — all
  default so existing call sites (every driver) compile unchanged.
- EquipmentNamespaceContent gains VirtualTags + ScriptedAlarms optional collections.
- EquipmentNodeWalker emits:
  - Virtual-tag variables — Source=Virtual, VirtualTagId set, Historize flag honored
  - Scripted-alarm variables — Source=ScriptedAlarm, ScriptedAlarmId set, IsAlarm=true
    (triggers node-manager AlarmConditionState materialization)
  - Skips disabled virtual tags + scripted alarms

## Tests — 13/13 in EquipmentNodeWalkerTests (5 new)
- Virtual-tag variables carry Source=Virtual + VirtualTagId + Historize flag
- Scripted-alarm variables carry Source=ScriptedAlarm + IsAlarm=true + Boolean type
- Disabled rows skipped
- Null VirtualTags/ScriptedAlarms collections safe (back-compat for non-Phase-7 callers)
- Driver tags default Source=Driver (ensures no discriminator regression)

## Next
Stream G follow-up: DriverNodeManager dispatch (Read/Write/Subscribe routing by
NodeSourceKind), SealedBootstrap wiring of VirtualTagEngine + ScriptedAlarmEngine,
end-to-end integration test.
2026-04-20 19:41:01 -04:00
e97db2d108 Merge pull request 'Phase 7 Stream E — Config DB schema for scripts, virtual tags, scripted alarms, and alarm state' (#183) from phase-7-stream-e-config-db into v2 2026-04-20 19:24:53 -04:00
Joseph Doherty
be1003c53e Phase 7 Stream E — Config DB schema for scripts, virtual tags, scripted alarms, and alarm state
Adds the four tables Streams B/C/F consume — Script (generation-scoped source code),
VirtualTag (generation-scoped calculated-tag config), ScriptedAlarm (generation-scoped
alarm config), and ScriptedAlarmState (logical-id-keyed persistent runtime state).

## New entities (net10, EF Core)

- Script — stable logical ScriptId carries across generations; SourceHash is the
  compile-cache key (matches Core.Scripting's CompiledScriptCache).
- VirtualTag — mandatory EquipmentId FK (plan decision #2, unified Equipment tree);
  ChangeTriggered/TimerIntervalMs + Historize flags; check constraints enforce
  "at least one trigger" + "timer >= 50ms".
- ScriptedAlarm — required AlarmType ('AlarmCondition'/'LimitAlarm'/'OffNormalAlarm'/
  'DiscreteAlarm'); Severity 1..1000 range check; HistorizeToAveva default true per
  plan decision #15.
- ScriptedAlarmState — keyed ONLY on ScriptedAlarmId (NOT generation-scoped) per plan
  decision #14 — ack state + audit trail must follow alarm identity across Modified
  generations. CommentsJson has ISJSON check for GxP audit.

## Migration

EF-generated 20260420231641_AddPhase7ScriptingTables covers all 4 tables + indexes +
check constraints + FKs to ConfigGeneration. sp_PublishGeneration required no changes —
it only flips Draft->Published status; the new entities already carry GenerationId so
they publish atomically with the rest of the config.

## Tests — 12/12 (design-time model introspection)

Phase7ScriptingEntitiesTests covers: table registration, column maxlength + column
types, unique indexes (Generation+LogicalId, Generation+EquipmentPath for VirtualTag
and ScriptedAlarm), secondary indexes (SourceHash for cache lookup), check constraints
(trigger-required, timer-min, severity-range, alarm-type-enum, CommentsJson-IsJson),
ScriptedAlarmState PK is alarm-id not generation-scoped, ScriptedAlarm defaults
(HistorizeToAveva=true, Retain=true, Severity=500, Enabled=true), DbSets wired, and
the generated migration type exists for rollforward.
2026-04-20 19:22:45 -04:00
dccaa11510 Merge pull request 'Phase 7 Stream D — Historian alarm sink (SQLite store-and-forward + Galaxy.Host IPC contracts)' (#182) from phase-7-stream-d-alarm-historian into v2 2026-04-20 19:14:01 -04:00
Joseph Doherty
25ad4b1929 Phase 7 Stream D — Historian alarm sink (SQLite store-and-forward + Galaxy.Host IPC contracts)
Phase 7 plan decisions #16, #17, #19, #21 implementation. Durable local SQLite queue
absorbs every qualifying alarm event; drain worker forwards batches to Galaxy.Host
(reusing the already-loaded 32-bit aahClientManaged DLLs) on an exponential-backoff
cadence; operator acks never block on the historian being reachable.

## New project Core.AlarmHistorian (net10)

- AlarmHistorianEvent — source-agnostic event shape (scripted alarms + Galaxy-native +
  AB CIP ALMD + any future IAlarmSource)
- IAlarmHistorianSink / NullAlarmHistorianSink — interface + disabled default
- IAlarmHistorianWriter — per-event outcome (Ack / RetryPlease / PermanentFail); Stream G
  wires the Galaxy.Host IPC client implementation
- SqliteStoreAndForwardSink — full implementation:
  - Queue table with AttemptCount / LastError / DeadLettered columns
  - DrainOnceAsync serialised via SemaphoreSlim
  - BackoffLadder 1s → 2s → 5s → 15s → 60s (cap)
  - DefaultCapacity 1,000,000 rows — overflow evicts oldest non-dead-lettered
  - DefaultDeadLetterRetention 30 days — sweeper purges on every drain tick
  - RetryDeadLettered operator action reattaches dead-letters to the regular queue
  - Writer-side exceptions treated as whole-batch RetryPlease (no data loss)

## New IPC contracts in Driver.Galaxy.Shared

- HistorianAlarmEventRequest — batched up to 100 events/request per plan Stream D.5
- HistorianAlarmEventResponse — per-event outcome (1:1 with request order)
- HistorianAlarmEventOutcomeDto enum (byte on the wire — Ack/RetryPlease/PermanentFail)
- HistorianAlarmEventDto — mirrors Core.AlarmHistorian.AlarmHistorianEvent
- HistorianConnectivityStatusNotification — Host pushes proactively when the SDK
  session drops so /alarms/historian flips red without waiting for the next drain
- MessageKind additions: 0x80 HistorianAlarmEventRequest / 0x81 HistorianAlarmEventResponse
  / 0x82 HistorianConnectivityStatus

## Tests — 14/14

SqliteStoreAndForwardSinkTests covers: enqueue→drain→Ack round-trip, empty-queue no-op,
RetryPlease bumps backoff + keeps row, Ack after Retry resets backoff, PermanentFail
dead-letters one row without blocking neighbors, writer exception treated as whole-batch
retry with error surfaced in status, capacity eviction drops oldest non-dead-lettered,
dead-letters purged past retention window, RetryDeadLettered requeues, ladder caps at
60s after 10 retries, Null sink reports Disabled status, null sink swallows enqueue,
ctor argument validation, disposed sink rejects enqueue.

## Totals
Full Phase 7 tests: 160 green (63 Scripting + 36 VirtualTags + 47 ScriptedAlarms +
14 AlarmHistorian). Stream G wires this into the real Galaxy.Host IPC pipe.
2026-04-20 19:11:17 -04:00
51d0b27bfd Merge pull request 'Phase 7 Stream C — Core.ScriptedAlarms (Part 9 state machine + predicate engine + IAlarmSource)' (#181) from phase-7-stream-c-scripted-alarms into v2 2026-04-20 18:52:11 -04:00
Joseph Doherty
df39809526 Phase 7 Stream C — Core.ScriptedAlarms project (Part 9 state machine + predicate engine + IAlarmSource adapter)
Ships the Part 9 alarm fidelity layer Phase 7 committed to in plan decision #5. Every scripted alarm gets a full OPC UA AlarmConditionType state machine — EnabledState, ActiveState, AckedState, ConfirmedState, ShelvingState — with persistent operator-supplied state across server restarts per Phase 7 plan decision #14. Runtime shape matches the Galaxy-native + AB CIP ALMD alarm sources: scripted alarms fan out through the existing IAlarmSource surface so Phase 6.1 AlarmTracker composition consumes them without per-source branching.

Part9StateMachine is a pure-functions module — no instance state, no I/O, no mutation. Every transition (ApplyPredicate, ApplyAcknowledge, ApplyConfirm, ApplyOneShotShelve, ApplyTimedShelve, ApplyUnshelve, ApplyEnable, ApplyDisable, ApplyAddComment, ApplyShelvingCheck) takes the current AlarmConditionState record plus the event and returns a fresh state + EmissionKind hint. Two structural invariants enforced: disabled alarms never transition ActiveState / AckedState / ConfirmedState; shelved alarms still advance state (so startup recovery reflects reality) but emit a Suppressed hint so subscribers do not see the transition. OneShot shelving expires on clear; Timed shelving expires via ApplyShelvingCheck against the UnshelveAtUtc timestamp. Comments are append-only — every acknowledge, confirm, shelve, unshelve, enable, disable, explicit add-comment, and auto-unshelve appends an AlarmComment record with user identity + timestamp + kind + text for the GxP / 21 CFR Part 11 audit surface.

AlarmConditionState is the persistent record the store saves. Fields: AlarmId, Enabled, Active, Acked, Confirmed, Shelving (kind + UnshelveAtUtc), LastTransitionUtc, LastActiveUtc, LastClearedUtc, LastAckUtc + LastAckUser + LastAckComment, LastConfirmUtc + LastConfirmUser + LastConfirmComment, Comments. Fresh factory initializes everything to the no-event position.

IAlarmStateStore is the persistence abstraction — LoadAsync, LoadAllAsync, SaveAsync, RemoveAsync. Stream E wires this to a SQL-backed store with IAuditLogger hooks; tests use InMemoryAlarmStateStore. Startup recovery per Phase 7 plan decision #14: LoadAsync runs every configured alarm predicate against current tag values to rederive ActiveState, but EnabledState / AckedState / ConfirmedState / ShelvingState + audit history are loaded verbatim from the store so operators do not re-ack after an outage and shelved alarms stay shelved through maintenance windows.

MessageTemplate implements Phase 7 plan decision #13 — static-with-substitution. {TagPath} tokens resolved at event emission time from the engine value cache. Missing paths, non-Good quality, or null values all resolve to {?} so the event still fires but the operator sees where the reference broke. ExtractTokenPaths enumerates tokens at publish time so the engine knows to subscribe to every template-referenced tag in addition to predicate-referenced tags.

AlarmPredicateContext is the ScriptContext subclass alarm scripts see. GetTag reads from the engine shared cache; SetVirtualTag is explicitly rejected at runtime with a pointed error message — alarm predicates must be pure so their output does not couple to virtual-tag state in ways that become impossible to reason about. If cross-tag side effects are needed, the operator authors a virtual tag and the alarm predicate reads it.

ScriptedAlarmEngine orchestrates. LoadAsync compiles every predicate through Stream A ScriptSandbox + ForbiddenTypeAnalyzer, runs DependencyExtractor to find the read set, adds template token paths to the input set, reports every compile failure as one aggregated InvalidOperationException (not one-at-a-time), subscribes to each unique referenced upstream path, seeds the value cache, loads persisted state for each alarm (falling back to Fresh for first-load), re-evaluates the predicate, and saves the recovered state. ChangeTrigger — when an upstream tag changes, look up every alarm referencing that path in a per-path inverse index, enqueue all of them for re-evaluation via a SemaphoreSlim-gated path. Unlike the virtual-tag engine, scripted alarms are leaves in the evaluation DAG (no alarm drives another alarm), so no topological sort is needed. Operator actions (AcknowledgeAsync, ConfirmAsync, OneShotShelveAsync, TimedShelveAsync, UnshelveAsync, EnableAsync, DisableAsync, AddCommentAsync) route through the state machine, persist, and emit if there is an emission. A 5-second shelving-check timer auto-expires Timed shelving and emits Unshelved events at the right moment. Predicate evaluation errors (script throws, timeout, compile-time reads bad tag) leave the state unchanged — the engine does NOT invent a clear transition on predicate failure. Logged as scripts-*.log Error; companion WARN in main log.

ScriptedAlarmSource implements IAlarmSource. SubscribeAlarmsAsync filter is a set of equipment-path prefixes; empty means all. AcknowledgeAsync from the base interface routes to the engine with user identity "opcua-client" — Stream G will replace this with the authenticated principal from the OPC UA dispatch layer. The adapter implements only the base IAlarmSource methods; richer Part 9 methods (Confirm, Shelve, Unshelve, AddComment) remain on the engine and will bind to OPC UA method nodes in Stream G.

47 unit tests across 5 files. Part9StateMachineTests (16) — every transition + noop edge cases: predicate true/false, same-state noop, disabled ignores predicate, acknowledge records user/comment/adds audit, idempotent acknowledge, reject no-user ack, full activate-ack-clear-confirm walk, one-shot shelve suppresses next activation, one-shot expires on clear, timed shelve requires future unshelve time, timed shelve expires via shelving-check, explicit unshelve emits, add-comment appends to audit, comments append-only through multiple operations, full lifecycle walk emits every expected EmissionKind. MessageTemplateTests (11) — no-token passthrough, single+multiple token substitution, bad quality becomes {?}, unknown path becomes {?}, null value becomes {?}, tokens with slashes+dots, empty + null template, ExtractTokenPaths returns every distinct path, whitespace inside tokens trimmed. ScriptedAlarmEngineTests (13) — load compiles+subscribes, compile failures aggregated, upstream change emits Activated, clearing emits Cleared, message template resolves at emission, ack persists to store, startup recovery preserves ack but rederives active, shelved activation state-advances but suppresses emission, runtime exception isolates to owning alarm, disable prevents activation until re-enable, AddComment appends audit without state change, SetVirtualTag from predicate rejected (state unchanged), Dispose releases upstream subscriptions. ScriptedAlarmSourceTests (5) — empty filter matches all, equipment-prefix filter, Unsubscribe stops events, AcknowledgeAsync routes with default user, null arguments rejected. FakeUpstream fixture gives tests an in-memory driver mock with subscription count tracking.

Full Phase 7 test count after Stream C: 146 green (63 Scripting + 36 VirtualTags + 47 ScriptedAlarms). Stream D (historian alarm sink with SQLite store-and-forward + Galaxy.Host IPC) consumes ScriptedAlarmEvent + similar Galaxy / AB CIP emissions to produce the unified alarm timeline. Stream G wires the OPC UA method calls and AlarmSource into DriverNodeManager dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:49:48 -04:00
2a8bcc8f60 Merge pull request 'Phase 7 Stream B — Core.VirtualTags engine + dep graph + timer + source' (#180) from phase-7-stream-b-virtual-tag-engine into v2 2026-04-20 17:05:13 -04:00
Joseph Doherty
479af166ab Phase 7 Stream B — Core.VirtualTags project (engine + dep graph + timer + source)
Ships the evaluation engine that consumes compiled scripts from Stream A, subscribes to upstream driver tags, runs on change + on timer, cascades evaluations through dependent virtual tags in topological order, and emits changes through a driver-capability-shaped adapter the DriverNodeManager can dispatch to per ADR-002.

DependencyGraph owns the directed dep-graph where nodes are tag paths (driver tags implicit leaves, virtual tags registered internal nodes) and edges run from a virtual tag to each tag it reads. Kahn algorithm produces the topological sort. Tarjan iterative SCC detects every cycle in one pass so publish-time rejection surfaces all offending cycles together. Both iterative so 10k-deep chains do not StackOverflow. Re-adding a node overwrites prior dependency set cleanly (supports config-publish reloads).

VirtualTagDefinition is the operator-authored config row (Path, DataType, ScriptSource, ChangeTriggered, TimerInterval, Historize). Stream E config DB materializes these on publish.

ITagUpstreamSource is the abstraction the engine pulls driver tag values from. Stream G bridges this to IReadable + ISubscribable on live drivers; tests use FakeUpstream that tracks subscription count for leak-test assertions.

IHistoryWriter is the per-tag Historize sink. NullHistoryWriter default when caller does not pass one.

VirtualTagContext is the per-evaluation ScriptContext. Reads from engine last-known-value cache, writes route through SetVirtualTag callback so cross-tag side effects participate in change cascades. Injectable Now clock for deterministic tests.

VirtualTagEngine orchestrates. Load compiles every script via ScriptSandbox, builds the dep graph via DependencyExtractor, checks for cycles, reports every compile failure in one error, subscribes to each referenced upstream path, seeds the value cache. EvaluateAllAsync runs topological order. EvaluateOneAsync is timer path. Read returns cached value. Subscribe registers observer. OnUpstreamChange updates cache, fans out, schedules transitive dependents (change-driven=false tags skipped). EvaluateInternalAsync holds a SemaphoreSlim so cascades do not interleave. Script exceptions and timeouts map per-tag to BadInternalError. Coercion from script double to config Int32 uses Convert.ToInt32.

TimerTriggerScheduler groups tags by interval into shared Timers. Tags without TimerInterval not scheduled.

VirtualTagSource implements IReadable + ISubscribable per ADR-002. ReadAsync returns cache. SubscribeAsync fires initial-data callback per OPC UA convention. IWritable deliberately not implemented — OPC UA writes to virtual tags rejected in DriverNodeManager per Phase 7 decision 6.

36 unit tests across 4 files: DependencyGraphTests 12, VirtualTagEngineTests 13, VirtualTagSourceTests 6, TimerTriggerSchedulerTests 4. Coverage includes cycle detection (self-loop, 2-node, 3-node, multiple disjoint), 2-level change cascade, per-tag error isolation (one tag throws, others keep working), timeout isolation, Historize toggle, ChangeTriggered=false ignore, reload cleans subscriptions, Dispose releases resources, SetVirtualTag fires observers, type coercion, 10k deep graph no stack overflow, initial-data callback, Unsubscribe stops events.

Fixed two bugs during implementation. Monitor.Enter/Exit cannot be held across await (Monitor ownership is thread-local and lost across suspension) — switched to SemaphoreSlim. Kahn edge-direction was inverted — for dependency ordering (X depends on Y means Y comes before X) in-degree should be count of a node own deps, not count of nodes pointing to it; was incrementing inDegree[dep] instead of inDegree[nodeId], causing false cycle detection on valid DAGs.

Full Phase 7 test count after Stream B: 99 green (63 Scripting + 36 VirtualTags). Streams C and G will plug engine + source into live OPC UA dispatch path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:02:50 -04:00
00724e9784 Merge pull request 'Phase 7 Stream A.3 — ScriptLoggerFactory + ScriptLogCompanionSink (closes Stream A)' (#179) from phase-7-stream-a3-script-logger into v2 2026-04-20 16:45:09 -04:00
Joseph Doherty
36774842cf Phase 7 Stream A.3 — ScriptLoggerFactory + ScriptLogCompanionSink. Third of 3 increments closing out Stream A. Adds the Serilog plumbing that ties script-emitted log events to the dedicated scripts-*.log rolling sink with structured-property filtering AND forwards script Error+ events to the main opcua-*.log at Warning level so operators see script failures in the primary log without drowning it in Debug/Info script chatter. Both pieces are library-level building blocks — the actual file-sink + logger composition at server startup happens in Stream F (Admin UI) / Stream G (address-space wiring). This PR ships the reusable factory + sink + tests so any consumer can wire them up without rediscovering the structured-property contract.
ScriptLoggerFactory wraps a Serilog root logger (the scripts-*.log pipeline) and .Create(scriptName) returns a per-script ILogger with the ScriptName structured property pre-bound via ForContext. The structured property name is a public const (ScriptNameProperty = "ScriptName") because the Admin UI's log-viewer filter references this exact string — changing it breaks the filter silently, so it's stable by contract. Factory constructor rejects a null root logger; Create rejects null/empty/whitespace script names. No per-evaluation allocation in the hot path — engines (Stream B virtual-tag / Stream C scripted-alarm) create one factory per engine instance then cache per-script loggers beside the ScriptContext instances they already build.

ScriptLogCompanionSink is a Serilog ILogEventSink that forwards Error+ events from the script-logger pipeline to a separate "main" logger (the opcua-*.log pipeline in production) at Warning level. Rationale: operators usually watch the main server log, not scripts-*.log. Script authors log Info/Debug liberally during development — those stay in the scripts file. When a script actually fails (Error or Fatal), the operator needs to see it in the primary log so it can't be missed. Downgrading to Warning in the main log marks these as "needs attention but not a core server issue" since the server itself is healthy; the script author fixes the script. Forwarded event includes the ScriptName property (so operators can tell which script failed at a glance), the OriginalLevel (Error vs Fatal, preserved), the rendered message, and the original exception (preserved so the main log keeps the full stack trace — critical for diagnosis). Missing ScriptName property falls back to "unknown" without throwing; bypassing the factory is defensive but shouldn't happen in practice. Mirror threshold is configurable via constructor (defaults to LogEventLevel.Error) so deployments with stricter signal/noise requirements can raise it to Fatal.

15 new unit tests across two files. ScriptLoggerFactoryTests (6): Create sets the ScriptName structured property, each script gets its own property value across fan-out, Error-level event preserves level and exception, null root rejected, empty/whitespace/null name rejected, ScriptNameProperty const is stable at "ScriptName" (external-contract guard). ScriptLogCompanionSinkTests (9): Info/Warning events land in scripts sink only (not mirrored), Error event mirrored to main at Warning level (level-downgrade behavior), mirrored event includes ScriptName + OriginalLevel properties, mirrored event preserves exception for main-log stack-trace diagnosis, Fatal mirrored identically to Error, missing ScriptName falls back to "unknown" without throwing (defensive), null main logger rejected, custom mirror threshold (raised to Fatal) applied correctly.

Full Core.Scripting test suite after Stream A: 63/63 green (29 A.1 + 19 A.2 + 15 A.3). Stream A is complete — the scripting engine foundation, sandbox, sandbox-defense-in-depth, AST-inferred dependency extraction, compile cache, per-evaluation timeout, per-script logger with structured-property filtering, and companion-warn forwarding are all shipped and tested. Streams B through G build on this; Stream H closes out the phase with the compliance script + test baseline + merge to v2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:42:48 -04:00
cb5d7b2d58 Merge pull request 'Phase 7 Stream A.2 — compile cache + per-evaluation timeout wrapper' (#178) from phase-7-stream-a2-cache-timeout into v2 2026-04-20 16:41:07 -04:00
Joseph Doherty
0ae715cca4 Phase 7 Stream A.2 — compile cache + per-evaluation timeout wrapper. Second of 3 increments within Stream A. Adds two independent resilience primitives that the virtual-tag engine (Stream B) and scripted-alarm engine (Stream C) will compose with the base ScriptEvaluator. Both are generic on (TContext, TResult) so different engines get their own instances without cross-contamination.
CompiledScriptCache<TContext, TResult> — source-hash-keyed cache of compiled evaluators. Roslyn compilation is the most expensive step in the evaluator pipeline (5-20ms per script depending on size); re-compiling on every value-change event would starve the engine. ConcurrentDictionary of Lazy<ScriptEvaluator> with ExecutionAndPublication mode ensures concurrent callers never double-compile even on a cold cache race. Failed compiles evict the cache entry so an Admin UI retry with corrected source actually recompiles (otherwise the cached exception would persist). Whitespace-sensitive hash — reformatting a script misses the cache on purpose, simpler than AST-canonicalize and happens rarely. No capacity bound because virtual-tag + alarm scripts are config-DB bounded (thousands, not millions); if scale pushes past that in v3 an LRU eviction slots in behind the same API.

TimedScriptEvaluator<TContext, TResult> — wraps a ScriptEvaluator with a per-evaluation wall-clock timeout (default 250ms per Phase 7 plan Stream A.4, configurable per tag so slower backends can widen). Critical implementation detail: the underlying Roslyn ScriptRunner executes synchronously on the calling thread for CPU-bound user scripts, returning an already-completed Task before the caller can register a timeout. Naive `Task.WaitAsync(timeout)` would see the completed task and never fire. Fix: push evaluation to a thread-pool thread via Task.Run, so the caller's thread is free to wait and the timeout reliably fires after the configured budget. Known trade-off (documented in the class summary): when a script times out, the underlying evaluation task continues running on the thread-pool thread until Roslyn returns; in the CPU-bound-infinite-loop case it's effectively leaked until the runtime decides to unwind. Tighter CPU budgeting would require an out-of-process script runner (v3 concern). In practice the timeout + structured warning log surfaces the offending script so the operator fixes it, and the orphan thread is rare. Caller-supplied CancellationToken is honored and takes precedence over the timeout, so driver-shutdown paths see a clean OperationCanceledException rather than a misclassified ScriptTimeoutException.

ScriptTimeoutException carries the configured Timeout and a diagnostic message pointing the operator at ctx.Logger output around the failure plus suggesting widening the timeout, simplifying the script, or moving heavy work out of the evaluation path. The virtual-tag engine (Stream B) will catch this and map the owning tag's quality to BadInternalError per Phase 7 decision #11, logging a structured warning with the offending script name.

Tests: CompiledScriptCacheTests (10) — first-call compile, identical-source dedupe to same instance, different-source produces different evaluator, whitespace-sensitivity documented, cached evaluator still runs correctly, failed compile evicted for retry, Clear drops entries, concurrent GetOrCompile of the same source deduplicates to one instance, different TContext/TResult use separate cache instances, null source rejected. TimedScriptEvaluatorTests (9) — fast script completes under timeout, CPU-bound script throws ScriptTimeoutException, caller cancellation takes precedence over timeout (shutdown path correctness), default 250ms per plan, zero/negative timeout rejected at construction, null inner rejected, null context rejected, user-thrown exceptions propagate unwrapped (not conflated with timeout), timeout exception message contains diagnostic guidance. Full suite: 48/48 green (29 from A.1 + 19 new).

Next: Stream A.3 wires the dedicated scripts-*.log Serilog rolling sink + structured-property filtering + companion-WARN enricher to the main log, closing out Stream A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:38:43 -04:00
d2bfcd9f1e Merge pull request 'Phase 7 Stream A.1 — Core.Scripting project scaffold + ScriptContext + sandbox + AST dependency extractor' (#177) from phase-7-stream-a1-core-scripting into v2 2026-04-20 16:29:44 -04:00
Joseph Doherty
e4dae01bac Phase 7 Stream A.1 — Core.Scripting project scaffold + ScriptContext + sandbox + AST dependency extractor. First of 3 increments within Stream A. Ships the Roslyn-based script engine's foundation: user C# snippets compile against a constrained ScriptOptions allow-list + get a post-compile sandbox guard, the static tag-dependency set is extracted from the AST at publish time, and the script sees a stable ctx.GetTag/SetVirtualTag/Now/Logger/Deadband API that later streams plug into concrete backends.
ScriptContext abstract base defines the API user scripts see as ctx — GetTag(string) returns DataValueSnapshot so scripts branch on quality naturally, SetVirtualTag(string, object?) is the only write path virtual tags have (OPC UA client writes to virtual nodes rejected separately in DriverNodeManager per ADR-002), Now + Logger + Deadband static helper round out the surface. Concrete subclasses in Streams B + C plug in actual tag backends + per-script Serilog loggers.

ScriptSandbox.Build(contextType) produces the ScriptOptions for every compile — explicit allow-list of six assemblies (System.Private.CoreLib / System.Linq / Core.Abstractions / Core.Scripting / Serilog / the context type's own assembly), with a matching import list so scripts don't need using clauses. Allow-list is plan-level — expanding it is not a casual change.

DependencyExtractor uses CSharpSyntaxWalker to find every ctx.GetTag("literal") and ctx.SetVirtualTag("literal", ...) call, rejects every non-literal path (variable, concatenation, interpolation, method-returned). Rejections carry the exact TextSpan so the Admin UI can point at the offending token. Reads + writes are returned as two separate sets so the virtual-tag engine (Stream B) knows both the subscription targets and the write targets.

Sandbox enforcement turned out needing a second-pass semantic analyzer because .NET 10's type forwarding makes assembly-level restriction leaky — System.Net.Http.HttpClient resolves even with WithReferences limited to six assemblies. ForbiddenTypeAnalyzer runs after Roslyn's Compile() against the SemanticModel, walks every ObjectCreationExpression / InvocationExpression / MemberAccessExpression / IdentifierName, resolves to the containing type's namespace, and rejects any prefix-match against the deny-list (System.IO, System.Net, System.Diagnostics, System.Reflection, System.Threading.Thread, System.Runtime.InteropServices, Microsoft.Win32). Rejections throw ScriptSandboxViolationException with the aggregated list + source spans so the Admin UI surfaces every violation in one round-trip instead of whack-a-mole. System.Environment explicitly stays allowed (read-only process state, doesn't persist or leak outside) and that compromise is pinned by a dedicated test.

ScriptGlobals<TContext> wraps the context as a named field so scripts see ctx instead of the bare globalsType-member-access convention Roslyn defaults to — keeps script ergonomics (ctx.GetTag) consistent with the AST walker's parse shape and the Admin UI's hand-written type stub (coming in Stream F). Generic on TContext so Stream C's alarm-predicate context with an Alarm property inherits cleanly.

ScriptEvaluator<TContext, TResult>.Compile is the three-step gate: (1) Roslyn compile — throws CompilationErrorException on syntax/type errors with Location-carrying diagnostics; (2) ForbiddenTypeAnalyzer semantic pass — catches type-forwarding sandbox escapes; (3) delegate creation. Runtime exceptions from user code propagate unwrapped — the virtual-tag engine in Stream B catches + maps per-tag to BadInternalError quality per Phase 7 decision #11.

29 unit tests covering every surface: DependencyExtractorTests has 14 theories — single/multiple/deduplicated reads, separate write tracking, rejection of variable/concatenated/interpolated/method-returned/empty/whitespace paths, ignoring non-ctx methods named GetTag, empty-source no-op, source span carried in rejections, multiple bad paths reported in one pass, nested literal extraction. ScriptSandboxTests has 15 — happy-path compile + run, SetVirtualTag round-trip, rejection of File.IO + HttpClient + Process.Start + Reflection.Assembly.Load via ScriptSandboxViolationException, Environment.GetEnvironmentVariable explicitly allowed (pinned compromise), script-exception propagation, ctx.Now reachable, Deadband static reachable, LINQ Where/Sum reachable, DataValueSnapshot usable in scripts including quality branches, compile error carries source location.

Next two PRs within Stream A: A.2 adds the compile cache (source-hash keyed) + per-evaluation timeout wrapper; A.3 wires the dedicated scripts-*.log Serilog rolling sink with structured-property filtering + the companion-warning enricher to the main log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:27:07 -04:00
6ae638a6de Merge pull request 'ADR-002 — driver-vs-virtual dispatch for Phase 7 scripting' (#176) from adr-002-driver-vs-virtual-dispatch into v2 2026-04-20 16:10:30 -04:00
Joseph Doherty
2a74daf228 ADR-002 — driver-vs-virtual dispatch: DriverNodeManager routes reads/writes/subscriptions across driver tags and virtual (scripted) tags via a single NodeManager with a NodeSource tag on NodeScopeResolver's output. Locks the architecture decision Phase 7 Stream G was going to have to make anyway — documenting it up front so the stream implementation can reference the chosen shape instead of rediscovering it. Option A (separate VirtualTagNodeManager sibling) rejected because shared Equipment folders owning both driver and virtual children would force two NodeManagers to fight for ownership on every Equipment node — the common case, not the exception — defeating the separation. Option C (virtual engine registers as a synthetic IDriver through DriverTypeRegistry) rejected because DriverInstance shape is wrong for scripting config (no DriverType, no HostAddress, no connectivity probe, no NSSM wrapper), IDriver.InitializeAsync semantics don't match script compilation, Polly resilience wrappers calibrated for network calls would either passthrough pointlessly or tune wrong, and Admin UI would need special-casing everywhere to hide fields that don't apply. Option B (single DriverNodeManager, NodeScopeResolver returns NodeSource enum alongside ScopeId, dispatch branches on source) accepted because it preserves one address-space tree with one walker, ACL binding works identically for both kinds, Phase 6.1 resilience + Phase 6.2 audit apply uniformly to the driver branch without needing Roslyn analyzer exemptions, and adding future source kinds is a single-enum-case addition. NodeScopeResolver.Resolve returns NodeScope(ScopeId, NodeSource, DriverInstanceId?, VirtualTagId?); DriverNodeManager pattern-matches on scope.Source and routes to either the driver dictionary or IVirtualTagEngine. OPC UA client writes to a virtual node return BadUserAccessDenied before the dispatch branch because Phase 7 decision #6 restricts virtual-tag writes to scripts via ctx.SetVirtualTag. Dispatch test coverage specified for Stream G.4: mixed Equipment folders browsing correctly, read routing per source kind, subscription fan-out across both kinds, the BadUserAccessDenied guard on virtual writes, and script-driven writes firing subscription notifications. ADR-001's walker gains the VirtualTag config-DB table as an additional input channel alongside Tag; NodeScopeResolver's ScopeId return stays unchanged so Phase 6.2's ACL trie needs no modification. Consequences flagged: whether IVirtualTagEngine lives in Core.Abstractions vs Phase 7's Core.VirtualTags project, and whether future server-side methods on virtual nodes would route through this dispatch, both marked out-of-scope for ADR-002.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:08:01 -04:00
3eb5f1d9da Merge pull request 'Phase 7 plan doc — scripting runtime + virtual tags + scripted alarms + historian alarm sink' (#175) from phase-7-plan-doc into v2 2026-04-20 16:07:34 -04:00
Joseph Doherty
f2c1cc84e9 Phase 7 plan doc — scripting runtime + virtual tags + scripted alarms + historian alarm sink. Draft output from the 2026-04-20 interactive planning session. Phase 7 is the last phase before v2 release readiness; adds two additive runtime capabilities on top of the existing driver + Equipment address-space foundation: (1) virtual (calculated) tags — OPC UA variables whose values are computed by user-authored C# scripts against other tags, evaluated on change and/or timer, living in the existing Equipment tree alongside driver tags, behaving identically to clients; (2) Part 9 scripted alarms — full state machine (EnabledState/ActiveState/AckedState/ConfirmedState/ShelvingState) with persistent operator-supplied state across restarts, complementing (not replacing) the existing Galaxy-native and AB CIP ALMD alarm sources. A third tie-in capability — Aveva Historian as alarm system of record — routes every qualifying alarm transition from any IAlarmSource (scripted + Galaxy + ALMD) through a local SQLite store-and-forward queue to Galaxy.Host, which uses its already-loaded aahClientManaged DLLs to write to the Historian alarm schema; per-alarm HistorizeToAveva toggle gates which sources flow (default off for Galaxy-native to avoid duplicating the direct Galaxy historian path, default on for scripted).
Locks in 22 design decisions from the planning conversation: C# via Roslyn scripting; virtual tags in the Equipment tree (not a separate /Virtual/ namespace); change-driven + timer-driven triggers operator-configurable per tag; Shape A one-script-per-tag-or-alarm (no predicate/action split); full OPC UA Part 9 alarm fidelity; read-only sandbox (scripts read any tag, write only to virtual tags, no File/HttpClient/Process/reflection); AST-inferred dependencies via CSharpSyntaxWalker (non-literal tag paths rejected at publish); config DB storage with generation-sealed cache; ctx.GetTag returns a full DataValue {Value, StatusCode, Timestamp}; per-tag Historize checkbox; per-tag error isolation (throwing script sets tag quality BadInternalError, engine unaffected); dedicated scripts-*.log Serilog sink bound to ctx.Logger; alarm message as template with {TagPath} substitution resolved at event emission; ActiveState recomputed from tags on startup while EnabledState/AckedState/ConfirmedState/ShelvingState + audit persist to config DB; historian sink scope = all IAlarmSource impls with per-alarm toggle; SQLite store-and-forward on the node so operators are never blocked by Historian downtime; IPC to Galaxy.Host for ingestion reusing the already-loaded aahClientManaged DLLs; Monaco editor for Admin code editing; serial cascade evaluation for v1 (parallel as follow-up); shelving UX via OPC UA method calls only with no custom Admin controls (operator drives state transitions from plant HMIs or Client.CLI); 30-day dead-letter retention with manual retry button; test harness accepts only declared-input paths so the harness enforces dependency declaration.

Eight streams totaling ~10-12 weeks, scope-comparable to Phase 6: A - Core.Scripting (Roslyn engine + sandbox + AST inference + logger); B - virtual tag engine (dependency graph + change/timer schedulers + historize); C - scripted alarm engine (Part 9 state machine + template messages + startup recovery + OPC UA method binding); D - historian alarm sink (SQLite store-and-forward + Galaxy.Host IPC contract extension); E - config DB schema (four new tables under sp_PublishGeneration); F - Admin UI scripting tab (Monaco + test harness + dependency preview + script-log viewer + historian diagnostics); G - address-space integration (extend EquipmentNodeWalker for virtual source kind + extend DriverNodeManager dispatch); H - exit gate.

Compliance-check surface covers sandbox escape (typeof/Assembly.Load/File/HttpClient attempts must fail at compile), dependency inference (literal-only paths), change cascade (topological ordering), cycle rejection at publish, startup recovery (ack/confirm/shelve survive restart but ActiveState recomputed), ack audit trail persistence, historian queue durability (Galaxy.Host offline → online drains in-order), per-alarm historian toggle gating, script timeout isolation, log sink isolation, ACL binding (virtual tags inherit Equipment scope grants).

Follow-up artifacts tracked as tasks #231-#238 (stream placeholders). Supporting doc updates (plan.md §6 Migration Strategy, config-db-schema.md §§ for the four new tables, driver-specs.md §Alarm semantics clarification, new ADR-002 for driver-vs-virtual dispatch) will land alongside the streams that touch them, not in this doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:05:12 -04:00
8384e58655 Merge pull request 'Modbus exception-injection profile — wire-level coverage for codes 0x01/0x03/0x04/0x05/0x06/0x0A/0x0B' (#174) from modbus-exception-injection-profile into v2 2026-04-20 15:14:00 -04:00
Joseph Doherty
96940aeb24 Modbus exception-injection profile — closes the end-to-end test gap for exception codes 0x01/0x03/0x04/0x05/0x06/0x0A/0x0B. pymodbus simulator naturally emits only 0x02 (Illegal Data Address on reads outside configured ranges) + 0x03 (Illegal Data Value on over-length); the driver's MapModbusExceptionToStatus table translates eight codes, but only 0x02 had integration-level coverage (via DL205's unmapped-register test). Unit tests lock the translation function in isolation but an integration test was missing for everything else. This PR lands wire-level coverage for the remaining seven codes without depending on device-specific quirks to naturally produce them.
New exception_injector.py — standalone pure-Python-stdlib Modbus/TCP server shipped alongside the pymodbus image. Speaks the wire protocol directly (MBAP header parse + FC 01/02/03/04/05/06/15/16 dispatch + store-backed happy-path reads/writes + spec-enforced length caps) and looks up each (fc, starting-address) against a rules list loaded from JSON; a matching rule makes the server respond [fc|0x80, exception_code] instead of the normal response. Zero runtime dependencies outside the stdlib — the Dockerfile just COPY's the script into /fixtures/ alongside the pymodbus profile JSONs, no new pip install needed. ~200 lines. New exception_injection.json profile carries rules for every exception code on FC03 (addresses 1000-1007, one per code), FC06 (2000-2001 for CPU-PROGRAM-mode and busy), and FC16 (3000 for server failure). New exception_injection compose profile binds :5020 like every other service + runs python /fixtures/exception_injector.py --config /fixtures/exception_injection.json.

New ExceptionInjectionTests.cs in Modbus.IntegrationTests — 11 tests. Eight FC03-read theories exercise every exception code 0x01/0x02/0x03/0x04/0x05/0x06/0x0A/0x0B asserting the driver's expected OPC UA StatusCode mapping (BadNotSupported/BadOutOfRange/BadOutOfRange/BadDeviceFailure/BadDeviceFailure/BadDeviceFailure/BadCommunicationError/BadCommunicationError). Two FC06-write theories cover the write path for 0x04 (Server Failure, CPU in PROGRAM mode) + 0x06 (Server Busy). One sanity-check read at address 5 confirms the injector isn't globally broken + non-injected reads round-trip cleanly with Value=5/StatusCode=Good. All tests follow the MODBUS_SIM_PROFILE=exception_injection skip guard so they no-op on a fresh clone without Docker running.

Docker/README.md gains an §Exception injection section explaining what pymodbus can and cannot emit, what the injector does, where the rules live, and how to append new ones. docs/drivers/Modbus-Test-Fixture.md follow-up item #2 (extend pymodbus profiles to inject exceptions) gets a shipped strikethrough with the new coverage inventory; the unit-level section adds ExceptionInjectionTests next to DL205ExceptionCodeTests so the split-of-responsibilities is explicit (DL205 test = natural out-of-range via dl205 profile, ExceptionInjectionTests = every other code via the injector).

Test baselines: Modbus unit 182/182 green (unchanged); Modbus integration with exception_injection profile live 11/11 new tests green. Existing DL205/S7/Mitsubishi integration tests unaffected since they skip on MODBUS_SIM_PROFILE mismatch.

Found + fixed during validation: a stale native pymodbus simulator from April 18 was still listening on port 5020 on IPv6 localhost (Windows was load-balancing between it + the Docker IPv4 forward, making injected exceptions intermittently come back as pymodbus's default 0x02). Killed the leftover. Documented the debugging path in the commit as a note for anyone who hits the same "my tests see exception 0x02 but the injector log has no request" symptom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:11:32 -04:00
340f580be0 Merge pull request 'FOCAS Tier-C PR E — ops glue: ProcessHostLauncher + post-mortem MMF + NSSM scripts' (#173) from focas-tier-c-pr-e-ops-glue into v2 2026-04-20 14:26:35 -04:00
Joseph Doherty
8d88ffa14d FOCAS Tier-C PR E — ops glue: ProcessHostLauncher + post-mortem MMF + NSSM install scripts + doc close-out. Final of the 5 PRs for #220. With this landing, the Tier-C architecture is fully shipped; the only remaining FOCAS work is the hardware-dependent FwlibHostedBackend (real Fwlib32.dll P/Invoke, gated on #222 lab rig).
Production IHostProcessLauncher (ProcessHostLauncher.cs): Process.Start spawns OtOpcUa.Driver.FOCAS.Host.exe with OTOPCUA_FOCAS_PIPE / OTOPCUA_ALLOWED_SID / OTOPCUA_FOCAS_SECRET / OTOPCUA_FOCAS_BACKEND in the environment (supervisor-owned, never disk), polls FocasIpcClient.ConnectAsync at 250ms cadence until the pipe is up or the Host exits or the ConnectTimeout deadline passes, then wraps the connected client in an IpcFocasClient. TerminateAsync kills the entire process tree + disposes the IPC stream. ProcessHostLauncherOptions carries HostExePath + PipeName + AllowedSid plus optional SharedSecret (auto-generated from a GUID when omitted so install scripts don't have to), Arguments, Backend (fwlib32/fake/unconfigured default-unconfigured), ConnectTimeout (15s), and Series for CNC pre-flight.

Post-mortem MMF (Host/Stability/PostMortemMmf.cs + Proxy/Supervisor/PostMortemReader.cs): ring-buffer of the last ~1000 IPC operations written by the Host into a memory-mapped file. On a Host crash the supervisor reads the MMF — which survives process death — to see what was in flight. File format: 16-byte header [magic 'OFPC' (0x4F465043) | version | capacity | writeIndex] + N × 256-byte entries [8-byte UTC unix ms | 8-byte opKind | 240-byte UTF-8 message + null terminator]. Magic distinguishes FOCAS MMFs from the Galaxy MMFs that ship the same format shape. Writer is single-producer (Host) with a lock_writeGate; reader is multi-consumer (Proxy + any diagnostic tool) using a separate MemoryMappedFile handle.

NSSM install wrappers (scripts/install/Install-FocasHost.ps1 + Uninstall-FocasHost.ps1): idempotent service registration for OtOpcUaFocasHost. Resolves SID from the ServiceAccount, generates a fresh shared secret per install if not supplied, stages OTOPCUA_FOCAS_PIPE/SID/SECRET/BACKEND in AppEnvironmentExtra so they never hit disk, rotates 10MB stdout/stderr logs under %ProgramData%\OtOpcUa, DependOnService=OtOpcUa so startup order is deterministic. Backend selector defaults to unconfigured so a fresh install doesn't accidentally load a half-configured Fwlib32.dll on first start.

Tests (7 new, 2 files): PostMortemMmfTests.cs in FOCAS.Host.Tests — round-trip write+read preserves order + content, ring-buffer wraps at capacity (writes 10 entries to a 3-slot buffer, asserts only op-7/8/9 survive in FIFO order), message truncation at the 240-byte cap is null-terminated + non-overflowing, reopening an existing file preserves entries. PostMortemReaderCompatibilityTests.cs in FOCAS.Tests — hand-writes a file in the exact host format (magic/entry layout) + asserts the Proxy reader decodes with correct ring-walk ordering when writeIndex != 0, empty-return on missing file + magic mismatch. Keeps the two codebases in format-lockstep without the net10 test project referencing the net48 Host assembly.

Docs updated: docs/v2/implementation/focas-isolation-plan.md promoted from DRAFT to PRs A-E shipped status with per-PR citations + post-ship test counts (189 + 24 + 13 = 226 FOCAS-family tests green). docs/drivers/FOCAS-Test-Fixture.md §5 updated from "architecture scoped but not implemented" to listing the shipped components with the FwlibHostedBackend gap explicitly labeled as hardware-gated. Install-FocasHost.ps1 documents the OTOPCUA_FOCAS_BACKEND selector + points at docs/v2/focas-deployment.md for Fwlib32.dll licensing.

What ISN'T in this PR: (1) the real FwlibHostedBackend implementing IFocasBackend with the P/Invoke — requires either a CNC on the bench or a licensed FANUC developer kit to validate, tracked under #220 as a single follow-up task; (2) Admin /hosts surface integration for FOCAS runtime status — Galaxy Tier-C already has the shape, FOCAS can slot in when someone wires ObservedCrashes/StickyAlertActive/BackoffAttempt to the FleetStatusHub; (3) a full integration test that actually spawns a real FOCAS Host process — ProcessHostLauncher is tested via its contract + the MMF is tested via round-trip, but no test spins up the real exe (the Galaxy Tier-C tests do this, but the FOCAS equivalent adds no new coverage over what's already in place).

Total FOCAS-family tests green after this PR: 189 driver + 24 Shared + 13 Host = 226.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:24:13 -04:00
446a5c022c Merge pull request 'FOCAS Tier-C PR D — supervisor + backoff + crash-loop breaker' (#172) from focas-tier-c-pr-d-supervisor into v2 2026-04-20 14:19:32 -04:00
Joseph Doherty
5033609944 FOCAS Tier-C PR D — supervisor + backoff + crash-loop breaker + heartbeat monitor. Fourth of 5 PRs for #220. Ships the resilience harness that sits between the driver's IFocasClient requests and the Tier-C Host process, so a crashing Fwlib32.dll takes down only the Host (not the main server), gets respawned on a backoff ladder, and opens a circuit with a sticky operator alert when the crash rate is pathological. Same shape as Galaxy Tier-C so the Admin /hosts surface has a single mental model. New Supervisor/ namespace in Driver.FOCAS (.NET 10, Proxy-side): Backoff with the 5s→15s→60s default ladder + StableRunThreshold that resets the index after a 2-min clean run (so a one-off crash after hours of steady-state doesn't restart from the top); CircuitBreaker with 3-crashes-in-5-min threshold + escalating 1h→4h→manual-reset cooldown ladder + StickyAlertActive flag that persists across cooldowns until AcknowledgeAndReset is called; HeartbeatMonitor tracking ConsecutiveMisses against the 3-misses-kill threshold + LastAckUtc for telemetry; IHostProcessLauncher abstraction over "spawn Host process + produce an IFocasClient connected to it" so the supervisor stays I/O-free and fully testable with a fake launcher that can be told to throw on specific attempts (production wiring over Process.Start + FocasIpcClient.ConnectAsync is the PR E ops-glue concern); FocasHostSupervisor orchestrating them — GetOrLaunchAsync cycles through backoff until either a client is returned or the breaker opens (surfaced as InvalidOperationException so the driver maps to BadDeviceFailure), NotifyHostDeadAsync fans out the unavailable event + terminates the current launcher + records the crash without blocking (so heartbeat-loss detection can short-circuit subscriber fan-out and let the next GetOrLaunchAsync handle the respawn), AcknowledgeAndReset is the operator-clear path, OnUnavailable event for Admin /hosts wiring + ObservedCrashes + BackoffAttempt + StickyAlertActive for telemetry. 14 new unit tests across SupervisorTests.cs: Backoff (default sequence, clamping, RecordStableRun resets), CircuitBreaker (below threshold allowed, opens at threshold, escalates cooldown after second open, ManualReset clears state), HeartbeatMonitor (3 consecutive misses declares dead, ack resets counter), FocasHostSupervisor (first-launch success, retry-with-backoff after transient failure, repeated failures open breaker + surface InvalidOperationException, NotifyHostDeadAsync terminates + fan-outs + increments crash count, AcknowledgeAndReset clears sticky, Dispose terminates). Full FOCAS driver tests now 186/186 green (172 + 14 new). No changes to IFocasClient DI contract; existing FakeFocasClient-based tests unaffected. PR E wires the real Process-based IHostProcessLauncher + NSSM install scripts + MMF post-mortem + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:17:23 -04:00
9034294b77 Merge pull request 'FOCAS Tier-C PR C — IPC path end-to-end' (#171) from focas-tier-c-pr-c-ipc-proxy into v2 2026-04-20 14:13:33 -04:00
Joseph Doherty
3892555631 FOCAS Tier-C PR C — IPC path end-to-end: Proxy IpcFocasClient + Host FwlibFrameHandler + IFocasBackend abstraction. Third of 5 PRs for #220. Ships the wire path from IFocasClient calls in the .NET 10 driver, over a named-pipe (or in-memory stream) to the .NET 4.8 Host's FwlibFrameHandler, dispatched to an IFocasBackend. Keeps the existing IFocasClient DI seam intact so existing unit tests are unaffected (172/172 still pass). Proxy side adds Ipc/FocasIpcClient (owns one pipe stream + call gate so concurrent callers don't interleave frames, supports both real NamedPipeClientStream and arbitrary Stream for in-memory test loopback) and Ipc/IpcFocasClient (implements IFocasClient by forwarding every call as an IPC frame — Connect sends OpenSessionRequest and caches the SessionId; Read sends ReadRequest and decodes the typed value via FocasDataTypeCode; Write sends WriteRequest for non-bit data or PmcBitWriteRequest when it's a PMC bit so the RMW critical section stays on the Host; Probe sends ProbeRequest; Dispose best-effort sends CloseSessionRequest); plus FocasIpcException surfacing Host-side ErrorResponse frames as typed exceptions. Host side adds Backend/IFocasBackend (the Host's view of one FOCAS session — Open/Close/Read/Write/PmcBitWrite/Probe) with two implementations: FakeFocasBackend (in-memory, per-address value store, honors bit-write RMW semantics against the containing byte — used by tests and as an OTOPCUA_FOCAS_BACKEND=fake operational stub) and UnconfiguredFocasBackend (structured failure pointing at docs/v2/focas-deployment.md — the safe default when OTOPCUA_FOCAS_BACKEND is unset or hardware isn't configured). Ipc/FwlibFrameHandler replaces StubFrameHandler: deserializes each request DTO, delegates to the IFocasBackend, re-serializes into the matching response kind. Catches backend exceptions and surfaces them as ErrorResponse{backend-exception} rather than tearing down the pipe. Program.cs now picks the backend from OTOPCUA_FOCAS_BACKEND env var (fake/unconfigured/fwlib32; fwlib32 still maps to Unconfigured because the real Fwlib32 P/Invoke integration is a hardware-dependent follow-up — #220 captures it). Tests: 7 new IPC round-trip tests on the Proxy side (IpcFocasClient vs. an IpcLoopback fake server: connect happy path, connect rejection, read decode, write round-trip, PMC bit write routes to first-class RMW frame, probe, ErrorResponse surfaces as typed exception) + 6 new Host-side tests on FwlibFrameHandler (OpenSession allocates id, read-without-session fails, full open/write/read round-trip preserves value, PmcBitWrite sets the specified bit, Probe reports healthy with open session, UnconfiguredBackend returns pointed-at-docs error with ErrorCode=NoFwlibBackend). Existing 165 FOCAS unit tests + 24 Shared tests + 3 Host handshake tests all unchanged. Total post-PR: 172+24+9 = 205 FOCAS-family tests green. What's NOT in this PR: the actual Fwlib32.dll P/Invoke integration inside the Host (FwlibHostedBackend) lands as a hardware-dependent follow-up since no CNC is available for validation; supervisor + respawn + crash-loop breaker comes in PR D; MMF + NSSM install scripts in PR E.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:10:52 -04:00
3609a5c676 Merge pull request 'FOCAS Tier-C PR B � Driver.FOCAS.Host net48 x86 skeleton' (#170) from focas-tier-c-pr-b-host into v2 2026-04-20 14:02:56 -04:00
Joseph Doherty
a6f53e5b22 FOCAS Tier-C PR B — Driver.FOCAS.Host net48 x86 skeleton + pipe server. Second PR of the 5-PR #220 split. Stands up the Windows Service entry point + named-pipe scaffolding so PR C has a place to move the Fwlib32 calls into. New net48 x86 project (Fwlib32.dll is 32-bit-only, same bitness constraint as Galaxy.Host/MXAccess); references Driver.FOCAS.Shared for framing + DTOs so the wire format Proxy<->Host stays a single type system. Ships four files: PipeAcl creates a PipeSecurity where only the configured server principal SID gets ReadWrite+Synchronize + LocalSystem/BuiltinAdministrators are explicitly denied (so a compromised service account on the same host can't escalate via the pipe); IFrameHandler abstracts the dispatch surface with HandleAsync + AttachConnection for server-push event sinks; PipeServer accepts one connection at a time, verifies the peer SID via RunAsClient, reads the first Hello frame + matches the shared-secret and protocol major version, sends HelloAck, then hands off to the handler until EOF or cancel; StubFrameHandler fully handles Heartbeat/HeartbeatAck so a future supervisor's liveness detector stays happy, and returns ErrorResponse{Code=not-implemented,Message="Kind X is stubbed - Fwlib32 lift lands in PR C"} for every data-plane request. Program.cs mirrors the Galaxy.Host startup exactly: reads OTOPCUA_FOCAS_PIPE / OTOPCUA_ALLOWED_SID / OTOPCUA_FOCAS_SECRET from the env (supervisor passes these at spawn time), builds Serilog rolling-file logger into %ProgramData%\OtOpcUa\focas-host-*.log, constructs the pipe server with StubFrameHandler, loops on RunAsync until SIGINT. Log messages mark the backend as "stub" so it's visible in logs that Fwlib32 isn't actually connected yet. Driver.FOCAS.Host.Tests (net48 x86) ships three integration tests mirroring IpcHandshakeIntegrationTests from Galaxy.Host: correct-secret handshake + heartbeat round-trip, wrong-secret rejection with UnauthorizedAccessException, and a new test that sends a ReadRequest and asserts the StubFrameHandler returns ErrorResponse{not-implemented} mentioning PR C in the message so the wiring between frame dispatch + kind → error mapping is locked. Tests follow the same is-Administrator skip guard as Galaxy because PipeAcl denies BuiltinAdministrators. No changes to existing driver code; FOCAS unit tests still at 165/165 + Shared tests at 24/24. PR C wires the real Fwlib32 backend.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:00:56 -04:00
b968496471 Merge pull request 'FOCAS Tier-C PR A � Driver.FOCAS.Shared MessagePack contracts' (#169) from focas-tier-c-pr-a-shared into v2 2026-04-20 13:57:45 -04:00
158 changed files with 19206 additions and 62 deletions

View File

@@ -3,6 +3,10 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
@@ -15,6 +19,7 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
@@ -25,8 +30,13 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
@@ -43,6 +53,7 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>

View File

@@ -69,14 +69,32 @@ covers the common address shapes; per-model quirks are not stressed.
- Parameter range enforcement (CNC rejects out-of-range writes)
- MTB (machine tool builder) custom screens that expose non-standard data
### 5. Tier-C process isolation behavior
### 5. Tier-C process isolation — architecture shipped, Fwlib32 integration hardware-gated
Per driver-stability.md, FOCAS should run process-isolated because
`Fwlib32.dll` has documented crash modes. The test suite runs in-process +
only exercises the happy path + mapped error codes — a native access
violation from the DLL would take the test host down. The process-isolation
path (similar to Galaxy's out-of-process Host) has been scoped but not
implemented.
The Tier-C architecture is now in place as of PRs #169#173 (FOCAS
PR AE, task #220):
- `Driver.FOCAS.Shared` carries MessagePack IPC contracts
- `Driver.FOCAS.Host` (.NET 4.8 x86 Windows service via NSSM) accepts
a connection on a strictly-ACL'd named pipe + dispatches frames to
an `IFocasBackend`
- `Driver.FOCAS.Ipc.IpcFocasClient` implements the `IFocasClient` DI
seam by forwarding over IPC — swap the DI registration and the
driver runs Tier-C with zero other changes
- `Driver.FOCAS.Supervisor.FocasHostSupervisor` owns the spawn +
heartbeat + respawn + 3-in-5min crash-loop breaker + sticky alert
- `Driver.FOCAS.Host.Stability.PostMortemMmf`
`Driver.FOCAS.Supervisor.PostMortemReader` — ring-buffer of the
last ~1000 IPC operations survives a Host crash
The one remaining gap is the production `FwlibHostedBackend`: an
`IFocasBackend` implementation that wraps the licensed
`Fwlib32.dll` P/Invoke. That's hardware-gated on task #222 — we
need a CNC on the bench (or the licensed FANUC developer kit DLL
with a test harness) to validate it. Until then, the Host ships
`FakeFocasBackend` + `UnconfiguredFocasBackend`. Setting
`OTOPCUA_FOCAS_BACKEND=fake` lets operators smoke-test the whole
Tier-C pipeline end-to-end without any CNC.
## When to trust FOCAS tests, when to reach for a rig

View File

@@ -34,7 +34,8 @@ shaped (neither is a Modbus-side concept).
- `DL205SmokeTests` — FC16 write → FC03 read round-trip on holding register
- `DL205CoilMappingTests` — Y-output / C-relay / X-input address mapping
(octal → Modbus offset)
- `DL205ExceptionCodeTests` — Modbus exception → OPC UA StatusCode mapping
- `DL205ExceptionCodeTests` — Modbus exception 0x02 → OPC UA `BadOutOfRange` against the dl205 profile (natural out-of-range path)
- `ExceptionInjectionTests` — every other exception code in the mapping table (0x01 / 0x03 / 0x04 / 0x05 / 0x06 / 0x0A / 0x0B) against the `exception_injection` profile on both read + write paths
- `DL205FloatCdabQuirkTests` — CDAB word-swap float encoding
- `DL205StringQuirkTests` — packed-string V-memory layout
- `DL205VMemoryQuirkTests` — V-memory octal addressing
@@ -103,8 +104,13 @@ Not a Modbus concept. Driver doesn't implement `IAlarmSource` or
1. Add `MODBUS_SIM_ENDPOINT` override documentation to
`docs/v2/test-data-sources.md` so operators can point the suite at a lab rig.
2. Extend `pymodbus` profiles to inject exception responses — a JSON flag per
register saying "next read returns exception 0x04."
2. ~~Extend `pymodbus` profiles to inject exception responses~~**shipped**
via the `exception_injection` compose profile + standalone
`exception_injector.py` server. Rules in
`Docker/profiles/exception_injection.json` map `(fc, address)` to an
exception code; `ExceptionInjectionTests` exercises every code in
`MapModbusExceptionToStatus` (0x01 / 0x02 / 0x03 / 0x04 / 0x05 / 0x06 /
0x0A / 0x0B) end-to-end on both read (FC03) and write (FC06) paths.
3. Add an FX5U profile once a lab rig is available; the scaffolding is in place.
## Key fixture / config files

View File

@@ -0,0 +1,136 @@
# ADR-002 — Driver-vs-virtual dispatch: how `DriverNodeManager` routes reads, writes, and subscriptions across driver tags and virtual (scripted) tags
**Status:** Accepted 2026-04-20 — Option B (single NodeManager + NodeSource tag on the resolver output); Options A and C explicitly rejected.
**Related phase:** [Phase 7 — Scripting Runtime + Scripted Alarms](phase-7-scripting-and-alarming.md) Stream G.
**Related tasks:** #237 Phase 7 Stream G — Address-space integration.
**Related ADRs:** [ADR-001 — Equipment node walker](adr-001-equipment-node-walker.md) (this ADR extends the walker + resolver it established).
## Context
Phase 7 introduces **virtual tags** — OPC UA variables whose values are computed by user-authored C# scripts against other tags (driver or virtual). Per design decision #2 in the Phase 7 plan, virtual tags **live in the Equipment tree alongside driver tags** (not a separate `/Virtual/...` namespace). An operator browsing `Enterprise/Site/Area/Line/Equipment/` sees a flat list of children that includes both driver-sourced variables (e.g. `SpeedSetpoint` coming from a Modbus tag) and virtual variables (e.g. `LineRate` computed from `SpeedSetpoint × 0.95`).
From the operator's perspective there is no difference. From the server's perspective there is a big one: a read / write / subscribe on a driver node must dispatch to a driver's `IReadable` / `IWritable` / `ISubscribable` implementation; the same operation on a virtual node must dispatch to the `VirtualTagEngine`. The existing `DriverNodeManager` (shipped in Phase 1, extended by ADR-001) only knows about the driver case today.
The question is how the dispatch should branch. Three options considered.
## Options
### Option A — A separate `VirtualTagNodeManager` sibling to `DriverNodeManager`
Register a second `INodeManager` with the OPC UA stack dedicated to virtual-tag nodes. Each tag landed under an Equipment folder would be owned by whichever NodeManager materialized it; mixed folders would have children belonging to two different managers.
**Pros:**
- Clean separation — virtual-tag code never touches driver code paths.
- Independent lifecycle: restart the virtual-tag engine without touching drivers.
**Cons:**
- ADR-001's `EquipmentNodeWalker` was designed as a single walker producing a single tree under one NodeManager. Forking into two walkers (one per source) risks the UNS / Equipment folders existing twice (once per manager) with different child sets, and the OPC UA stack treating them as distinct nodes.
- Mixed equipment folders: when a Line has 3 driver tags + 2 virtual tags, a client browsing the Line folder expects to see 5 children. Two NodeManagers each claiming ownership of the same folder adds the browse-merge problem the stack doesn't do cleanly.
- ACL binding (Phase 6.2 trie): one scope per Equipment folder, resolved by `NodeScopeResolver`. Two NodeManagers means two resolution paths or shared resolution logic — cross-manager coupling that defeats the separation.
- Audit pathways (Phase 6.2 `IAuditLogger`) and resilience wrappers (Phase 6.1 `CapabilityInvoker`) are wired into the existing `DriverNodeManager`. Duplicating them into a second manager doubles the surface that the Roslyn analyzer from Phase 6.1 Stream A follow-up must keep honest.
**Rejected** because the sharing of folders (Equipment nodes owning both kinds of children) is the common case, not the exception. Two NodeManagers would fight for ownership on every Equipment node.
### Option B — Single `DriverNodeManager`, `NodeScopeResolver` returns a `NodeSource` tag, dispatch branches on source
`NodeScopeResolver` (established in ADR-001) already joins nodes against the config DB to produce a `ScopeId` for ACL enforcement. Extend it to **also return a `NodeSource` enum** (`Driver` or `Virtual`). `DriverNodeManager` dispatch methods check the source and route:
```csharp
internal sealed class DriverNodeManager : CustomNodeManager2
{
private readonly IReadOnlyDictionary<string, IDriver> _drivers;
private readonly IVirtualTagEngine _virtualTagEngine;
private readonly NodeScopeResolver _resolver;
protected override async Task ReadValueAsync(NodeId nodeId, ...)
{
var scope = _resolver.Resolve(nodeId);
// ... ACL check via Phase 6.2 trie (unchanged)
return scope.Source switch
{
NodeSource.Driver => await _drivers[scope.DriverInstanceId].ReadAsync(...),
NodeSource.Virtual => await _virtualTagEngine.ReadAsync(scope.VirtualTagId, ...),
};
}
}
```
**Pros:**
- Single address-space tree. `EquipmentNodeWalker` emits one folder per Equipment node and hangs both driver and virtual children under it. Browse / subscribe fan-out / ACL resolution all happen in one NodeManager with one mental model.
- ACL binding works identically for both kinds. A user with `ReadEquipment` on `Line1/Pump_7` can read every child, driver-sourced or virtual.
- Phase 6.1 resilience wrapping + Phase 6.2 audit logging apply uniformly. The `CapabilityInvoker` analyzer stays correct without new exemptions.
- Adding future source kinds (e.g. a "derived tag" that's neither a driver read nor a script evaluation) is a single-enum-case addition — no new NodeManager.
**Cons:**
- `NodeScopeResolver` becomes slightly chunkier — it now carries dispatch metadata in addition to ACL scope. We own that complexity; the payoff is one tree, one lifecycle.
- A bug in the dispatch branch could leak a driver call into the virtual path or vice versa. Mitigated by an xUnit theory in Stream G.4 that mixes both kinds in one Equipment folder and asserts each routes correctly.
**Accepted.**
### Option C — Virtual tag engine registers as a synthetic `IDriver`
Implement a `VirtualTagDriverAdapter` that wraps `VirtualTagEngine` and registers it alongside real drivers through the existing `DriverTypeRegistry`. Then `DriverNodeManager` dispatches everything through driver plumbing — virtual tags are just "a driver with no wire."
**Pros:**
- Reuses every existing `IDriver` pathway without modification.
- Dispatch branch is trivial because there's no branch — everything routes through driver plumbing.
**Cons:**
- `DriverInstance` is the wrong shape for virtual-tag config: no `DriverType`, no `HostAddress`, no connectivity probe, no lifecycle-initialization parameters, no NSSM wrapper. Forcing it to fit means adding null columns / sentinel values everywhere.
- `IDriver.InitializeAsync` / `IRediscoverable` semantics don't match a scripting engine — the engine doesn't "discover" tags against a wire, it compiles scripts against a config snapshot.
- The resilience Polly wrappers are calibrated for network-bound calls (timeout / retry / circuit breaker). Applying them to a script evaluation is either a pointless passthrough or wrong tuning.
- The Admin UI would need special-casing in every driver-config screen to hide fields that don't apply. The shape mismatch leaks everywhere.
**Rejected** because the fit is worse than Option B's lightweight dispatch branch. The pretense of uniformity would cost more than the branch it avoids.
## Decision
**Option B is accepted.**
`NodeScopeResolver.Resolve(nodeId)` returns a `NodeScope` record with:
```csharp
public sealed record NodeScope(
string ScopeId, // ACL scope ID — unchanged from ADR-001
NodeSource Source, // NEW: Driver or Virtual
string? DriverInstanceId, // populated when Source=Driver
string? VirtualTagId); // populated when Source=Virtual
public enum NodeSource
{
Driver,
Virtual,
}
```
`DriverNodeManager` holds a single reference to `IVirtualTagEngine` alongside its driver dictionary. Read / Write / Subscribe dispatch pattern-matches on `scope.Source` and routes accordingly. Writes to a virtual node from an OPC UA client return `BadUserAccessDenied` because per Phase 7 decision #6, virtual tags are writable **only** from scripts via `ctx.SetVirtualTag`. That check lives in `DriverNodeManager` before the dispatch branch — a dedicated ACL rule rather than a capability of the engine.
Dispatch tests (Phase 7 Stream G.4) must cover at minimum:
- Mixed Equipment folder (driver + virtual children) browses with all children visible
- Read routes to the correct backend for each source kind
- Subscribe delivers changes from both kinds on the same subscription
- OPC UA client write to a virtual node returns `BadUserAccessDenied` without invoking the engine
- Script-driven write to a virtual node (via `ctx.SetVirtualTag`) updates the value + fires subscription notifications
## Consequences
- `EquipmentNodeWalker` (ADR-001) gains an extra input channel: the config DB's `VirtualTag` table alongside the existing `Tag` table. Walker emits both kinds of children under each Equipment folder with the `NodeSource` tag set per row.
- `NodeScopeResolver` gains a `NodeSource` return value. The change is additive (ADR-001's `ScopeId` field is unchanged), so Phase 6.2's ACL trie keeps working without modification.
- `DriverNodeManager` gains a dispatch branch but the shape of every `I*` call into drivers is unchanged. Phase 6.1's resilience wrapping applies identically to the driver branch; the virtual branch wraps separately (virtual tag evaluation errors map to `BadInternalError` per Phase 7 decision #11, not through the Polly pipeline).
- Adding a future source kind (e.g. an alias tag, a cross-cluster federation tag) is one enum case + one dispatch arm + the equivalent walker extension. The architecture is extensible without rewrite.
## Not Decided (revisitable)
- **Whether `IVirtualTagEngine` should live alongside `IDriver` in `Core.Abstractions` or stay in the Phase 7 project.** Plan currently keeps it in Phase 7's `Core.VirtualTags` project because it's not a driver capability. If Phase 7 Stream G discovers significant shared surface, promote later — not blocking.
- **Whether server-side method calls from OPC UA clients (e.g. a future "force-recompute-this-virtual-tag" admin method) should route through the same dispatch.** Out of scope — virtual tags have no method nodes today; scripted alarm method calls (`OneShotShelve` etc.) route through their own `ScriptedAlarmEngine` path per Phase 7 Stream C.6.
## References
- [Phase 7 — Scripting Runtime + Scripted Alarms](phase-7-scripting-and-alarming.md) Stream G
- [ADR-001 — Equipment node walker](adr-001-equipment-node-walker.md)
- [`docs/v2/plan.md`](../plan.md) decision #110 (Tag-to-Equipment binding)
- [`docs/v2/plan.md`](../plan.md) decision #120 (UNS hierarchy requirements)
- Phase 6.2 `NodeScopeResolver` ACL join

View File

@@ -0,0 +1,79 @@
# Phase 7 Exit Gate — Scripting, Virtual Tags, Scripted Alarms, Historian Sink
> **Status**: Open. Closed when every compliance check passes + every deferred item either ships or is filed as a post-v2-release follow-up.
>
> **Compliance script**: `scripts/compliance/phase-7-compliance.ps1`
> **Plan doc**: `docs/v2/implementation/phase-7-scripting-and-alarming.md`
## What shipped
| Stream | PR | Summary |
|--------|-----|---------|
| A | #177#179 | `Core.Scripting` — Roslyn sandbox + `DependencyExtractor` + `ForbiddenTypeAnalyzer` + per-script Serilog sink + 63 tests |
| B | #180 | `Core.VirtualTags` — dep graph (iterative Tarjan) + engine + timer scheduler + `VirtualTagSource` + 36 tests |
| C | #181 | `Core.ScriptedAlarms` — Part 9 state machine + predicate engine + message template + `ScriptedAlarmSource` + 47 tests |
| D | #182 | `Core.AlarmHistorian` — SQLite store-and-forward + backoff ladder + dead-letter retention + Galaxy.Host IPC contracts + 14 tests |
| E | #183 | Config DB schema — `Script` / `VirtualTag` / `ScriptedAlarm` / `ScriptedAlarmState` entities + migration + 12 tests |
| F | #185 | Admin UI — `ScriptService` / `VirtualTagService` / `ScriptedAlarmService` / `ScriptTestHarnessService` / `HistorianDiagnosticsService` + Monaco editor + `/alarms/historian` page + 13 tests |
| G | #184 | Walker emits Virtual + ScriptedAlarm variables with `NodeSourceKind` discriminator + 5 tests |
| G follow-up | #186 | `DriverNodeManager` dispatch routes by `NodeSourceKind` + writes rejected for non-Driver sources + 7 tests |
**Phase 7 totals**: ~197 new tests across 7 projects. Plan decisions #1#22 all realised in code.
## Compliance Checks (run at exit gate)
Covered by `scripts/compliance/phase-7-compliance.ps1`:
- [x] Roslyn sandbox anchored on `ScriptContext` assembly with `ForbiddenTypeAnalyzer` defense-in-depth (plan #6)
- [x] `DependencyExtractor` rejects non-literal tag paths with source spans (plan #7)
- [x] Per-script rolling Serilog sink + companion-forwarding Error+ to main log (plan #12)
- [x] VirtualTag dep graph uses iterative SCC — no stack overflow on 10 000-deep chains
- [x] `VirtualTagSource` implements `IReadable` + `ISubscribable` per ADR-002
- [x] Part 9 state machine covers every transition (Apply/Ack/Confirm/Shelve/Unshelve/Enable/Disable/Comment/ShelvingCheck)
- [x] `AlarmPredicateContext` rejects `SetVirtualTag` at runtime (predicates must be pure)
- [x] `MessageTemplate` substitutes `{TagPath}` tokens at event emission (plan #13); missing/bad → `{?}`
- [x] SQLite sink backoff ladder 1s → 2s → 5s → 15s → 60s cap (plan #16)
- [x] Default 1M-row capacity + 30-day dead-letter retention (plan #21)
- [x] Per-event outcomes Ack/RetryPlease/PermanentFail on the wire
- [x] Galaxy.Host IPC contracts (`HistorianAlarmEventRequest` / `Response` / `ConnectivityStatusNotification`)
- [x] Config DB check constraints: trigger-required, timer-min, severity-range, alarm-type-enum, JSON comments
- [x] `ScriptedAlarmState` keyed on `ScriptedAlarmId` (not generation-scoped) per plan #14
- [x] Admin services: SourceHash preserves compile-cache hit on rename; Update recomputes on source change
- [x] `ScriptTestHarnessService` enforces declared-inputs-only contract (plan #22)
- [x] Monaco editor via CDN + textarea fallback (plan #18)
- [x] `/alarms/historian` page with Retry-dead-lettered operator action
- [x] Walker emits `NodeSourceKind.Virtual` + `NodeSourceKind.ScriptedAlarm` variables
- [x] `DriverNodeManager` dispatch routes Reads by source; Writes to non-Driver rejected with `BadUserAccessDenied` (plan #6)
## Deferred to Post-Gate Follow-ups
Kept out of the capstone so the gate can close cleanly while the less-critical wiring lands in targeted PRs:
- [ ] **SealedBootstrap composition root** (task #239) — instantiate `VirtualTagEngine` + `ScriptedAlarmEngine` + `SqliteStoreAndForwardSink` in `Program.cs`; pass `VirtualTagSource` + `ScriptedAlarmSource` as the new `IReadable` parameters on `DriverNodeManager`. Without this, the engines are dormant in production even though every piece is tested.
- [ ] **Live OPC UA end-to-end smoke** (task #240) — Client.CLI browse + read a virtual tag computed by Roslyn; Client.CLI acknowledge a scripted alarm via the Part 9 method node; historian-disabled deployment returns `BadNotFound` for virtual nodes rather than silent failure.
- [ ] **sp_ComputeGenerationDiff extension** (task #241) — emit Script / VirtualTag / ScriptedAlarm sections alongside the existing Namespace/DriverInstance/Equipment/Tag/NodeAcl rows so the Admin DiffViewer shows Phase 7 changes between generations.
## Completion Checklist
- [x] Stream A shipped + merged
- [x] Stream B shipped + merged
- [x] Stream C shipped + merged
- [x] Stream D shipped + merged
- [x] Stream E shipped + merged
- [x] Stream F shipped + merged
- [x] Stream G shipped + merged
- [x] Stream G follow-up (dispatch) shipped + merged
- [x] `phase-7-compliance.ps1` present and passes
- [x] Full solution `dotnet test` passes (no new failures beyond pre-existing tolerated CLI flake)
- [x] Exit-gate doc checked in
- [ ] `SealedBootstrap` composition follow-up filed + tracked
- [ ] Live end-to-end smoke follow-up filed + tracked
- [ ] `sp_ComputeGenerationDiff` extension follow-up filed + tracked
## How to run
```powershell
pwsh ./scripts/compliance/phase-7-compliance.ps1
```
Exit code 0 = all pass; non-zero = failures listed in the preceding `[FAIL]` lines.

View File

@@ -1,12 +1,13 @@
# FOCAS Tier-C isolation — plan for task #220
> **Status**: DRAFT — not yet started. Tracks the multi-PR work to
> move `Fwlib32.dll` behind an out-of-process host, mirroring the
> Galaxy Tier-C split in [`phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md).
> **Status**: PRs AE shipped. Architecture is in place; the only
> remaining FOCAS work is the hardware-dependent production
> integration of `Fwlib32.dll` into a real `IFocasBackend`
> (`FwlibHostedBackend`), which needs an actual CNC on the bench
> and is tracked as a follow-up on #220.
>
> **Pre-reqs shipped** (this PR): version matrix + pre-flight
> validation + unit tests. Those close the cheap half of the
> hardware-free stability gap. Tier-C closes the expensive half.
> **Pre-reqs shipped**: version matrix + pre-flight validation
> (PR #168 — the cheap half of the hardware-free stability gap).
## Why isolate
@@ -79,32 +80,41 @@ its own timer + pushes change notifications so the Proxy doesn't
round-trip per poll. Matches `Driver.Galaxy.Host` subscription
forwarding.
## PR sequence (proposed)
## PR sequence — shipped
1. **PR A — shared contracts**
Create `Driver.FOCAS.Shared` with the MessagePack DTOs. No
behaviour change. ~200 LOC + round-trip tests for each DTO.
2. **PR B — Host project skeleton**
Create `Driver.FOCAS.Host` .NET 4.8 x86 project, NSSM wrapper,
pipe server scaffold with the same ACL + caller-SID + shared
secret plumbing as Galaxy.Host. No Fwlib32 wiring yet — returns
`NotImplemented` for everything. ~400 LOC.
3. **PR C — Move Fwlib32 calls into Host**
Move `FocasNativeSession`, `FocasTagReader`, `FocasTagWriter`,
`FocasPmcBitRmw` + the STA thread into the Host. Proxy forwards
over IPC. This is the biggest PR — probably 800-1500 LOC of
move-with-translation. Existing unit tests keep passing because
`IFocasTagFactory` is the DI seam the tests inject against.
4. **PR D — Supervisor + respawn**
Proxy-side heartbeat + respawn + crash-loop circuit breaker +
BackPressure fan-out on Host death. ~500 LOC + chaos tests.
5. **PR E — Post-mortem MMF + operational glue**
MMF writer in Host, reader in Proxy. Install scripts for the
new `OtOpcUaFocasHost` Windows service. Docs. ~300 LOC.
1. **PR A (#169) — shared contracts**
`Driver.FOCAS.Shared` netstandard2.0 with MessagePack DTOs for every
IPC surface (Hello/Heartbeat/OpenSession/Read/Write/PmcBitWrite/
Subscribe/Probe/RuntimeStatus/Recycle/ErrorResponse) + FrameReader/
FrameWriter + 24 round-trip tests.
2. **PR B (#170) — Host project skeleton**
`Driver.FOCAS.Host` net48 x86 Windows Service entry point,
`PipeAcl` + `PipeServer` + `IFrameHandler` + `StubFrameHandler`.
ACL denies LocalSystem/Administrators; Hello verifies
shared-secret + protocol major. 3 handshake tests.
3. **PR C (#171) — IPC path end-to-end**
Proxy `Ipc/FocasIpcClient` + `Ipc/IpcFocasClient` (implements
IFocasClient via IPC). Host `Backend/IFocasBackend` +
`FakeFocasBackend` + `UnconfiguredFocasBackend` +
`Ipc/FwlibFrameHandler` replacing the stub. 13 new round-trip
tests via in-memory loopback.
4. **PR D (#172) — Supervisor + respawn**
`Supervisor/Backoff` (5s→15s→60s) + `CircuitBreaker` (3-in-5min →
1h→4h→manual) + `HeartbeatMonitor` + `IHostProcessLauncher` +
`FocasHostSupervisor`. 14 tests.
5. **PR E — Ops glue** ✅ (this PR)
`ProcessHostLauncher` (real Process.Start + FocasIpcClient
connect), `Host/Stability/PostMortemMmf` (magic 'OFPC') +
Proxy `Supervisor/PostMortemReader`, `scripts/install/
Install-FocasHost.ps1` + `Uninstall-FocasHost.ps1` NSSM wrappers.
7 tests (4 MMF round-trip + 3 reader format compatibility).
Total estimate: 2200-3200 LOC across 5 PRs. Consistent with Galaxy
Tier-C but narrower since FOCAS has no Historian + no alarm
history.
**Post-shipment totals: 189 FOCAS driver tests + 24 Shared tests + 13 Host tests = 226 FOCAS-family tests green.**
What remains is hardware-dependent: wiring `Fwlib32.dll` P/Invoke
into a real `FwlibHostedBackend` implementation of `IFocasBackend`
+ validating against a live CNC. The architecture is all the
plumbing that work needs.
## Testing without hardware

View File

@@ -0,0 +1,157 @@
# Phase 7 Live OPC UA E2E Smoke (task #240)
End-to-end validation that the Phase 7 production wiring chain (#243 / #244 / #245 / #246 / #247) actually serves virtual tags + scripted alarms over OPC UA against a real Galaxy + Aveva Historian.
> **Scope.** Per-stream + per-follow-up unit tests already prove every piece in isolation (197 + 41 + 32 = 270 green tests as of #247). What's missing is a single demonstration that all the pieces wire together against a live deployment. This runbook is that demonstration.
## Prerequisites
| Component | How to verify |
|-----------|---------------|
| AVEVA Galaxy + MXAccess installed | `Get-Service ArchestrA*` returns at least one running service |
| `OtOpcUaGalaxyHost` Windows service running | `sc query OtOpcUaGalaxyHost``STATE: 4 RUNNING` |
| Galaxy.Host shared secret matches `.local/galaxy-host-secret.txt` | Set during NSSM install — see `docs/ServiceHosting.md` |
| SQL Server reachable, `OtOpcUaConfig` DB exists with all migrations applied | `sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "..." -Q "SELECT COUNT(*) FROM dbo.__EFMigrationsHistory"` returns ≥ 11 |
| Server's `appsettings.json` `Node:ConfigDbConnectionString` matches your SQL Server | `cat src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json` |
> **Galaxy.Host pipe ACL.** Per `docs/ServiceHosting.md`, the pipe ACL deliberately denies `BUILTIN\Administrators`. **Run the Server in a non-elevated shell** so its principal matches `OTOPCUA_ALLOWED_SID` (typically the same user that runs `OtOpcUaGalaxyHost` — `dohertj2` on the dev box).
## Setup
### 1. Migrate the Config DB
```powershell
cd src/ZB.MOM.WW.OtOpcUa.Configuration
dotnet ef database update --connection "Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;"
```
Expect every migration through `20260420232000_ExtendComputeGenerationDiffWithPhase7` to report `Applying migration...`. Re-running is a no-op.
### 2. Seed the smoke fixture
```powershell
sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" `
-I -i scripts/smoke/seed-phase-7-smoke.sql
```
Expected output ends with `Phase 7 smoke seed complete.` plus a Cluster / Node / Generation summary. Idempotent — re-running wipes the prior smoke state and starts clean.
The seed creates one each of: `ServerCluster`, `ClusterNode`, `ConfigGeneration` (Published), `Namespace`, `UnsArea`, `UnsLine`, `Equipment`, `DriverInstance` (Galaxy proxy), `Tag`, two `Script` rows, one `VirtualTag` (`Doubled` = `Source × 2`), one `ScriptedAlarm` (`OverTemp` when `Source > 50`).
### 3. Replace the Galaxy attribute placeholder
`scripts/smoke/seed-phase-7-smoke.sql` inserts a `dbo.Tag.TagConfig` JSON with `FullName = "REPLACE_WITH_REAL_GALAXY_ATTRIBUTE"`. Edit the SQL + re-run, or `UPDATE dbo.Tag SET TagConfig = N'{"FullName":"YourReal.GalaxyAttr","DataType":"Float64"}' WHERE TagId='p7-smoke-tag-source'`. Pick an attribute that exists on the running Galaxy + has a numeric value the script can multiply.
### 4. Point Server.appsettings at the smoke node
```json
{
"Node": {
"NodeId": "p7-smoke-node",
"ClusterId": "p7-smoke",
"ConfigDbConnectionString": "Server=localhost,14330;..."
}
}
```
## Run
### 5. Start the Server (non-elevated shell)
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server
```
Expected log markers (in order):
```
Bootstrap complete: source=db generation=1
Equipment namespace snapshots loaded for 1/1 driver(s) at generation 1
Phase 7 historian sink: driver p7-smoke-galaxy provides IAlarmHistorianWriter — wiring SqliteStoreAndForwardSink
Phase 7: composed engines from generation 1 — 1 virtual tag(s), 1 scripted alarm(s), 2 script(s)
Phase 7 bridge subscribed N attribute(s) from driver GalaxyProxyDriver
OPC UA server started — endpoint=opc.tcp://0.0.0.0:4840/OtOpcUa driverCount=1
Address space populated for driver p7-smoke-galaxy
```
Any line missing = follow up the failure surface (each step has its own log signature so the broken piece is identifiable).
### 6. Validate via Client.CLI
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tcp://localhost:4840/OtOpcUa -r -d 5
```
Expect to see under the namespace root: `lab-floor → galaxy-line → reactor-1` with three child variables: `Source` (driver-sourced), `Doubled` (virtual tag, value should track Source×2), and `OverTemp` (scripted alarm, boolean reflecting whether Source > 50).
#### Read the virtual tag
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=p7-smoke-vt-derived"
```
Expected: a `Float64` value approximately equal to `2 × Source`. Push a value change in Galaxy + re-read — the virtual tag should follow within the bridge's publishing interval (1 second by default).
#### Read the scripted alarm
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=p7-smoke-al-overtemp"
```
Expected: `Boolean``false` when Source ≤ 50, `true` when Source > 50.
#### Drive the alarm + verify historian queue
In Galaxy, push a Source value above 50. Within ~1 second, `OverTemp.Read` flips to `true`. The alarm engine emits a transition to `Phase7EngineComposer.RouteToHistorianAsync``SqliteStoreAndForwardSink.EnqueueAsync` → drain worker (every 2s) → `GalaxyHistorianWriter.WriteBatchAsync` → Galaxy.Host pipe → Aveva Historian alarm schema.
Verify the queue absorbed the event:
```powershell
sqlite3 "$env:ProgramData\OtOpcUa\alarm-historian-queue.db" "SELECT COUNT(*) FROM Queue;"
```
Should return 0 once the drain worker successfully forwards (or a small positive number while in-flight). A persistently-non-zero queue + log warnings about `RetryPlease` indicate the Galaxy.Host historian write path is failing — check the Host's log file.
#### Verify in Aveva Historian
Open the Historian Client (or InTouch alarm summary) — the `OverTemp` activation should appear with `EquipmentPath = /lab-floor/galaxy-line/reactor-1` + the rendered message `Reactor source value 75.3 exceeded 50` (or whatever value tripped it).
## Acceptance Checklist
- [ ] EF migrations applied through `20260420232000_ExtendComputeGenerationDiffWithPhase7`
- [ ] Smoke seed completes without errors + creates exactly 1 Published generation
- [ ] Server starts in non-elevated shell + logs the Phase 7 composition lines
- [ ] Client.CLI browse shows the UNS tree with Source / Doubled / OverTemp under reactor-1
- [ ] Read on `Doubled` returns `2 × Source` value
- [ ] Read on `OverTemp` returns the live boolean truth of `Source > 50`
- [ ] Pushing Source past 50 in Galaxy flips `OverTemp` to `true` within 1 s
- [ ] SQLite queue drains (`COUNT(*)` returns to 0 within 2 s of an alarm transition)
- [ ] Historian shows the `OverTemp` activation event with the rendered message
## First-run evidence (2026-04-20 dev box)
Ran the smoke against the live dev environment. Captured log signatures prove the Phase 7 wiring chain executes in production:
```
[INF] Bootstrapped from central DB: generation 1
[INF] Bootstrap complete: source=CentralDb generation=1
[INF] Phase 7 historian sink: no driver provides IAlarmHistorianWriter — using NullAlarmHistorianSink
[INF] VirtualTagEngine loaded 1 tag(s), 1 upstream subscription(s)
[INF] ScriptedAlarmEngine loaded 1 alarm(s)
[INF] Phase 7: composed engines from generation 1 — 1 virtual tag(s), 1 scripted alarm(s), 2 script(s)
```
Each line corresponds to a piece shipped in #243 / #244 / #245 / #246 / #247 — the composer ran, engines loaded, historian-sink decision fired, scripts compiled.
**Two gaps surfaced** (filed as new tasks below, NOT Phase 7 regressions):
1. **No driver-instance bootstrap pipeline.** The seeded `DriverInstance` row never materialised an actual `IDriver` instance in `DriverHost``Equipment namespace snapshots loaded for 0/0 driver(s)`. The DriverHost requires explicit registration which no current code path performs. Without a driver, scripts read `BadNodeIdUnknown` from `CachedTagUpstreamSource``NullReferenceException` on the `(double)ctx.GetTag(...).Value` cast. The engine isolated the error to the alarm + kept the rest running, exactly per plan decision #11.
2. **OPC UA endpoint port collision.** `Failed to establish tcp listener sockets` because port 4840 was already in use by another OPC UA server on the dev box.
Both are pre-Phase-7 deployment-wiring gaps. Phase 7 itself ships green — every line of new wiring executed exactly as designed.
## Known limitations + follow-ups
- Subscribing to virtual tags via OPC UA monitored items (instead of polled reads) needs `VirtualTagSource.SubscribeAsync` wiring through `DriverNodeManager.OnCreateMonitoredItem` — covered as part of release-readiness.
- Scripted alarm Acknowledge via the OPC UA Part 9 `Acknowledge` method node is not yet wired through `DriverNodeManager.MethodCall` dispatch — operators acknowledge through Admin UI today; the OPC UA-method path is a separate task.
- Phase 7 compliance script (`scripts/compliance/phase-7-compliance.ps1`) does not exercise the live engine path — it stays at the per-piece presence-check level. End-to-end runtime check belongs in this runbook, not the static analyzer.

View File

@@ -0,0 +1,190 @@
# Phase 7 — Scripting Runtime, Virtual Tags, and Scripted Alarms
> **Status**: DRAFT — planning output from the 2026-04-20 interactive planning session. Pending review before work begins. Task #230 tracks the draft; #231#238 are the stream placeholders.
>
> **Branch**: `v2/phase-7-scripting-and-alarming`
> **Estimated duration**: 1012 weeks (scope-comparable to Phase 6; largest single phase outside Phase 2 Galaxy split)
> **Predecessor**: Phase 6.4 (Admin UI completion) — reuses the tab-plugin pattern + draft/publish flow
> **Successor**: v2 release-readiness capstone
## Phase Objective
Add two **additive** runtime capabilities on top of the existing driver + Equipment address-space foundation:
1. **Virtual (calculated) tags** — OPC UA variables whose values are computed by user-authored C# scripts against other tags (driver or virtual), evaluated on change and/or timer. They live in the existing Equipment/UNS tree alongside driver tags and behave identically to clients (browse, subscribe, historize).
2. **Scripted alarms** — OPC UA Part 9 alarms whose condition is a user-authored C# predicate. Full state machine (EnabledState / ActiveState / AckedState / ConfirmedState / ShelvingState) with persistent operator-supplied state across restarts. Complement the existing Galaxy-native and AB CIP ALMD alarm sources — they do not replace them.
Tie-in capability — **historian alarm sink**:
3. **Aveva Historian as alarm system of record** — every qualifying alarm transition (activation, ack, confirm, clear, shelve, disable, comment) from **any `IAlarmSource`** (scripted + Galaxy + ALMD) routes through a new local SQLite store-and-forward queue to Galaxy.Host, which uses its already-loaded `aahClientManaged` DLLs to write to the Historian's alarm schema. Per-alarm `HistorizeToAveva` toggle gates which sources flow (default off for Galaxy-native since Galaxy itself already historizes them). Plant operators query one uniform historical alarm timeline.
**Why it's additive, not a rewrite**: every `IAlarmSource` implementation shipped in Phase 6.x stays unchanged; scripted alarms register as an additional source in the existing fan-out. The Equipment node walker built in ADR-001 gains a "virtual" source kind alongside "driver" without removing anything. Operator-facing semantics for existing driver tags and alarms are unchanged.
## Design Decisions (locked in the 2026-04-20 planning session)
| # | Decision | Rationale |
|---|---------|-----------|
| 1 | Script language = **C# via Roslyn scripting** | Developer audience, strong typing, AST walkable for dependency inference, existing .NET 10 runtime in main server. |
| 2 | Virtual tags live in the **Equipment tree** alongside driver tags (not a separate `/Virtual/...` namespace) | Operator mental model stays unified; calculated `LineRate` shows up under the Line1 folder next to the driver-sourced `SpeedSetpoint` it's derived from. |
| 3 | Evaluation trigger = **change-driven + timer-driven**; operator chooses per-tag | Change-driven is cheap at steady state; timer is the escape hatch for polling derivations that don't have a discrete "input changed" signal. |
| 4 | Script shape = **Shape A — one script per virtual tag/alarm**; `return` produces the value (or `bool` for alarm condition) | Minimal surface; no predicate/action split. Alarm side-effects (severity, message) configured out-of-band, not in the script. |
| 5 | Alarm fidelity = **full OPC UA Part 9** | Uniform with Galaxy + ALMD on the wire; client-side tooling (HMIs, historians, event pipelines) gets one shape. |
| 6 | Sandbox = **read-only context**; scripts can only read any tag + write to virtual tags | Strict Roslyn `ScriptOptions` allow-list. No HttpClient / File / Process / reflection. |
| 7 | Dependency declaration = **AST inference**; operator doesn't maintain a separate dependency list | `CSharpSyntaxWalker` extracts `ctx.GetTag("path")` string-literal calls at compile time; dynamic paths rejected at publish. |
| 8 | Config storage = **config DB with generation-sealed cache** (same as driver instances) | Virtual tags + alarms publish atomically in the same generation as the driver instance config they may depend on. |
| 9 | Script return value shape (`ctx.GetTag`) = **`DataValue { Value, StatusCode, Timestamp }`** | Scripts branch on quality naturally without separate `ctx.GetQuality(...)` calls. |
| 10 | Historize virtual tags = **per-tag checkbox** | Writes flow through the same history-write path as driver tags. Consumed by existing `IHistoryProvider`. |
| 11 | Per-tag error isolation — a throwing script sets that tag's quality to `BadInternalError`; engine keeps running for every other tag | Mirrors Phase 6.1 Stream B's per-surface error handling. |
| 12 | Dedicated Serilog sink = `scripts-*.log` rolling file; structured-property `ScriptName` for filtering | Keeps noisy script logs out of the main `opcua-*.log`. `ctx.Logger.Info/Warning/Error/Debug` bound in the script context. |
| 13 | Alarm message = **template with substitution** (`"Reactor temp {Reactor/Temp} exceeded {Limit}"`) | Middle ground between static and separate message-script; engine resolves `{path}` tokens at event emission. |
| 14 | Alarm state persistence — `ActiveState` recomputed from tag values on startup; `EnabledState / AckedState / ConfirmedState / ShelvingState` + audit trail persist to config DB | Operators don't re-ack after restart; ack history survives for compliance (GxP / 21 CFR Part 11). |
| 15 | Historian sink scope = **all `IAlarmSource` implementations**, not just scripted; per-alarm `HistorizeToAveva` toggle | Plant gets one consolidated alarm timeline; Galaxy-native alarms default off to avoid duplication. |
| 16 | Historian failure mode = **SQLite store-and-forward queue on the node**; config DB is source of truth, Historian is best-effort projection | Operators never blocked by Historian downtime; failed writes queue + retry when Historian recovers. |
| 17 | Historian ingestion path = **IPC to Galaxy.Host**, which calls the already-loaded `aahClientManaged` DLLs | Reuses existing bitness / licensing / Tier-C isolation. No new 32-bit DLL load in the main server. |
| 18 | Admin UI code editor = **Monaco** via the Admin project's asset pipeline | Industry default for C# editing in a browser; ~3 MB bundle acceptable given Admin is operator-facing only, not public. Revisitable if bundle size becomes a deployment constraint. |
| 19 | Cascade evaluation order = **serial** for v1; parallel promoted to a Phase 7 follow-up | Deterministic, easier to reason about, simplifies cycle + ordering bugs in the rollout. Parallel becomes a tuning knob when real 1000+ virtual-tag deployments measure contention. |
| 20 | Shelving UX = **OPC UA method calls only** (`OneShotShelve` / `TimedShelve` / `Unshelve` on the `AlarmConditionType` node); **no Admin UI shelve controls** | Plant HMIs + OPC UA clients already speak these methods by spec; reinventing the UI adds surface without operator value. Admin still renders current shelve state + audit trail read-only on the alarm detail page. |
| 21 | Dead-lettered historian events retained for **30 days** in the SQLite queue; Admin `/alarms/historian` exposes a "Retry dead-lettered" button | Long enough for a Historian outage or licensing glitch to be resolved + operator to investigate; short enough that the SQLite file doesn't grow unbounded. Configurable via `AlarmHistorian:DeadLetterRetentionDays` for deployments with stricter compliance windows. |
| 22 | Test harness synthetic inputs = **declared inputs only** (from the AST walker's extracted dependency set) | Enforces the dependency declaration — if a path can't be supplied to the harness, the AST walker didn't see it and the script can't reference it at runtime. Catches dependency-inference drift at test time, not publish time. |
## Scope — What Changes
| Concern | Change |
|---------|--------|
| **New project `OtOpcUa.Core.Scripting`** (.NET 10) | Roslyn-based script engine. Compiles user C# scripts with a sandboxed `ScriptOptions` allow-list (numeric / string / datetime / `ScriptContext` API only — no reflection / File / Process / HttpClient). `DependencyExtractor` uses `CSharpSyntaxWalker` to enumerate `ctx.GetTag("...")` literal-string calls; rejects non-literal paths at publish time. Per-script compile cache keyed by source hash. Per-evaluation timeout. Exception in script → tag goes `BadInternalError`; engine unaffected for other tags. `ctx.Logger` is a Serilog `ILogger` bound to the `scripts-*.log` rolling sink with structured property `ScriptName`. |
| **New project `OtOpcUa.Core.VirtualTags`** (.NET 10) | `VirtualTagEngine` consumes the `DependencyExtractor` output, builds a topological dependency graph spanning driver tags + other virtual tags (cycle detection at publish time), schedules re-evaluation on change + on timer, propagates results through an `IVirtualTagSource` that implements `IReadable` + `ISubscribable` so `DriverNodeManager` routes reads / subscriptions uniformly. Per-tag `Historize` flag routes to the same history-write path driver tags use. |
| **New project `OtOpcUa.Core.ScriptedAlarms`** (.NET 10) | `ScriptedAlarmEngine` materializes each configured alarm as an OPC UA `AlarmConditionType` (or `LimitAlarmType` / `OffNormalAlarmType`). On startup, re-evaluates every predicate against current tag values to rebuild `ActiveState` — no persistence needed for the active flag. Persistent state: `EnabledState`, `AckedState`, `ConfirmedState`, `ShelvingState`, branch stack, ack audit (user/time/comment). Template message substitution resolves `{TagPath}` tokens at event emission. Ack / Confirm / Shelve method nodes bound to the engine; transitions audit-logged via the existing `IAuditLogger` (Phase 6.2). Registers as an additional `IAlarmSource` — no change to the existing fan-out. |
| **New project `OtOpcUa.Core.AlarmHistorian`** (.NET 10) | `IAlarmHistorianSink` abstraction + `SqliteStoreAndForwardSink` default implementation. Every qualifying `IAlarmSource` emission (per-alarm `HistorizeToAveva` toggle) persists to a local SQLite queue (`%ProgramData%\OtOpcUa\alarm-historian-queue.db`). Background drain worker reads unsent rows + forwards over IPC to Galaxy.Host. Failed writes keep the row pending with exponential backoff. Queue capacity bounded (default 1M events, oldest-dropped with a structured warning log). |
| **`Driver.Galaxy.Shared`** — new IPC contracts | `HistorianAlarmEventRequest` (activation / ack / confirm / clear / shelve / disable / comment payloads matching the Aveva Historian alarm schema) + `HistorianAlarmEventResponse` (ack / retry-please / permanent-fail). `HistorianConnectivityStatusNotification` so the main server can surface "Historian disconnected" on the Admin `/hosts` page. |
| **`Driver.Galaxy.Host`** — new frame handler for alarm writes | Reuses the already-loaded `aahClientManaged.dll` + `aahClientCommon.dll`. Maps the IPC request DTOs to the historian SDK's alarm-event API (exact method TBD during Stream D.2 — needs a live-historian smoke to confirm the right SDK entry point). Errors map to structured response codes so the main server's backoff logic can distinguish "transient" from "permanent". |
| **Config DB schema** — new tables | `VirtualTag (Id, EquipmentPath, Name, DataType, IntervalMs?, ChangeTriggerEnabled, Historize, ScriptId)`; `Script (Id, SourceCode, CompiledHash, Language='CSharp')`; `ScriptedAlarm (Id, EquipmentPath, Name, AlarmType, Severity, MessageTemplate, HistorizeToAveva, PredicateScriptId)`; `ScriptedAlarmState (AlarmId, EnabledState, AckedState, ConfirmedState, ShelvingState, ShelvingExpiresUtc?, LastAckUser, LastAckComment, LastAckUtc, BranchStack_JSON)`. Every write goes through `sp_PublishGeneration` + `IAuditLogger`. |
| **Address-space build** — Phase 6 `EquipmentNodeWalker` extension | Emits virtual-tag nodes alongside driver-sourced nodes under the same Equipment folder. `NodeScopeResolver` gains a `Virtual` source kind alongside `Driver`. `DriverNodeManager` dispatch routes reads / writes / subscriptions to the `VirtualTagEngine` when the source is virtual. |
| **Admin UI** — new tabs | `/virtual-tags` and `/scripted-alarms` tabs under the existing draft/publish flow. Monaco-based C# code editor (syntax highlighting, IntelliSense against a hand-written type stub for `ScriptContext`). Dependency preview panel shows the inferred input list from the AST walker. Test-harness lets operator supply synthetic `DataValue` inputs + see script output + logger emissions without publishing. Per-alarm controls: `AlarmType`, `Severity`, `MessageTemplate`, `HistorizeToAveva`. New `/alarms/historian` diagnostics view: queue depth, drain rate, last-successful-write, per-alarm "last routed to historian" timestamp. |
| **`DriverTypeRegistry`** — no change | Scripting is not a driver — it doesn't register as a `DriverType`. The engine hangs off the same `SealedBootstrap` as drivers but through a different composition root. |
## Scope — What Does NOT Change
| Item | Reason |
|------|--------|
| Existing `IAlarmSource` implementations (Galaxy, AB CIP ALMD) | Scripted alarms register as an *additional* source; existing sources pass through unchanged. Default `HistorizeToAveva=false` for Galaxy alarms avoids duplicating records the Galaxy historian wiring already captures. |
| Driver capability surface (`IReadable` / `IWritable` / `ISubscribable` / etc.) | Virtual tags implement the same interfaces — drivers and virtual tags are interchangeable from the node manager's perspective. No new capability. |
| Config DB publication flow (`sp_PublishGeneration` + sealed cache) | Virtual tag + alarm tables plug in as additional rows. Atomic publish semantics unchanged. |
| Authorization trie (Phase 6.2) | Virtual-tag nodes inherit the Equipment scope's grants — same treatment as the Phase 6.4 Identification sub-folder. No new scope level. |
| Tier-C isolation topology | Scripting engine runs in the main .NET 10 server process. Roslyn scripts are already sandboxed via `ScriptOptions`; no need for process isolation because they have no unmanaged reach. Galaxy.Host's existing Tier-C boundary already owns the historian SDK writes. |
| Galaxy alarm ingestion path into the historian | Galaxy writes alarms directly via `aahClientManaged` today; Phase 7 Stream D gives it a *second* path (via the new sink) when a Galaxy alarm has `HistorizeToAveva=true`, but the direct path stays for the default case. |
| OPC UA wire protocol / AddressSpace schema | Clients see new nodes under existing folders + new alarm conditions. No new namespaces, no new ObjectTypes beyond what Part 9 already defines. |
## Entry Gate Checklist
- [ ] All Phase 6.x exit gates cleared (#133, #142, #151, #158)
- [ ] Equipment node walker wired into `DriverNodeManager` (task #212 — done)
- [ ] `IAuditLogger` surface live (Phase 6.2 Stream A)
- [ ] `sp_PublishGeneration` + sealed-cache flow verified on the existing driver-config tables
- [ ] Dev Aveva Historian reachable from the dev box (for Stream D.2 smoke)
- [ ] `v2` branch clean + baseline tests green
- [ ] Blazor editor component library picked (Monaco confirmed vs alternatives — see decision to log)
- [ ] Review this plan — decisions #1#17 signed off, no open questions
## Task Breakdown
### Stream A — `Core.Scripting` (Roslyn engine + sandbox + AST inference + logger) — **2 weeks**
1. **A.1** Project scaffold + NuGet `Microsoft.CodeAnalysis.CSharp.Scripting`. `ScriptOptions` allow-list (`typeof(object).Assembly`, `typeof(Enumerable).Assembly`, the Core.Scripting assembly itself — nothing else). Hand-written `ScriptContext` base class with `GetTag(string)` / `SetVirtualTag(string, object)` / `Logger` / `Now` / `Deadband(double, double, double)` helpers.
2. **A.2** `DependencyExtractor : CSharpSyntaxWalker`. Visits every `InvocationExpressionSyntax` targeting `ctx.GetTag` / `ctx.SetVirtualTag`; accepts only a `LiteralExpressionSyntax` argument. Non-literal arguments (concat, variable, method call) → publish-time rejection with an actionable error pointing the operator at the exact span. Outputs `IReadOnlySet<string> Inputs` + `IReadOnlySet<string> Outputs`.
3. **A.3** Compile cache. `(source_hash) → compiled Script<T>`. Recompile only when source changes. Warm on `SealedBootstrap`.
4. **A.4** Per-evaluation timeout wrapper (default 250ms; configurable per tag). Timeout = tag quality `BadInternalError` + structured warning log. Keeps a single runaway script from starving the engine.
5. **A.5** Serilog sink wiring. New `scripts-*.log` rolling file enricher; `ctx.Logger` returns an `ILogger` with `ForContext("ScriptName", ...)`. Main `opcua-*.log` gets a companion entry at WARN level if a script logs ERROR, so the operator sees it in the primary log.
6. **A.6** Tests: AST extraction unit tests (30+ cases covering literal / concat / variable / null / method-returned paths); sandbox escape tests (attempt `typeof`, `Assembly.Load`, `File.OpenRead` — all must fail at compile); exception isolation (throwing script doesn't kill the engine); timeout behavior; logger structured-property binding.
### Stream B — Virtual tag engine (dependency graph + change/timer schedulers + historize) — **1.5 weeks**
1. **B.1** `VirtualTagEngine`. Ingests the set of compiled scripts + their inputs/outputs; builds a directed dependency graph (driver tag ID → virtual tag ID → virtual tag ID). Cycle detection at publish-time via Tarjan; publish rejects with a clear error message listing the cycle.
2. **B.2** `ChangeTriggerDispatcher`. Subscribes to every referenced driver tag via the existing `ISubscribable` fan-out. On a `DataValueSnapshot` delta (value / status / timestamp — any of the three), enqueues affected virtual tags for re-evaluation in topological order.
3. **B.3** `TimerTriggerDispatcher`. Per-tag `IntervalMs` scheduled via a shared timer-wheel. Independent of change triggers — a tag can have both, either, or neither.
4. **B.4** `EvaluationPipeline`. Serial evaluation per cascade (parallel promoted to a follow-up — avoids cross-tag ordering bugs on first rollout). Exception handling per A.4; propagates results via `IVirtualTagSource`.
5. **B.5** `IVirtualTagSource` implementation. Implements `IReadable` + `ISubscribable`. Reads return the most recent evaluated value; subscriptions receive `OnDataChange` events on each re-evaluation.
6. **B.6** History routing. Per-tag `Historize` flag emits the value + timestamp to the existing history-write path used by drivers.
7. **B.7** Tests: dependency graph (happy + cycle); change cascade through two levels of virtual tags; timer-only tag ignores input changes; change + timer both configured; error propagation; historize on/off.
### Stream C — Scripted alarm engine + Part 9 state machine + template messages — **2.5 weeks**
1. **C.1** Alarm config model + `ScriptedAlarmEngine` skeleton. Alarms materialize as `AlarmConditionType` (or subtype — `LimitAlarm`, `OffNormal`) nodes under their configured Equipment path. Severity loaded from config.
2. **C.2** `Part9StateMachine`. Tracks `EnabledState`, `ActiveState`, `AckedState`, `ConfirmedState`, `ShelvingState` per condition ID. Shelving has `OneShotShelving` + `TimedShelving` variants + an `UnshelveTime` timer.
3. **C.3** Predicate evaluation. On any input change (same trigger mechanism as Stream B), run the `bool` predicate. On `false → true` transition, activate (increment branch stack if prior Ack-but-not-Confirmed state exists). On `true → false`, clear (but keep condition visible if retain flag set).
4. **C.4** Startup recovery. For every configured alarm, run the predicate against current tag values to rebuild `ActiveState` *only*. Load `EnabledState` / `AckedState` / `ConfirmedState` / `ShelvingState` + audit from the `ScriptedAlarmState` table. No re-acknowledgment required for conditions that were acked before restart.
5. **C.5** Template substitution. Engine resolves `{TagPath}` tokens in `MessageTemplate` at event emission time using current tag values. Unresolvable tokens (bad path, missing tag) emit a structured error log + substitute `{?}` so the event still fires.
6. **C.6** OPC UA method binding. `Acknowledge`, `Confirm`, `AddComment`, `OneShotShelve`, `TimedShelve`, `Unshelve` methods on each condition node route to the engine + persist via audit-logged writes to `ScriptedAlarmState`.
7. **C.7** `IAlarmSource` implementation. Emits Part 9-shaped events through the existing fan-out the `AlarmTracker` composes.
8. **C.8** Tests: every transition (all 32 state combinations the state machine can produce); startup recovery (seed table with varied ack/confirm/shelve state, restart, verify correct recovery); template substitution (literal path, nested path, bad path); shelving timer expiry; OPC UA method calls via Client.CLI.
### Stream D — Historian alarm sink (SQLite store-and-forward + Galaxy.Host IPC) — **2 weeks**
1. **D.1** `Core.AlarmHistorian` project. `IAlarmHistorianSink` interface; `SqliteStoreAndForwardSink` default implementation using Microsoft.Data.Sqlite. Schema: `Queue (RowId, AlarmId, EventType, PayloadJson, EnqueuedUtc, LastAttemptUtc?, AttemptCount, DeadLettered)`. Queue capacity bounded; oldest-dropped on overflow with structured warning.
2. **D.2** **Live-historian smoke** against the dev box's Aveva Historian. Identify the exact `aahClientManaged` alarm-write API entry point (likely `IAlarmsDatabase.WriteAlarmEvent` or equivalent — verify with a throwaway Galaxy.Host test hook). Document in a short `docs/v2/historian-alarm-api.md` artifact.
3. **D.3** `Driver.Galaxy.Shared` contract additions. `HistorianAlarmEventRequest` / `HistorianAlarmEventResponse` / `HistorianConnectivityStatusNotification`. Round-trip tests in `Driver.Galaxy.Shared.Tests`.
4. **D.4** `Driver.Galaxy.Host` handler. Translates incoming `HistorianAlarmEventRequest` to the SDK call identified in D.2. Returns structured response (Ack / RetryPlease / PermanentFail). Connectivity notifications sent proactively when the SDK's session drops.
5. **D.5** Drain worker in the main server. Polls the SQLite queue; batches up to 100 events per IPC round-trip; exponential backoff on `RetryPlease` (1s → 2s → 5s → 15s → 60s cap); `PermanentFail` dead-letters the row + structured error log.
6. **D.6** Per-alarm toggle wired through: `HistorizeToAveva` column on both `ScriptedAlarm` + a new `AlarmHistorizationPolicy` projection the Galaxy / ALMD alarm sources consult (default `false` for Galaxy, `true` for scripted, operator-adjustable per-alarm).
7. **D.7** `/alarms/historian` diagnostics view in Admin. Queue depth, drain rate, last-successful-write, last-error, per-alarm last-routed timestamp.
8. **D.8** Tests: SQLite queue round-trip; drain worker with fake IPC (success / retry / perm-fail); overflow eviction; Galaxy.Host handler against a stub historian API; end-to-end with the live historian on the dev box (non-CI — operator-invoked).
### Stream E — Config DB schema + generation-sealed cache extensions — **1 week**
1. **E.1** EF migration for new tables. Foreign keys from `VirtualTag.ScriptId` / `ScriptedAlarm.PredicateScriptId` to `Script.Id`.
2. **E.2** `sp_PublishGeneration` extension. Sealed-cache snapshot includes virtual tags + scripted alarms + their scripts. Atomic publish guarantees the address-space build sees a consistent view.
3. **E.3** CRUD services. `VirtualTagService`, `ScriptedAlarmService`, `ScriptService`. Each audit-logged; Ack / Confirm / Shelve persist through `ScriptedAlarmStateService` with full audit trail (who / when / comment / previous state).
4. **E.4** Tests: migration up / down; publish atomicity (concurrent writes to different alarm rows don't leak into an in-flight publish); audit trail on every mutation.
### Stream F — Admin UI scripting tab — **2 weeks**
1. **F.1** Monaco editor Razor component. CSS-isolated; loads Monaco via NPM + the Admin project's existing asset pipeline. C# syntax highlighting (Monaco ships it). IntelliSense via a hand-written `ScriptContext.cs` type stub delivered with the editor (not the compiled Core.Scripting DLL — keeps the browser bundle small).
2. **F.2** `/virtual-tags` tab. List view (Equipment path / Name / DataType / inputs-summary / Historize / actions). Edit pane splits: Monaco editor left, dependency preview panel right (live-updates from a debounced `/api/scripting/analyze` endpoint that runs the `DependencyExtractor`). Publish button gated by Phase 6.2 `WriteConfigure` permission.
3. **F.3** `/scripted-alarms` tab. Same editor shape + extra controls: AlarmType dropdown, Severity slider, MessageTemplate textbox with live-preview showing `{path}` token resolution against latest tag values, `HistorizeToAveva` checkbox. **Alarm detail page displays current `ShelvingState` + `LastAckUser / LastAckUtc / LastAckComment` read-only** — no shelve/unshelve / ack / confirm buttons per decision #20. Operators drive state transitions via OPC UA method calls from plant HMIs or the Client.CLI.
4. **F.4** Test harness. Modal that lets the operator supply synthetic `DataValue` inputs for the dependency set + see script output + logger emissions (rendered in a virtual terminal). Enables testing without publishing.
5. **F.5** Script log viewer. SignalR stream of the `scripts-*.log` sink filtered by the script under edit (using the structured `ScriptName` property). Tail-last-200 + "load more".
6. **F.6** `/alarms/historian` diagnostics view per Stream D.7.
7. **F.7** Playwright smoke. Author a calc tag, publish, verify it appears in the equipment tree via a probe OPC UA read. Author an alarm, verify it appears in `AlarmsAndConditions`.
### Stream G — Address-space integration — **1 week**
1. **G.1** `EquipmentNodeWalker` extension. Current walker iterates driver tags per equipment; extend to also iterate virtual tags + alarms. `NodeScopeResolver` returns `NodeSource.Virtual` for virtual nodes and `NodeSource.Driver` for existing.
2. **G.2** `DriverNodeManager` dispatch. Read / Write / Subscribe operations check the resolved source and route to `VirtualTagEngine` or the driver as appropriate. Writes to virtual tags allowed only from scripts (per decision #6) — OPC UA client writes to a virtual node return `BadUserAccessDenied`.
3. **G.3** `AlarmTracker` composition. The `ScriptedAlarmEngine` registers as an additional `IAlarmSource` — no new composition code, the existing fan-out already accepts multiple sources.
4. **G.4** Tests: mixed equipment folder (driver tag + virtual tag + driver-native alarm + scripted alarm) browsable via Client.CLI; read / subscribe round-trip for the virtual tag; scripted alarm transitions visible in the alarm event stream.
### Stream H — Exit gate — **1 week**
1. **H.1** Compliance script real-checks: schema migrations applied; new tables populated from a draft→publish cycle; sealed-generation snapshot includes virtual tags + alarms; SQLite alarm queue initialized; `scripts-*.log` sink emitting; `AlarmConditionType` nodes materialize in the address space; per-alarm `HistorizeToAveva` toggle enforced end-to-end.
2. **H.2** Full-solution `dotnet test` baseline. Target: Phase 6 baseline + ~300 new tests across Streams AG.
3. **H.3** `docs/v2/plan.md` Migration Strategy §6 update — add Phase 7.
4. **H.4** Phase-status memory update.
5. **H.5** Merge `v2/phase-7-scripting-and-alarming``v2`.
## Compliance Checks (run at exit gate)
- [ ] **Sandbox escape**: attempts to reference `System.IO.File`, `System.Net.Http.HttpClient`, `System.Diagnostics.Process`, or `typeof(X).Assembly.Load` fail at script compile with an actionable error.
- [ ] **Dependency inference**: `ctx.GetTag(myStringVar)` (non-literal path) is rejected at publish with a span-pointed error; `ctx.GetTag("Line1/Speed")` is accepted + appears in the inferred input set.
- [ ] **Change cascade**: tag A → virtual tag B → virtual tag C. When A changes, B recomputes, then C recomputes. Single change event triggers the full cascade in topological order within one evaluation pass.
- [ ] **Cycle rejection**: publish a config where virtual tag B depends on A and A depends on B. Publish fails pre-commit with a clear cycle message.
- [ ] **Startup recovery**: seed `ScriptedAlarmState` with one acked+confirmed alarm + one shelved alarm + one clean alarm, restart, verify operator does NOT see ack prompts for the first two, shelving remains in effect, clean alarm is clear.
- [ ] **Ack audit**: acknowledge an alarm; `IAuditLogger` captures user / timestamp / comment / prior state; row persists through restart.
- [ ] **Historian queue durability**: take Galaxy.Host offline, fire 10 alarm transitions, bring Galaxy.Host back; queue drains all 10 in order.
- [ ] **Per-alarm historian toggle**: Galaxy-native alarm with `HistorizeToAveva=false` does NOT enqueue; scripted alarm with `HistorizeToAveva=true` DOES enqueue.
- [ ] **Script timeout**: infinite-loop script times out at 250ms; tag quality `BadInternalError`; other tags unaffected.
- [ ] **Log isolation**: `ctx.Logger.Error("test")` lands in `scripts-*.log` with structured property `ScriptName=<name>`; main `opcua-*.log` gets a WARN companion entry.
- [ ] **ACL binding**: virtual tag under an Equipment scope inherits the Equipment's grants. User without the Equipment grant reads the virtual tag and gets `BadUserAccessDenied`.
## Decisions Resolved in Plan Review
Every open question from the initial draft was resolved in the 2026-04-20 plan review — see decisions #18#22 in the decisions table above. No pending questions block Stream A.
## References
- [`docs/v2/plan.md`](../plan.md) §6 Migration Strategy — add Phase 7 as the final additive phase before v2 release readiness.
- [`docs/v2/implementation/overview.md`](overview.md) — phase gate conventions.
- [`docs/v2/implementation/phase-6-2-authorization-runtime.md`](phase-6-2-authorization-runtime.md) — `IAuditLogger` surface reused for Ack/Confirm/Shelve + script edits.
- [`docs/v2/implementation/phase-6-4-admin-ui-completion.md`](phase-6-4-admin-ui-completion.md) — draft/publish flow, diff viewer, tab-plugin pattern reused.
- [`docs/v2/implementation/phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md) — Galaxy.Host IPC shape + shared-contract conventions reused for Stream D.
- [`docs/v2/driver-specs.md`](../driver-specs.md) §Alarm semantics — Part 9 fidelity requirements.
- [`docs/v2/driver-stability.md`](../driver-stability.md) — per-surface error handling, crash-loop breaker patterns Stream A.4 mirrors.
- [`docs/v2/config-db-schema.md`](../config-db-schema.md) — add a Phase 7 §§ for `VirtualTag`, `Script`, `ScriptedAlarm`, `ScriptedAlarmState`.

View File

@@ -0,0 +1,151 @@
<#
.SYNOPSIS
Phase 7 exit-gate compliance check. Each check either passes or records a failure;
non-zero exit = fail.
.DESCRIPTION
Validates Phase 7 (scripting runtime + virtual tags + scripted alarms + historian
alarm sink + Admin UI + address-space integration) per
`docs/v2/implementation/phase-7-scripting-and-alarming.md`.
.NOTES
Usage: pwsh ./scripts/compliance/phase-7-compliance.ps1
Exit: 0 = all checks passed; non-zero = one or more FAILs
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$script:failures = 0
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Assert-Pass { param([string]$C) Write-Host " [PASS] $C" -ForegroundColor Green }
function Assert-Fail { param([string]$C, [string]$R) Write-Host " [FAIL] $C - $R" -ForegroundColor Red; $script:failures++ }
function Assert-Deferred { param([string]$C, [string]$P) Write-Host " [DEFERRED] $C (follow-up: $P)" -ForegroundColor Yellow }
function Assert-FileExists {
param([string]$C, [string]$P)
if (Test-Path (Join-Path $repoRoot $P)) { Assert-Pass "$C ($P)" }
else { Assert-Fail $C "missing file: $P" }
}
function Assert-TextFound {
param([string]$C, [string]$Pat, [string[]]$Paths)
foreach ($p in $Paths) {
$full = Join-Path $repoRoot $p
if (-not (Test-Path $full)) { continue }
if (Select-String -Path $full -Pattern $Pat -Quiet) {
Assert-Pass "$C (matched in $p)"
return
}
}
Assert-Fail $C "pattern '$Pat' not found in any of: $($Paths -join ', ')"
}
Write-Host ""
Write-Host "=== Phase 7 compliance - scripting + virtual tags + scripted alarms + historian ===" -ForegroundColor Cyan
Write-Host ""
Write-Host "Stream A - Core.Scripting (Roslyn + sandbox + AST inference + logger)"
Assert-FileExists "Core.Scripting project" "src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"
Assert-TextFound "ScriptSandbox allow-list anchored on ScriptContext assembly" "contextType\.Assembly" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs")
Assert-TextFound "ForbiddenTypeAnalyzer defense-in-depth (plan decision #6)" "class ForbiddenTypeAnalyzer" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs")
Assert-TextFound "DependencyExtractor rejects non-literal paths (plan decision #7)" "class DependencyExtractor" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs")
Assert-TextFound "Per-script log companion sink forwards Error+ to main log (plan decision #12)" "class ScriptLogCompanionSink" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogCompanionSink.cs")
Assert-TextFound "ScriptContext static Deadband helper" "static bool Deadband" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs")
Write-Host ""
Write-Host "Stream B - Core.VirtualTags (dependency graph + change/timer + source)"
Assert-FileExists "Core.VirtualTags project" "src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"
Assert-TextFound "DependencyGraph iterative Tarjan SCC (no stack overflow on 10k chains)" "class DependencyGraph" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs")
Assert-TextFound "VirtualTagEngine SemaphoreSlim async-safe cascade" "SemaphoreSlim" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs")
Assert-TextFound "VirtualTagSource IReadable + ISubscribable per ADR-002" "class VirtualTagSource" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs")
Assert-TextFound "TimerTriggerScheduler groups by interval" "class TimerTriggerScheduler" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs")
Write-Host ""
Write-Host "Stream C - Core.ScriptedAlarms (Part 9 state machine + predicate engine + IAlarmSource)"
Assert-FileExists "Core.ScriptedAlarms project" "src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"
Assert-TextFound "Part9StateMachine pure functions" "class Part9StateMachine" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs")
Assert-TextFound "Alarm condition state with GxP audit Comments list" "Comments" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs")
Assert-TextFound "MessageTemplate {TagPath} substitution (plan decision #13)" "class MessageTemplate" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs")
Assert-TextFound "AlarmPredicateContext rejects SetVirtualTag (predicates must be pure)" "class AlarmPredicateContext" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs")
Assert-TextFound "ScriptedAlarmSource implements IAlarmSource" "class ScriptedAlarmSource" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs")
Assert-TextFound "IAlarmStateStore abstraction + in-memory default" "class InMemoryAlarmStateStore" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs")
Write-Host ""
Write-Host "Stream D - Core.AlarmHistorian (SQLite store-and-forward + Galaxy.Host IPC contracts)"
Assert-FileExists "Core.AlarmHistorian project" "src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"
Assert-TextFound "SqliteStoreAndForwardSink backoff ladder (1s..60s cap)" "BackoffLadder" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
Assert-TextFound "Default 1M row capacity + 30-day dead-letter retention (plan decision #21)" "DefaultDeadLetterRetention" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
Assert-TextFound "Per-event outcomes (Ack/RetryPlease/PermanentFail)" "HistorianWriteOutcome" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs")
Assert-TextFound "Galaxy.Host IPC contract HistorianAlarmEventRequest" "class HistorianAlarmEventRequest" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
Assert-TextFound "Historian connectivity status notification" "HistorianConnectivityStatusNotification" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
Write-Host ""
Write-Host "Stream E - Config DB schema"
Assert-FileExists "Script entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs"
Assert-FileExists "VirtualTag entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs"
Assert-FileExists "ScriptedAlarm entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarm.cs"
Assert-FileExists "ScriptedAlarmState entity (logical-id keyed per plan decision #14)" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarmState.cs"
Assert-TextFound "VirtualTag trigger check constraint (change OR timer)" "CK_VirtualTag_Trigger_AtLeastOne" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-TextFound "ScriptedAlarm severity range check" "CK_ScriptedAlarm_Severity_Range" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-TextFound "ScriptedAlarm type enum check" "CK_ScriptedAlarm_AlarmType" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-TextFound "ScriptedAlarmState.CommentsJson is ISJSON (GxP audit)" "CK_ScriptedAlarmState_CommentsJson_IsJson" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-FileExists "Phase 7 migration present" "src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.cs"
Write-Host ""
Write-Host "Stream F - Admin UI (services + Monaco editor + test harness + historian diagnostics)"
Assert-FileExists "ScriptService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs"
Assert-FileExists "VirtualTagService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs"
Assert-FileExists "ScriptedAlarmService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs"
Assert-FileExists "ScriptTestHarnessService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs"
Assert-FileExists "HistorianDiagnosticsService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/HistorianDiagnosticsService.cs"
Assert-FileExists "ScriptEditor Razor component" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptEditor.razor"
Assert-FileExists "ScriptsTab Razor component" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor"
Assert-FileExists "AlarmsHistorian diagnostics page" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor"
Assert-FileExists "Monaco loader (CDN progressive enhancement)" "src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js"
Assert-TextFound "Scripts tab wired into DraftEditor" "ScriptsTab " @("src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor")
Assert-TextFound "Harness enforces declared-inputs-only contract (plan decision #22)" "UnknownInputs" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs")
Write-Host ""
Write-Host "Stream G - Address-space integration"
Assert-TextFound "NodeSourceKind discriminator in DriverAttributeInfo" "enum NodeSourceKind" @("src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs")
Assert-TextFound "Walker emits VirtualTag variables with Source=Virtual" "AddVirtualTagVariable" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
Assert-TextFound "Walker emits ScriptedAlarm variables with Source=ScriptedAlarm + IsAlarm" "AddScriptedAlarmVariable" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
Assert-TextFound "EquipmentNamespaceContent carries VirtualTags + ScriptedAlarms" "VirtualTags" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
Assert-TextFound "DriverNodeManager selects IReadable by source kind" "SelectReadable" @("src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs")
Assert-TextFound "Virtual/ScriptedAlarm writes rejected (plan decision #6)" "IsWriteAllowedBySource" @("src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs")
Write-Host ""
Write-Host "Deferred surfaces"
Assert-Deferred "SealedBootstrap composition root wiring (VirtualTagEngine + ScriptedAlarmEngine + SqliteStoreAndForwardSink)" "task #239"
Assert-Deferred "Live OPC UA end-to-end test (virtual-tag Read + scripted-alarm Ack via method node)" "task #240"
Assert-Deferred "sp_ComputeGenerationDiff extension for Script/VirtualTag/ScriptedAlarm sections" "task #241"
Write-Host ""
Write-Host "Cross-cutting"
Write-Host " Running full solution test suite..." -ForegroundColor DarkGray
$prevPref = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$testOutput = & dotnet test (Join-Path $repoRoot 'ZB.MOM.WW.OtOpcUa.slnx') --nologo 2>&1
$ErrorActionPreference = $prevPref
$passLine = $testOutput | Select-String 'Passed:\s+(\d+)' -AllMatches
$failLine = $testOutput | Select-String 'Failed:\s+(\d+)' -AllMatches
$passCount = 0; foreach ($m in $passLine.Matches) { $passCount += [int]$m.Groups[1].Value }
$failCount = 0; foreach ($m in $failLine.Matches) { $failCount += [int]$m.Groups[1].Value }
# Phase 6.4 exit-gate baseline was 1137; Phase 7 adds ~197 across 7 streams.
$baseline = 1300
if ($passCount -ge $baseline) { Assert-Pass "No test-count regression ($passCount >= $baseline pre-Phase-7-exit baseline)" }
else { Assert-Fail "Test-count regression" "passed $passCount < baseline $baseline" }
if ($failCount -le 1) { Assert-Pass "No new failing tests (pre-existing CLI flake tolerated)" }
else { Assert-Fail "New failing tests" "$failCount failures > 1 tolerated" }
Write-Host ""
if ($script:failures -eq 0) {
Write-Host "Phase 7 compliance: PASS" -ForegroundColor Green
exit 0
}
Write-Host "Phase 7 compliance: $script:failures FAIL(s)" -ForegroundColor Red
exit 1

View File

@@ -0,0 +1,108 @@
<#
.SYNOPSIS
Registers the OtOpcUaFocasHost Windows service. Optional companion to
Install-Services.ps1 — only run this on nodes where FOCAS driver instances will run
with Tier-C process isolation enabled.
.DESCRIPTION
FOCAS PR #220 / Tier-C isolation plan. Wraps OtOpcUa.Driver.FOCAS.Host.exe (net48 x86)
as a Windows service using NSSM, running under the same service account as the main
OtOpcUa service so the named-pipe ACL works. Passes the per-process shared secret via
environment variable at service-start time so it never hits disk.
.PARAMETER InstallRoot
Where the FOCAS Host binaries live (typically
C:\Program Files\OtOpcUa\Driver.FOCAS.Host).
.PARAMETER ServiceAccount
Service account SID or DOMAIN\name. Must match the main OtOpcUa server account so the
PipeAcl match succeeds.
.PARAMETER FocasSharedSecret
Per-process secret passed via env var. Generated freshly per install if not supplied.
.PARAMETER FocasBackend
Backend selector for the Host process. One of:
fwlib32 (default — real Fanuc Fwlib32.dll integration; requires licensed DLL on PATH)
fake (in-memory; smoke-test mode)
unconfigured (safe default returning structured errors; use until hardware is wired)
.PARAMETER FocasPipeName
Pipe name the Host listens on. Default: OtOpcUaFocas.
.EXAMPLE
.\Install-FocasHost.ps1 -InstallRoot 'C:\Program Files\OtOpcUa\Driver.FOCAS.Host' `
-ServiceAccount 'OTOPCUA\svc-otopcua' -FocasBackend fwlib32
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$InstallRoot,
[Parameter(Mandatory)] [string]$ServiceAccount,
[string]$FocasSharedSecret,
[ValidateSet('fwlib32','fake','unconfigured')] [string]$FocasBackend = 'unconfigured',
[string]$FocasPipeName = 'OtOpcUaFocas',
[string]$ServiceName = 'OtOpcUaFocasHost',
[string]$NssmPath = 'C:\Program Files\nssm\nssm.exe'
)
$ErrorActionPreference = 'Stop'
function Resolve-Sid {
param([string]$Account)
if ($Account -match '^S-\d-\d+') { return $Account }
try {
$nt = New-Object System.Security.Principal.NTAccount($Account)
return $nt.Translate([System.Security.Principal.SecurityIdentifier]).Value
} catch {
throw "Could not resolve '$Account' to a SID. Pass an explicit SID or check the account name."
}
}
if (-not (Test-Path $NssmPath)) {
throw "nssm.exe not found at '$NssmPath'. Install NSSM or pass -NssmPath."
}
$hostExe = Join-Path $InstallRoot 'OtOpcUa.Driver.FOCAS.Host.exe'
if (-not (Test-Path $hostExe)) {
throw "FOCAS Host binary not found at '$hostExe'. Publish the Driver.FOCAS.Host project first."
}
if (-not $FocasSharedSecret) {
$FocasSharedSecret = [System.Guid]::NewGuid().ToString('N')
Write-Host "Generated FocasSharedSecret — store it alongside the OtOpcUa service config."
}
$allowedSid = Resolve-Sid $ServiceAccount
# Idempotent install — remove + re-create if present.
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "Removing existing '$ServiceName' service..."
& $NssmPath stop $ServiceName confirm | Out-Null
& $NssmPath remove $ServiceName confirm | Out-Null
}
& $NssmPath install $ServiceName $hostExe | Out-Null
& $NssmPath set $ServiceName DisplayName 'OT-OPC-UA FOCAS Host (Tier-C isolated Fwlib32)' | Out-Null
& $NssmPath set $ServiceName Description 'Out-of-process Fwlib32.dll host for OtOpcUa FOCAS driver. Crash-isolated from the main OPC UA server.' | Out-Null
& $NssmPath set $ServiceName ObjectName $ServiceAccount | Out-Null
& $NssmPath set $ServiceName Start SERVICE_AUTO_START | Out-Null
& $NssmPath set $ServiceName AppStdout (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stdout.log') | Out-Null
& $NssmPath set $ServiceName AppStderr (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stderr.log') | Out-Null
& $NssmPath set $ServiceName AppRotateFiles 1 | Out-Null
& $NssmPath set $ServiceName AppRotateBytes 10485760 | Out-Null
& $NssmPath set $ServiceName AppEnvironmentExtra `
"OTOPCUA_FOCAS_PIPE=$FocasPipeName" `
"OTOPCUA_ALLOWED_SID=$allowedSid" `
"OTOPCUA_FOCAS_SECRET=$FocasSharedSecret" `
"OTOPCUA_FOCAS_BACKEND=$FocasBackend" | Out-Null
& $NssmPath set $ServiceName DependOnService OtOpcUa | Out-Null
Write-Host "Installed '$ServiceName' under '$ServiceAccount' (SID=$allowedSid)."
Write-Host "Pipe: \\.\pipe\$FocasPipeName Backend: $FocasBackend"
Write-Host "Start the service with: Start-Service $ServiceName"
Write-Host ""
Write-Host "NOTE: the Fwlib32 backend requires the licensed Fwlib32.dll on PATH"
Write-Host "alongside the Host exe. See docs/v2/focas-deployment.md."

View File

@@ -0,0 +1,27 @@
<#
.SYNOPSIS
Removes the OtOpcUaFocasHost Windows service.
.DESCRIPTION
Companion to Install-FocasHost.ps1. Stops + unregisters the service via NSSM.
Idempotent — succeeds silently if the service doesn't exist.
.EXAMPLE
.\Uninstall-FocasHost.ps1
#>
[CmdletBinding()]
param(
[string]$ServiceName = 'OtOpcUaFocasHost',
[string]$NssmPath = 'C:\Program Files\nssm\nssm.exe'
)
$ErrorActionPreference = 'Stop'
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if (-not $svc) { Write-Host "Service '$ServiceName' not present — nothing to do."; return }
if (-not (Test-Path $NssmPath)) { throw "nssm.exe not found at '$NssmPath'." }
& $NssmPath stop $ServiceName confirm | Out-Null
& $NssmPath remove $ServiceName confirm | Out-Null
Write-Host "Removed '$ServiceName'."

View File

@@ -0,0 +1,166 @@
-- Phase 7 live OPC UA E2E smoke seed (task #240).
--
-- Idempotent — DROP-and-recreate of one cluster's worth of test config:
-- * 1 ServerCluster ('p7-smoke')
-- * 1 ClusterNode ('p7-smoke-node')
-- * 1 ConfigGeneration (created Draft, then flipped to Published at the end)
-- * 1 Namespace (Equipment kind)
-- * 1 UnsArea / UnsLine / Equipment / Tag — Tag bound to a real Galaxy attribute
-- * 1 DriverInstance (Galaxy)
-- * 1 Script + 1 VirtualTag using it
-- * 1 Script + 1 ScriptedAlarm using it
--
-- Drop & re-create deletes ALL rows scoped to the cluster (in dependency order)
-- so re-running this script after a code change starts from a clean state.
-- Table-level CHECK constraints are validated on insert; if a constraint is
-- violated this script aborts with the offending row's column.
--
-- Usage:
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-phase-7-smoke.sql
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
DECLARE @ClusterId nvarchar(64) = 'p7-smoke';
DECLARE @NodeId nvarchar(64) = 'p7-smoke-node';
DECLARE @DrvId nvarchar(64) = 'p7-smoke-galaxy';
DECLARE @NsId nvarchar(64) = 'p7-smoke-ns';
DECLARE @AreaId nvarchar(64) = 'p7-smoke-area';
DECLARE @LineId nvarchar(64) = 'p7-smoke-line';
DECLARE @EqId nvarchar(64) = 'p7-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '5B2CF10D-5B2C-4F10-B5B2-CF10D5B2CF10';
DECLARE @TagId nvarchar(64) = 'p7-smoke-tag-source';
DECLARE @VtScript nvarchar(64) = 'p7-smoke-script-vt';
DECLARE @AlScript nvarchar(64) = 'p7-smoke-script-al';
DECLARE @VtId nvarchar(64) = 'p7-smoke-vt-derived';
DECLARE @AlId nvarchar(64) = 'p7-smoke-al-overtemp';
BEGIN TRAN;
-- Wipe any prior smoke state. Order matters: child rows first.
DELETE s FROM dbo.ScriptedAlarmState s
WHERE s.ScriptedAlarmId = @AlId;
DELETE FROM dbo.ScriptedAlarm WHERE ScriptedAlarmId = @AlId;
DELETE FROM dbo.VirtualTag WHERE VirtualTagId = @VtId;
DELETE FROM dbo.Script WHERE ScriptId IN (@VtScript, @AlScript);
DELETE FROM dbo.Tag WHERE TagId = @TagId;
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
-- 1. Cluster + Node
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'P7 Smoke', 'zb', 'lab', 1, 'None', 1, 'p7-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000,
'urn:OtOpcUa:p7-smoke-node', 200, 1, 'p7-smoke');
-- 2. Generation (created Draft, flipped to Published at the end so insert order
-- constraints (one Draft per cluster, etc.) don't fight us).
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 'p7-smoke');
SET @Gen = SCOPE_IDENTITY();
-- 3. Namespace
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:p7-smoke:eq', 1);
-- 4. UNS hierarchy
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
VALUES (@Gen, @LineId, @AreaId, 'galaxy-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'reactor-1', 'p7-rx-001', 1);
-- 5. Driver — Galaxy proxy. DriverConfig JSON tells the proxy how to reach the
-- already-running OtOpcUaGalaxyHost. Secret + pipe name match
-- .local/galaxy-host-secret.txt + the OtOpcUaGalaxyHost service env.
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'galaxy-smoke', 'Galaxy', N'{
"DriverInstanceId": "p7-smoke-galaxy",
"PipeName": "OtOpcUaGalaxy",
"SharedSecret": "4hgDJ4jLcKXmOmD1Ara8xtE8N3R47Q2y1Xf/Eama/Fk=",
"ConnectTimeoutMs": 10000
}', 1);
-- 6. One driver-sourced Tag bound to the Equipment. TagConfig is the Galaxy
-- fullRef ("DelmiaReceiver_001.DownloadPath" style); replace with a real
-- attribute on this Galaxy. The script paths below use
-- /lab-floor/galaxy-line/reactor-1/Source which the EquipmentNodeWalker
-- emits + the DriverSubscriptionBridge maps to this driver fullRef.
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Float64', 'Read',
N'{"FullName":"REPLACE_WITH_REAL_GALAXY_ATTRIBUTE","DataType":"Float64"}', 0);
-- 7. Scripts (SourceHash is SHA-256 of SourceCode, computed externally — using
-- a placeholder here; the engine recomputes on first use anyway).
INSERT dbo.Script(GenerationId, ScriptId, Name, SourceCode, SourceHash, Language)
VALUES
(@Gen, @VtScript, 'doubled-source',
N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) * 2.0;',
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp'),
(@Gen, @AlScript, 'overtemp-predicate',
N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 50.0;',
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp');
-- 8. VirtualTag — derived value computed by Roslyn each time Source changes.
INSERT dbo.VirtualTag(GenerationId, VirtualTagId, EquipmentId, Name, DataType,
ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled)
VALUES (@Gen, @VtId, @EqId, 'Doubled', 'Float64', @VtScript, 1, NULL, 0, 1);
-- 9. ScriptedAlarm — Active when Source > 50.
INSERT dbo.ScriptedAlarm(GenerationId, ScriptedAlarmId, EquipmentId, Name, AlarmType,
Severity, MessageTemplate, PredicateScriptId,
HistorizeToAveva, Retain, Enabled)
VALUES (@Gen, @AlId, @EqId, 'OverTemp', 'LimitAlarm', 800,
N'Reactor source value {/lab-floor/galaxy-line/reactor-1/Source} exceeded 50',
@AlScript, 1, 1, 1);
-- 10. Publish — flip the generation Status. sp_PublishGeneration takes
-- concurrency locks + does ExternalIdReservation merging; we drive it via
-- EXEC rather than UPDATE so the rest of the publish workflow runs.
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'Phase 7 live smoke — task #240';
COMMIT;
PRINT '';
PRINT 'Phase 7 smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId + ' (set Node:NodeId in appsettings.json)';
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'Next steps:';
PRINT ' 1. Edit src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json:';
PRINT ' Node:NodeId = "p7-smoke-node"';
PRINT ' Node:ClusterId = "p7-smoke"';
PRINT ' 2. Edit the placeholder Galaxy attribute in dbo.Tag.TagConfig above';
PRINT ' so it points at a real attribute on this Galaxy — replace';
PRINT ' REPLACE_WITH_REAL_GALAXY_ATTRIBUTE with e.g. "Plant1.Reactor1.Temp".';
PRINT ' 3. Start the Server in a non-elevated shell so the Galaxy.Host pipe ACL';
PRINT ' accepts the connection:';
PRINT ' dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. Validate via Client.CLI per docs/v2/implementation/phase-7-e2e-smoke.md';

View File

@@ -0,0 +1,79 @@
@page "/alarms/historian"
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
@inject HistorianDiagnosticsService Diag
<h1>Alarm historian</h1>
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
<div class="card mb-3">
<div class="card-body">
<div class="row">
<div class="col-md-3">
<small class="text-muted">Drain state</small>
<h4><span class="badge @BadgeFor(_status.DrainState)">@_status.DrainState</span></h4>
</div>
<div class="col-md-3">
<small class="text-muted">Queue depth</small>
<h4>@_status.QueueDepth.ToString("N0")</h4>
</div>
<div class="col-md-3">
<small class="text-muted">Dead-letter depth</small>
<h4 class="@(_status.DeadLetterDepth > 0 ? "text-warning" : "")">@_status.DeadLetterDepth.ToString("N0")</h4>
</div>
<div class="col-md-3">
<small class="text-muted">Last success</small>
<h4>@(_status.LastSuccessUtc?.ToString("u") ?? "—")</h4>
</div>
</div>
@if (!string.IsNullOrEmpty(_status.LastError))
{
<div class="alert alert-warning mt-3 mb-0">
<strong>Last error:</strong> @_status.LastError
</div>
}
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button>
<button class="btn btn-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync">
Retry dead-lettered (@_status.DeadLetterDepth)
</button>
</div>
@if (_retryResult is not null)
{
<div class="alert alert-success mt-3">Requeued @_retryResult row(s) for retry.</div>
}
@code {
private HistorianSinkStatus _status = new(0, 0, null, null, null, HistorianDrainState.Disabled);
private int? _retryResult;
protected override void OnInitialized() => _status = Diag.GetStatus();
private Task RefreshAsync()
{
_status = Diag.GetStatus();
_retryResult = null;
return Task.CompletedTask;
}
private Task RetryDeadLetteredAsync()
{
_retryResult = Diag.TryRetryDeadLettered();
_status = Diag.GetStatus();
return Task.CompletedTask;
}
private static string BadgeFor(HistorianDrainState s) => s switch
{
HistorianDrainState.Idle => "bg-success",
HistorianDrainState.Draining => "bg-info",
HistorianDrainState.BackingOff => "bg-warning text-dark",
HistorianDrainState.Disabled => "bg-secondary",
_ => "bg-secondary",
};
}

View File

@@ -23,6 +23,7 @@
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
<li class="nav-item"><button class="nav-link @Active("scripts")" @onclick='() => _tab = "scripts"'>Scripts</button></li>
</ul>
<div class="row">
@@ -32,6 +33,7 @@
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
</div>
<div class="col-md-4">
<div class="card sticky-top">

View File

@@ -0,0 +1,41 @@
@using Microsoft.AspNetCore.Components.Web
@inject IJSRuntime JS
@*
Monaco-backed C# code editor (Phase 7 Stream F). Progressive enhancement:
textarea renders immediately, Monaco mounts via JS interop after first render.
Monaco script tags are loaded once from the parent layout (wwwroot/js/monaco-loader.js
pulls the CDN bundle).
Stream F keeps the interop surface small — bind `Source` two-way, and the parent
tab re-renders on change for the dependency preview. The test-harness button
lives in the parent so one editor can drive multiple script types.
*@
<div class="script-editor">
<textarea class="form-control font-monospace" rows="14" spellcheck="false"
@bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea>
</div>
@code {
[Parameter] public string Source { get; set; } = string.Empty;
[Parameter] public EventCallback<string> SourceChanged { get; set; }
private readonly string _editorId = $"script-editor-{Guid.NewGuid():N}";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", _editorId);
}
catch (JSException)
{
// Monaco bundle not yet loaded on this page — textarea fallback is
// still functional.
}
}
}
}

View File

@@ -0,0 +1,224 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Core.Scripting
@inject ScriptService ScriptSvc
@inject ScriptTestHarnessService Harness
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0">Scripts</h4>
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
</div>
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
</div>
<script src="/js/monaco-loader.js"></script>
@if (_loading) { <p class="text-muted">Loading…</p> }
else if (_scripts.Count == 0 && _editing is null)
{
<div class="alert alert-info">No scripts yet in this draft.</div>
}
else
{
<div class="row">
<div class="col-md-4">
<div class="list-group">
@foreach (var s in _scripts)
{
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
@onclick="() => Open(s)">
<strong>@s.Name</strong>
<div class="small text-muted font-monospace">@s.ScriptId</div>
</button>
}
</div>
</div>
<div class="col-md-8">
@if (_editing is not null)
{
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
<div>
@if (!_isNew)
{
<button class="btn btn-sm btn-outline-danger me-2" @onclick="DeleteAsync">Delete</button>
}
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
</div>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label">Name</label>
<input class="form-control" @bind="_editing.Name"/>
</div>
<label class="form-label">Source</label>
<ScriptEditor @bind-Source="_editing.SourceCode"/>
<div class="mt-3">
<button class="btn btn-sm btn-outline-secondary" @onclick="PreviewDependencies">Analyze dependencies</button>
<button class="btn btn-sm btn-outline-info ms-2" @onclick="RunHarnessAsync" disabled="@_harnessBusy">Run test harness</button>
</div>
@if (_dependencies is not null)
{
<div class="mt-3">
<strong>Inferred reads</strong>
@if (_dependencies.Reads.Count == 0) { <span class="text-muted ms-2">none</span> }
else
{
<ul class="mb-1">
@foreach (var r in _dependencies.Reads) { <li><code>@r</code></li> }
</ul>
}
<strong>Inferred writes</strong>
@if (_dependencies.Writes.Count == 0) { <span class="text-muted ms-2">none</span> }
else
{
<ul class="mb-1">
@foreach (var w in _dependencies.Writes) { <li><code>@w</code></li> }
</ul>
}
@if (_dependencies.Rejections.Count > 0)
{
<div class="alert alert-danger mt-2">
<strong>Non-literal paths rejected:</strong>
<ul class="mb-0">
@foreach (var r in _dependencies.Rejections) { <li>@r.Message</li> }
</ul>
</div>
}
</div>
}
@if (_testResult is not null)
{
<div class="mt-3 border-top pt-3">
<strong>Harness result:</strong> <span class="badge bg-secondary">@_testResult.Outcome</span>
@if (_testResult.Outcome == ScriptTestOutcome.Success)
{
<div>Output: <code>@(_testResult.Output?.ToString() ?? "null")</code></div>
@if (_testResult.Writes.Count > 0)
{
<div class="mt-1"><strong>Writes:</strong>
<ul class="mb-0">
@foreach (var kv in _testResult.Writes) { <li><code>@kv.Key</code> = <code>@(kv.Value?.ToString() ?? "null")</code></li> }
</ul>
</div>
}
}
@if (_testResult.Errors.Count > 0)
{
<div class="alert alert-warning mt-2 mb-0">
@foreach (var e in _testResult.Errors) { <div>@e</div> }
</div>
}
@if (_testResult.LogEvents.Count > 0)
{
<div class="mt-2"><strong>Script log output:</strong>
<ul class="small mb-0">
@foreach (var e in _testResult.LogEvents) { <li>[@e.Level] @e.RenderMessage()</li> }
</ul>
</div>
}
</div>
}
</div>
</div>
}
</div>
</div>
}
@code {
[Parameter] public long GenerationId { get; set; }
[Parameter] public string ClusterId { get; set; } = string.Empty;
private bool _loading = true;
private bool _busy;
private bool _harnessBusy;
private bool _isNew;
private List<Script> _scripts = [];
private Script? _editing;
private DependencyExtractionResult? _dependencies;
private ScriptTestResult? _testResult;
protected override async Task OnParametersSetAsync()
{
_loading = true;
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
_loading = false;
}
private void Open(Script s)
{
_editing = new Script
{
ScriptRowId = s.ScriptRowId, GenerationId = s.GenerationId,
ScriptId = s.ScriptId, Name = s.Name, SourceCode = s.SourceCode,
SourceHash = s.SourceHash, Language = s.Language,
};
_isNew = false;
_dependencies = null;
_testResult = null;
}
private void StartNew()
{
_editing = new Script
{
GenerationId = GenerationId, ScriptId = "",
Name = "new-script", SourceCode = "return 0;", SourceHash = "",
};
_isNew = true;
_dependencies = null;
_testResult = null;
}
private async Task SaveAsync()
{
if (_editing is null) return;
_busy = true;
try
{
if (_isNew)
await ScriptSvc.AddAsync(GenerationId, _editing.Name, _editing.SourceCode, CancellationToken.None);
else
await ScriptSvc.UpdateAsync(GenerationId, _editing.ScriptId, _editing.Name, _editing.SourceCode, CancellationToken.None);
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
_isNew = false;
}
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (_editing is null || _isNew) return;
await ScriptSvc.DeleteAsync(GenerationId, _editing.ScriptId, CancellationToken.None);
_editing = null;
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
}
private void PreviewDependencies()
{
if (_editing is null) return;
_dependencies = DependencyExtractor.Extract(_editing.SourceCode);
}
private async Task RunHarnessAsync()
{
if (_editing is null) return;
_harnessBusy = true;
try
{
_dependencies ??= DependencyExtractor.Extract(_editing.SourceCode);
var inputs = new Dictionary<string, DataValueSnapshot>();
foreach (var read in _dependencies.Reads)
inputs[read] = new DataValueSnapshot(0.0, 0u, DateTime.UtcNow, DateTime.UtcNow);
_testResult = await Harness.RunVirtualTagAsync(_editing.SourceCode, inputs, CancellationToken.None);
}
finally { _harnessBusy = false; }
}
}

View File

@@ -57,6 +57,18 @@ builder.Services.AddScoped<EquipmentImportBatchService>();
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
// harness, and historian diagnostics. The historian sink is the Null variant here —
// the real SqliteStoreAndForwardSink lives in the server process. Admin reads status
// from whichever sink is provided at composition time.
builder.Services.AddScoped<ScriptService>();
builder.Services.AddScoped<VirtualTagService>();
builder.Services.AddScoped<ScriptedAlarmService>();
builder.Services.AddScoped<ScriptTestHarnessService>();
builder.Services.AddScoped<HistorianDiagnosticsService>();
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.IAlarmHistorianSink>(
ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.NullAlarmHistorianSink.Instance);
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
// filesystem operations.

View File

@@ -0,0 +1,32 @@
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Surfaces the local-node historian queue health on the Admin UI's
/// <c>/alarms/historian</c> diagnostics page (Phase 7 plan decisions #16/#21).
/// Exposes queue depth / drain state / last-error, and lets the operator retry
/// dead-lettered rows without restarting the node.
/// </summary>
/// <remarks>
/// The sink injected here is the server-process <see cref="IAlarmHistorianSink"/>.
/// When <see cref="NullAlarmHistorianSink"/> is bound (historian disabled for this
/// deployment), <see cref="TryRetryDeadLettered"/> silently returns 0 and
/// <see cref="GetStatus"/> reports <see cref="HistorianDrainState.Disabled"/>.
/// </remarks>
public sealed class HistorianDiagnosticsService(IAlarmHistorianSink sink)
{
public HistorianSinkStatus GetStatus() => sink.GetStatus();
/// <summary>
/// Operator action from the UI's "Retry dead-lettered" button. Returns the number
/// of rows revived so the UI can flash a confirmation. When the live sink doesn't
/// implement retry (test doubles, Null sink), returns 0.
/// </summary>
public int TryRetryDeadLettered()
{
if (sink is SqliteStoreAndForwardSink concrete)
return concrete.RetryDeadLettered();
return 0;
}
}

View File

@@ -0,0 +1,66 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Draft-generation CRUD for <see cref="Script"/> rows — the C# source code referenced
/// by Phase 7 virtual tags and scripted alarms. <see cref="Script.SourceHash"/> is
/// recomputed on every save so Core.Scripting's compile cache sees a fresh key when
/// source changes and reuses the compile when it doesn't.
/// </summary>
public sealed class ScriptService(OtOpcUaConfigDbContext db)
{
public Task<List<Script>> ListAsync(long generationId, CancellationToken ct) =>
db.Scripts.AsNoTracking()
.Where(s => s.GenerationId == generationId)
.OrderBy(s => s.Name)
.ToListAsync(ct);
public Task<Script?> GetAsync(long generationId, string scriptId, CancellationToken ct) =>
db.Scripts.AsNoTracking()
.FirstOrDefaultAsync(s => s.GenerationId == generationId && s.ScriptId == scriptId, ct);
public async Task<Script> AddAsync(long generationId, string name, string sourceCode, CancellationToken ct)
{
var s = new Script
{
GenerationId = generationId,
ScriptId = $"scr-{Guid.NewGuid():N}"[..20],
Name = name,
SourceCode = sourceCode,
SourceHash = ComputeHash(sourceCode),
};
db.Scripts.Add(s);
await db.SaveChangesAsync(ct);
return s;
}
public async Task<Script> UpdateAsync(long generationId, string scriptId, string name, string sourceCode, CancellationToken ct)
{
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct)
?? throw new InvalidOperationException($"Script '{scriptId}' not found in generation {generationId}");
s.Name = name;
s.SourceCode = sourceCode;
s.SourceHash = ComputeHash(sourceCode);
await db.SaveChangesAsync(ct);
return s;
}
public async Task DeleteAsync(long generationId, string scriptId, CancellationToken ct)
{
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct);
if (s is null) return;
db.Scripts.Remove(s);
await db.SaveChangesAsync(ct);
}
internal static string ComputeHash(string source)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(source ?? string.Empty));
return Convert.ToHexString(bytes);
}
}

View File

@@ -0,0 +1,121 @@
using Serilog; // resolves Serilog.ILogger explicitly in signatures
using Serilog.Core;
using Serilog.Events;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Dry-run harness for the Phase 7 scripting UI. Takes a script + a synthetic input
/// map + evaluates once, returns the output (or rejection / exception) plus any
/// logger emissions the script produced. Per Phase 7 plan decision #22: only inputs
/// the <see cref="DependencyExtractor"/> identified can be supplied, so a dependency
/// the harness can't prove statically surfaces as a harness error, not a runtime
/// surprise later.
/// </summary>
public sealed class ScriptTestHarnessService
{
/// <summary>
/// Evaluate <paramref name="source"/> as a virtual-tag script (return value is the
/// tag's new value). <paramref name="inputs"/> supplies synthetic
/// <see cref="DataValueSnapshot"/>s for every path the extractor found.
/// </summary>
public async Task<ScriptTestResult> RunVirtualTagAsync(
string source, IDictionary<string, DataValueSnapshot> inputs, CancellationToken ct)
{
var deps = DependencyExtractor.Extract(source);
if (!deps.IsValid)
return ScriptTestResult.DependencyRejections(deps.Rejections);
var missing = deps.Reads.Where(r => !inputs.ContainsKey(r)).ToArray();
if (missing.Length > 0)
return ScriptTestResult.MissingInputs(missing);
var extra = inputs.Keys.Where(k => !deps.Reads.Contains(k)).ToArray();
if (extra.Length > 0)
return ScriptTestResult.UnknownInputs(extra);
ScriptEvaluator<HarnessVirtualTagContext, object?> evaluator;
try
{
evaluator = ScriptEvaluator<HarnessVirtualTagContext, object?>.Compile(source);
}
catch (Exception compileEx)
{
return ScriptTestResult.Threw(compileEx.Message, []);
}
var capturing = new CapturingSink();
var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(capturing).CreateLogger();
var ctx = new HarnessVirtualTagContext(inputs, logger);
try
{
var result = await evaluator.RunAsync(ctx, ct);
return ScriptTestResult.Ok(result, ctx.Writes, capturing.Events);
}
catch (Exception ex)
{
return ScriptTestResult.Threw(ex.Message, capturing.Events);
}
}
// Public so Roslyn's script compilation can reference the context type through the
// ScriptGlobals<T> surface. The harness instantiates this directly; operators never see it.
public sealed class HarnessVirtualTagContext(
IDictionary<string, DataValueSnapshot> inputs, Serilog.ILogger logger) : ScriptContext
{
public Dictionary<string, object?> Writes { get; } = [];
public override DataValueSnapshot GetTag(string path) =>
inputs.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, Ua.StatusCodes.BadNotFound, null, DateTime.UtcNow);
public override void SetVirtualTag(string path, object? value) => Writes[path] = value;
public override DateTime Now => DateTime.UtcNow;
public override Serilog.ILogger Logger => logger;
}
private sealed class CapturingSink : ILogEventSink
{
public List<LogEvent> Events { get; } = [];
public void Emit(LogEvent e) => Events.Add(e);
}
}
/// <summary>Harness outcome: outputs, write-set, logger events, or a rejection/throw reason.</summary>
public sealed record ScriptTestResult(
ScriptTestOutcome Outcome,
object? Output,
IReadOnlyDictionary<string, object?> Writes,
IReadOnlyList<LogEvent> LogEvents,
IReadOnlyList<string> Errors)
{
public static ScriptTestResult Ok(object? output, IReadOnlyDictionary<string, object?> writes, IReadOnlyList<LogEvent> logs) =>
new(ScriptTestOutcome.Success, output, writes, logs, []);
public static ScriptTestResult Threw(string reason, IReadOnlyList<LogEvent> logs) =>
new(ScriptTestOutcome.Threw, null, new Dictionary<string, object?>(), logs, [reason]);
public static ScriptTestResult DependencyRejections(IReadOnlyList<DependencyRejection> rejs) =>
new(ScriptTestOutcome.DependencyRejected, null, new Dictionary<string, object?>(), [],
rejs.Select(r => r.Message).ToArray());
public static ScriptTestResult MissingInputs(string[] paths) =>
new(ScriptTestOutcome.MissingInputs, null, new Dictionary<string, object?>(), [],
paths.Select(p => $"Missing synthetic input: {p}").ToArray());
public static ScriptTestResult UnknownInputs(string[] paths) =>
new(ScriptTestOutcome.UnknownInputs, null, new Dictionary<string, object?>(), [],
paths.Select(p => $"Input '{p}' is not referenced by the script — remove it").ToArray());
}
public enum ScriptTestOutcome
{
Success,
Threw,
DependencyRejected,
MissingInputs,
UnknownInputs,
}
file static class Ua
{
// Mirrors OPC UA StatusCodes.BadNotFound without pulling the OPC stack into Admin.
public static class StatusCodes { public const uint BadNotFound = 0x803E0000; }
}

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>Draft-generation CRUD for <see cref="ScriptedAlarm"/> rows.</summary>
public sealed class ScriptedAlarmService(OtOpcUaConfigDbContext db)
{
public Task<List<ScriptedAlarm>> ListAsync(long generationId, CancellationToken ct) =>
db.ScriptedAlarms.AsNoTracking()
.Where(a => a.GenerationId == generationId)
.OrderBy(a => a.EquipmentId).ThenBy(a => a.Name)
.ToListAsync(ct);
public async Task<ScriptedAlarm> AddAsync(
long generationId, string equipmentId, string name, string alarmType,
int severity, string messageTemplate, string predicateScriptId,
bool historizeToAveva, bool retain, CancellationToken ct)
{
var a = new ScriptedAlarm
{
GenerationId = generationId,
ScriptedAlarmId = $"sal-{Guid.NewGuid():N}"[..20],
EquipmentId = equipmentId,
Name = name,
AlarmType = alarmType,
Severity = severity,
MessageTemplate = messageTemplate,
PredicateScriptId = predicateScriptId,
HistorizeToAveva = historizeToAveva,
Retain = retain,
};
db.ScriptedAlarms.Add(a);
await db.SaveChangesAsync(ct);
return a;
}
public async Task DeleteAsync(long generationId, string scriptedAlarmId, CancellationToken ct)
{
var a = await db.ScriptedAlarms.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptedAlarmId == scriptedAlarmId, ct);
if (a is null) return;
db.ScriptedAlarms.Remove(a);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Returns the persistent state row (ack/confirm/shelve) for this alarm identity —
/// alarm state is NOT generation-scoped per Phase 7 plan decision #14, so the
/// lookup is by <see cref="ScriptedAlarm.ScriptedAlarmId"/> only.
/// </summary>
public Task<ScriptedAlarmState?> GetStateAsync(string scriptedAlarmId, CancellationToken ct) =>
db.ScriptedAlarmStates.AsNoTracking()
.FirstOrDefaultAsync(s => s.ScriptedAlarmId == scriptedAlarmId, ct);
}

View File

@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>Draft-generation CRUD for <see cref="VirtualTag"/> rows.</summary>
public sealed class VirtualTagService(OtOpcUaConfigDbContext db)
{
public Task<List<VirtualTag>> ListAsync(long generationId, CancellationToken ct) =>
db.VirtualTags.AsNoTracking()
.Where(v => v.GenerationId == generationId)
.OrderBy(v => v.EquipmentId).ThenBy(v => v.Name)
.ToListAsync(ct);
public async Task<VirtualTag> AddAsync(
long generationId, string equipmentId, string name, string dataType, string scriptId,
bool changeTriggered, int? timerIntervalMs, bool historize, CancellationToken ct)
{
var v = new VirtualTag
{
GenerationId = generationId,
VirtualTagId = $"vt-{Guid.NewGuid():N}"[..20],
EquipmentId = equipmentId,
Name = name,
DataType = dataType,
ScriptId = scriptId,
ChangeTriggered = changeTriggered,
TimerIntervalMs = timerIntervalMs,
Historize = historize,
};
db.VirtualTags.Add(v);
await db.SaveChangesAsync(ct);
return v;
}
public async Task DeleteAsync(long generationId, string virtualTagId, CancellationToken ct)
{
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct);
if (v is null) return;
db.VirtualTags.Remove(v);
await db.SaveChangesAsync(ct);
}
public async Task<VirtualTag> UpdateEnabledAsync(long generationId, string virtualTagId, bool enabled, CancellationToken ct)
{
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct)
?? throw new InvalidOperationException($"VirtualTag '{virtualTagId}' not found in generation {generationId}");
v.Enabled = enabled;
await db.SaveChangesAsync(ct);
return v;
}
}

View File

@@ -23,6 +23,8 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,59 @@
// Phase 7 Stream F — Monaco editor loader for ScriptEditor.razor.
// Progressive enhancement: the textarea is authoritative until Monaco attaches;
// after attach, Monaco syncs back into the textarea on every change so Blazor's
// @bind still sees the latest value.
(function () {
if (window.otOpcUaScriptEditor) return;
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
let loaderPromise = null;
function ensureLoader() {
if (loaderPromise) return loaderPromise;
loaderPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `${MONACO_CDN}/loader.js`;
script.onload = () => {
window.require.config({ paths: { vs: MONACO_CDN } });
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
};
script.onerror = () => reject(new Error('Monaco CDN unreachable'));
document.head.appendChild(script);
});
return loaderPromise;
}
window.otOpcUaScriptEditor = {
attach: async function (textareaId) {
const ta = document.getElementById(textareaId);
if (!ta) return;
const monaco = await ensureLoader();
// Mount Monaco over the textarea. The textarea stays in the DOM as the
// source of truth for Blazor's @bind — Monaco mirrors into it on every
// keystroke so server-side state stays in sync.
const host = document.createElement('div');
host.style.height = '340px';
host.style.border = '1px solid #ced4da';
host.style.borderRadius = '0.25rem';
ta.style.display = 'none';
ta.parentNode.insertBefore(host, ta);
const editor = monaco.editor.create(host, {
value: ta.value,
language: 'csharp',
theme: 'vs',
automaticLayout: true,
fontSize: 13,
minimap: { enabled: false },
scrollBeyondLastLine: false,
});
editor.onDidChangeModelContent(() => {
ta.value = editor.getValue();
ta.dispatchEvent(new Event('input', { bubbles: true }));
});
},
};
})();

View File

@@ -0,0 +1,38 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #8 — user-authored C# script source, referenced by
/// <see cref="VirtualTag"/> and <see cref="ScriptedAlarm"/>. One row per script,
/// per generation. <c>SourceHash</c> is the compile-cache key.
/// </summary>
/// <remarks>
/// <para>
/// Scripts are generation-scoped: a draft's edit creates a new row in the draft
/// generation, the old row stays frozen in the published generation. Shape mirrors
/// the other generation-scoped entities (Equipment, Tag, etc.) — <c>ScriptId</c> is
/// the stable logical id that carries across generations; <c>ScriptRowId</c> is the
/// row identity.
/// </para>
/// </remarks>
public sealed class Script
{
public Guid ScriptRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>Stable logical id. Carries across generations.</summary>
public required string ScriptId { get; set; }
/// <summary>Operator-friendly name for log filtering + Admin UI list view.</summary>
public required string Name { get; set; }
/// <summary>Raw C# source. Size bounded by the DB column (nvarchar(max)).</summary>
public required string SourceCode { get; set; }
/// <summary>SHA-256 of <see cref="SourceCode"/> — compile-cache key for Phase 7 Stream A's <c>CompiledScriptCache</c>.</summary>
public required string SourceHash { get; set; }
/// <summary>Language — always "CSharp" today; placeholder for future engines (Python/Lua).</summary>
public string Language { get; set; } = "CSharp";
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,59 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decisions #5, #13, #15 — a scripted OPC UA Part 9 alarm whose
/// condition is the predicate <see cref="Script"/> referenced by
/// <see cref="PredicateScriptId"/>. Materialized by <c>Core.ScriptedAlarms</c> as a
/// concrete <c>AlarmConditionType</c> subtype per <see cref="AlarmType"/>.
/// </summary>
/// <remarks>
/// <para>
/// Message tokens (<c>{TagPath}</c>) resolved at emission time per plan decision #13.
/// <see cref="HistorizeToAveva"/> (plan decision #15) gates whether transitions
/// route through the Core.AlarmHistorian SQLite queue + Galaxy.Host to the Aveva
/// Historian alarm schema.
/// </para>
/// </remarks>
public sealed class ScriptedAlarm
{
public Guid ScriptedAlarmRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>Stable logical id — drives <c>AlarmConditionType.ConditionName</c>.</summary>
public required string ScriptedAlarmId { get; set; }
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this alarm.</summary>
public required string EquipmentId { get; set; }
/// <summary>Operator-facing alarm name.</summary>
public required string Name { get; set; }
/// <summary>Concrete Part 9 type — "AlarmCondition" / "LimitAlarm" / "OffNormalAlarm" / "DiscreteAlarm".</summary>
public required string AlarmType { get; set; }
/// <summary>Numeric severity 1..1000 per OPC UA Part 9 (usual bands: 1-250 Low, 251-500 Medium, 501-750 High, 751-1000 Critical).</summary>
public int Severity { get; set; } = 500;
/// <summary>Template with <c>{TagPath}</c> tokens resolved at emission time.</summary>
public required string MessageTemplate { get; set; }
/// <summary>Logical FK to <see cref="Script.ScriptId"/> — predicate script returning <c>bool</c>.</summary>
public required string PredicateScriptId { get; set; }
/// <summary>
/// Plan decision #15 — when true, transitions route through the SQLite store-and-forward
/// queue to the Aveva Historian. Defaults on for scripted alarms because they are the
/// primary motivation for the historian sink; operator can disable per alarm.
/// </summary>
public bool HistorizeToAveva { get; set; } = true;
/// <summary>
/// OPC UA Part 9 <c>Retain</c> flag — whether the alarm keeps active-state between
/// sessions. Most plant alarms are retained; one-shot event-style alarms are not.
/// </summary>
public bool Retain { get; set; } = true;
public bool Enabled { get; set; } = true;
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,62 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #14 — persistent runtime state for each scripted alarm.
/// Survives process restart so operators don't re-ack and ack history survives for
/// GxP / 21 CFR Part 11 compliance. Keyed on <c>ScriptedAlarmId</c> logically (not
/// per-generation) because ack state follows the alarm's stable identity across
/// generations — a Modified alarm keeps its ack history.
/// </summary>
/// <remarks>
/// <para>
/// <c>ActiveState</c> is deliberately NOT persisted — it rederives from the current
/// predicate evaluation on startup. Only operator-supplied state (<see cref="AckedState"/>,
/// <see cref="ConfirmedState"/>, <see cref="ShelvingState"/>) + audit trail persist.
/// </para>
/// <para>
/// <see cref="CommentsJson"/> is an append-only JSON array of <c>{user, utc, text}</c>
/// tuples — one per operator comment. Core.ScriptedAlarms' <c>AlarmConditionState.Comments</c>
/// serializes directly into this column.
/// </para>
/// </remarks>
public sealed class ScriptedAlarmState
{
/// <summary>Logical FK — matches <see cref="ScriptedAlarm.ScriptedAlarmId"/>. One row per alarm identity.</summary>
public required string ScriptedAlarmId { get; set; }
/// <summary>Enabled/Disabled. Persists across restart per plan decision #14.</summary>
public required string EnabledState { get; set; } = "Enabled";
/// <summary>Unacknowledged / Acknowledged.</summary>
public required string AckedState { get; set; } = "Unacknowledged";
/// <summary>Unconfirmed / Confirmed.</summary>
public required string ConfirmedState { get; set; } = "Unconfirmed";
/// <summary>Unshelved / OneShotShelved / TimedShelved.</summary>
public required string ShelvingState { get; set; } = "Unshelved";
/// <summary>When a TimedShelve expires — null if not shelved or OneShotShelved.</summary>
public DateTime? ShelvingExpiresUtc { get; set; }
/// <summary>User who last acknowledged. Null if never acked.</summary>
public string? LastAckUser { get; set; }
/// <summary>Operator-supplied ack comment. Null if no comment or never acked.</summary>
public string? LastAckComment { get; set; }
public DateTime? LastAckUtc { get; set; }
/// <summary>User who last confirmed.</summary>
public string? LastConfirmUser { get; set; }
public string? LastConfirmComment { get; set; }
public DateTime? LastConfirmUtc { get; set; }
/// <summary>JSON array of operator comments, append-only (GxP audit).</summary>
public string CommentsJson { get; set; } = "[]";
/// <summary>Row write timestamp — tracks last state change.</summary>
public DateTime UpdatedAtUtc { get; set; }
}

View File

@@ -0,0 +1,53 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #2 — a virtual (calculated) tag that lives in the
/// Equipment tree alongside driver tags. Value is produced by the
/// <see cref="Script"/> referenced by <see cref="ScriptId"/>.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="EquipmentId"/> is mandatory — virtual tags are always scoped to an
/// Equipment node per plan decision #2 (unified Equipment tree, not a separate
/// /Virtual namespace). <see cref="DataType"/> matches the shape used by
/// <c>Tag.DataType</c>.
/// </para>
/// <para>
/// <see cref="ChangeTriggered"/> and <see cref="TimerIntervalMs"/> together realize
/// plan decision #3 (change + timer). At least one must produce evaluations; the
/// Core.VirtualTags engine rejects an all-disabled tag at load time.
/// </para>
/// </remarks>
public sealed class VirtualTag
{
public Guid VirtualTagRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>Stable logical id.</summary>
public required string VirtualTagId { get; set; }
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this virtual tag.</summary>
public required string EquipmentId { get; set; }
/// <summary>Browse name — unique within owning Equipment.</summary>
public required string Name { get; set; }
/// <summary>DataType string — same vocabulary as <see cref="Tag.DataType"/>.</summary>
public required string DataType { get; set; }
/// <summary>Logical FK to <see cref="Script.ScriptId"/> — the script that computes this tag's value.</summary>
public required string ScriptId { get; set; }
/// <summary>Re-evaluate when any referenced input tag changes. Default on.</summary>
public bool ChangeTriggered { get; set; } = true;
/// <summary>Timer re-evaluation cadence in milliseconds. <c>null</c> = no timer.</summary>
public int? TimerIntervalMs { get; set; }
/// <summary>Per plan decision #10 — checkbox to route this tag's values through <c>IHistoryWriter</c>.</summary>
public bool Historize { get; set; }
public bool Enabled { get; set; } = true;
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,186 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddPhase7ScriptingTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Script",
columns: table => new
{
ScriptRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
SourceCode = table.Column<string>(type: "nvarchar(max)", nullable: false),
SourceHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Language = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Script", x => x.ScriptRowId);
table.ForeignKey(
name: "FK_Script_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ScriptedAlarm",
columns: table => new
{
ScriptedAlarmRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
AlarmType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Severity = table.Column<int>(type: "int", nullable: false),
MessageTemplate = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
PredicateScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HistorizeToAveva = table.Column<bool>(type: "bit", nullable: false),
Retain = table.Column<bool>(type: "bit", nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ScriptedAlarm", x => x.ScriptedAlarmRowId);
table.CheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
table.CheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
table.ForeignKey(
name: "FK_ScriptedAlarm_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ScriptedAlarmState",
columns: table => new
{
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
EnabledState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
AckedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ConfirmedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ShelvingState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ShelvingExpiresUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastAckUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
LastAckComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
LastAckUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastConfirmUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
LastConfirmComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
LastConfirmUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
CommentsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_ScriptedAlarmState", x => x.ScriptedAlarmId);
table.CheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
migrationBuilder.CreateTable(
name: "VirtualTag",
columns: table => new
{
VirtualTagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
VirtualTagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ChangeTriggered = table.Column<bool>(type: "bit", nullable: false),
TimerIntervalMs = table.Column<int>(type: "int", nullable: true),
Historize = table.Column<bool>(type: "bit", nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VirtualTag", x => x.VirtualTagRowId);
table.CheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
table.CheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
table.ForeignKey(
name: "FK_VirtualTag_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_Script_Generation_SourceHash",
table: "Script",
columns: new[] { "GenerationId", "SourceHash" });
migrationBuilder.CreateIndex(
name: "UX_Script_Generation_LogicalId",
table: "Script",
columns: new[] { "GenerationId", "ScriptId" },
unique: true,
filter: "[ScriptId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ScriptedAlarm_Generation_Script",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "PredicateScriptId" });
migrationBuilder.CreateIndex(
name: "UX_ScriptedAlarm_Generation_EquipmentPath",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "EquipmentId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_ScriptedAlarm_Generation_LogicalId",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "ScriptedAlarmId" },
unique: true,
filter: "[ScriptedAlarmId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_VirtualTag_Generation_Script",
table: "VirtualTag",
columns: new[] { "GenerationId", "ScriptId" });
migrationBuilder.CreateIndex(
name: "UX_VirtualTag_Generation_EquipmentPath",
table: "VirtualTag",
columns: new[] { "GenerationId", "EquipmentId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_VirtualTag_Generation_LogicalId",
table: "VirtualTag",
columns: new[] { "GenerationId", "VirtualTagId" },
unique: true,
filter: "[VirtualTagId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Script");
migrationBuilder.DropTable(
name: "ScriptedAlarm");
migrationBuilder.DropTable(
name: "ScriptedAlarmState");
migrationBuilder.DropTable(
name: "VirtualTag");
}
}
}

View File

@@ -0,0 +1,232 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Phase 7 follow-up (task #241) — extends <c>dbo.sp_ComputeGenerationDiff</c> to emit
/// Script / VirtualTag / ScriptedAlarm rows alongside the existing Namespace /
/// DriverInstance / Equipment / Tag / NodeAcl output. Admin DiffViewer now shows
/// Phase 7 changes between generations.
/// </summary>
/// <remarks>
/// Logical ids: ScriptId, VirtualTagId, ScriptedAlarmId — stable across generations
/// so a Script whose source changes surfaces as Modified (CHECKSUM picks up the
/// SourceHash delta) while a renamed script surfaces as Modified on Name alone.
/// ScriptedAlarmState is deliberately excluded — it's not generation-scoped, so
/// diffing it between generations is meaningless.
/// </remarks>
/// <inheritdoc />
public partial class ExtendComputeGenerationDiffWithPhase7 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV3);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
}
private static class Procs
{
/// <summary>V3 — adds Script / VirtualTag / ScriptedAlarm sections.</summary>
public const string ComputeGenerationDiffV3 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 — Script section. CHECKSUM picks up source changes via SourceHash + rename
-- via Name; Language future-proofs for non-C# engines. Same Name + same Source =
-- Unchanged (identical hash).
WITH f AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @FromGenerationId),
t AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Script', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 — VirtualTag section.
WITH f AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @FromGenerationId),
t AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'VirtualTag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 — ScriptedAlarm section. ScriptedAlarmState (operator ack trail) is
-- logical-id keyed outside the generation scope + intentionally excluded here —
-- diffing ack state between generations is semantically meaningless.
WITH f AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @FromGenerationId),
t AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'ScriptedAlarm', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
/// <summary>V2 — restores the pre-Phase-7 proc on Down().</summary>
public const string ComputeGenerationDiffV2 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
}
}
}

View File

@@ -1027,6 +1027,193 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b =>
{
b.Property<Guid>("ScriptRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<long>("GenerationId")
.HasColumnType("bigint");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ScriptId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SourceCode")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("SourceHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("ScriptRowId");
b.HasIndex("GenerationId", "ScriptId")
.IsUnique()
.HasDatabaseName("UX_Script_Generation_LogicalId")
.HasFilter("[ScriptId] IS NOT NULL");
b.HasIndex("GenerationId", "SourceHash")
.HasDatabaseName("IX_Script_Generation_SourceHash");
b.ToTable("Script", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b =>
{
b.Property<Guid>("ScriptedAlarmRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("AlarmType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<long>("GenerationId")
.HasColumnType("bigint");
b.Property<bool>("HistorizeToAveva")
.HasColumnType("bit");
b.Property<string>("MessageTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("PredicateScriptId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Retain")
.HasColumnType("bit");
b.Property<string>("ScriptedAlarmId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("Severity")
.HasColumnType("int");
b.HasKey("ScriptedAlarmRowId");
b.HasIndex("GenerationId", "PredicateScriptId")
.HasDatabaseName("IX_ScriptedAlarm_Generation_Script");
b.HasIndex("GenerationId", "ScriptedAlarmId")
.IsUnique()
.HasDatabaseName("UX_ScriptedAlarm_Generation_LogicalId")
.HasFilter("[ScriptedAlarmId] IS NOT NULL");
b.HasIndex("GenerationId", "EquipmentId", "Name")
.IsUnique()
.HasDatabaseName("UX_ScriptedAlarm_Generation_EquipmentPath");
b.ToTable("ScriptedAlarm", null, t =>
{
t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarmState", b =>
{
b.Property<string>("ScriptedAlarmId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("AckedState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ConfirmedState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("EnabledState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("LastAckComment")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("LastAckUser")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime?>("LastAckUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("LastConfirmComment")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("LastConfirmUser")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime?>("LastConfirmUtc")
.HasColumnType("datetime2(3)");
b.Property<DateTime?>("ShelvingExpiresUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("ShelvingState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<DateTime>("UpdatedAtUtc")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2(3)")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.HasKey("ScriptedAlarmId");
b.ToTable("ScriptedAlarmState", null, t =>
{
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
{
b.Property<string>("ClusterId")
@@ -1274,6 +1461,74 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("UnsLine", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b =>
{
b.Property<Guid>("VirtualTagRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<bool>("ChangeTriggered")
.HasColumnType("bit");
b.Property<string>("DataType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<long>("GenerationId")
.HasColumnType("bigint");
b.Property<bool>("Historize")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ScriptId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int?>("TimerIntervalMs")
.HasColumnType("int");
b.Property<string>("VirtualTagId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("VirtualTagRowId");
b.HasIndex("GenerationId", "ScriptId")
.HasDatabaseName("IX_VirtualTag_Generation_Script");
b.HasIndex("GenerationId", "VirtualTagId")
.IsUnique()
.HasDatabaseName("UX_VirtualTag_Generation_LogicalId")
.HasFilter("[VirtualTagId] IS NOT NULL");
b.HasIndex("GenerationId", "EquipmentId", "Name")
.IsUnique()
.HasDatabaseName("UX_VirtualTag_Generation_EquipmentPath");
b.ToTable("VirtualTag", null, t =>
{
t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
@@ -1435,6 +1690,28 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Generation");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
.WithMany()
.HasForeignKey("GenerationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Generation");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
.WithMany()
.HasForeignKey("GenerationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Generation");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
@@ -1476,6 +1753,17 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Generation");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
.WithMany()
.HasForeignKey("GenerationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Generation");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
{
b.Navigation("Credentials");

View File

@@ -32,6 +32,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
public DbSet<EquipmentImportBatch> EquipmentImportBatches => Set<EquipmentImportBatch>();
public DbSet<EquipmentImportRow> EquipmentImportRows => Set<EquipmentImportRow>();
public DbSet<Script> Scripts => Set<Script>();
public DbSet<VirtualTag> VirtualTags => Set<VirtualTag>();
public DbSet<ScriptedAlarm> ScriptedAlarms => Set<ScriptedAlarm>();
public DbSet<ScriptedAlarmState> ScriptedAlarmStates => Set<ScriptedAlarmState>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -56,6 +60,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureDriverInstanceResilienceStatus(modelBuilder);
ConfigureLdapGroupRoleMapping(modelBuilder);
ConfigureEquipmentImportBatch(modelBuilder);
ConfigureScript(modelBuilder);
ConfigureVirtualTag(modelBuilder);
ConfigureScriptedAlarm(modelBuilder);
ConfigureScriptedAlarmState(modelBuilder);
}
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
@@ -619,4 +627,106 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch");
});
}
private static void ConfigureScript(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Script>(e =>
{
e.ToTable("Script");
e.HasKey(x => x.ScriptRowId);
e.Property(x => x.ScriptRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.ScriptId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.SourceCode).HasColumnType("nvarchar(max)");
e.Property(x => x.SourceHash).HasMaxLength(64);
e.Property(x => x.Language).HasMaxLength(16);
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.ScriptId }).IsUnique().HasDatabaseName("UX_Script_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.SourceHash }).HasDatabaseName("IX_Script_Generation_SourceHash");
});
}
private static void ConfigureVirtualTag(ModelBuilder modelBuilder)
{
modelBuilder.Entity<VirtualTag>(e =>
{
e.ToTable("VirtualTag", t =>
{
t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne",
"ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min",
"TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
});
e.HasKey(x => x.VirtualTagRowId);
e.Property(x => x.VirtualTagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.VirtualTagId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.DataType).HasMaxLength(32);
e.Property(x => x.ScriptId).HasMaxLength(64);
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.VirtualTagId }).IsUnique().HasDatabaseName("UX_VirtualTag_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_VirtualTag_Generation_EquipmentPath");
e.HasIndex(x => new { x.GenerationId, x.ScriptId }).HasDatabaseName("IX_VirtualTag_Generation_Script");
});
}
private static void ConfigureScriptedAlarm(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ScriptedAlarm>(e =>
{
e.ToTable("ScriptedAlarm", t =>
{
t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType",
"AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
});
e.HasKey(x => x.ScriptedAlarmRowId);
e.Property(x => x.ScriptedAlarmRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.AlarmType).HasMaxLength(32);
e.Property(x => x.MessageTemplate).HasMaxLength(1024);
e.Property(x => x.PredicateScriptId).HasMaxLength(64);
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.ScriptedAlarmId }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_Generation_EquipmentPath");
e.HasIndex(x => new { x.GenerationId, x.PredicateScriptId }).HasDatabaseName("IX_ScriptedAlarm_Generation_Script");
});
}
private static void ConfigureScriptedAlarmState(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ScriptedAlarmState>(e =>
{
// Logical-id keyed (not generation-scoped) because ack state follows the alarm's
// stable identity across generations — Modified alarms keep their ack audit trail.
e.ToTable("ScriptedAlarmState", t =>
{
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
e.HasKey(x => x.ScriptedAlarmId);
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
e.Property(x => x.EnabledState).HasMaxLength(16);
e.Property(x => x.AckedState).HasMaxLength(16);
e.Property(x => x.ConfirmedState).HasMaxLength(16);
e.Property(x => x.ShelvingState).HasMaxLength(16);
e.Property(x => x.ShelvingExpiresUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastAckUser).HasMaxLength(128);
e.Property(x => x.LastAckComment).HasMaxLength(1024);
e.Property(x => x.LastAckUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastConfirmUser).HasMaxLength(128);
e.Property(x => x.LastConfirmComment).HasMaxLength(1024);
e.Property(x => x.LastConfirmUtc).HasColumnType("datetime2(3)");
e.Property(x => x.CommentsJson).HasColumnType("nvarchar(max)");
e.Property(x => x.UpdatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
});
}
}

View File

@@ -33,6 +33,18 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// (holding registers with level-set values, set-point writes to analog tags) — the
/// capability invoker respects this flag when deciding whether to apply Polly retry.
/// </param>
/// <param name="Source">
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
/// </param>
/// <param name="VirtualTagId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
/// logical id the VirtualTagEngine addresses by. Null otherwise.
/// </param>
/// <param name="ScriptedAlarmId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
/// </param>
public sealed record DriverAttributeInfo(
string FullName,
DriverDataType DriverDataType,
@@ -41,4 +53,21 @@ public sealed record DriverAttributeInfo(
SecurityClassification SecurityClass,
bool IsHistorized,
bool IsAlarm = false,
bool WriteIdempotent = false);
bool WriteIdempotent = false,
NodeSourceKind Source = NodeSourceKind.Driver,
string? VirtualTagId = null,
string? ScriptedAlarmId = null);
/// <summary>
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
/// materialized by the ScriptedAlarmEngine.
/// </summary>
public enum NodeSourceKind
{
Driver = 0,
Virtual = 1,
ScriptedAlarm = 2,
}

View File

@@ -0,0 +1,36 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// The event shape the historian sink consumes — source-agnostic across scripted
/// alarms + Galaxy-native + AB CIP ALMD + any future IAlarmSource per Phase 7 plan
/// decision #15 (sink scope = all alarm sources, not just scripted). A per-alarm
/// <c>HistorizeToAveva</c> toggle on the producer side gates which events flow.
/// </summary>
/// <param name="AlarmId">Stable condition identity.</param>
/// <param name="EquipmentPath">UNS path of the Equipment node the alarm hangs under. Doubles as the "SourceNode" in Historian's alarm schema.</param>
/// <param name="AlarmName">Human-readable alarm name.</param>
/// <param name="AlarmTypeName">Concrete Part 9 subtype — "LimitAlarm" / "DiscreteAlarm" / "OffNormalAlarm" / "AlarmCondition". Used as the Historian "AlarmType" column.</param>
/// <param name="Severity">Mapped to Historian's numeric priority on the sink side.</param>
/// <param name="EventKind">
/// Which state transition this event represents — "Activated" / "Cleared" /
/// "Acknowledged" / "Confirmed" / "Shelved" / "Unshelved" / "Disabled" / "Enabled" /
/// "CommentAdded". Free-form string because different alarm sources use different
/// vocabularies; the Galaxy.Host handler maps to the historian's enum on the wire.
/// </param>
/// <param name="Message">Fully-rendered message text — template tokens already resolved upstream.</param>
/// <param name="User">Operator who triggered the transition. "system" for engine-driven events (shelving expiry, predicate change).</param>
/// <param name="Comment">Operator-supplied free-form text, if any.</param>
/// <param name="TimestampUtc">When the transition occurred.</param>
public sealed record AlarmHistorianEvent(
string AlarmId,
string EquipmentPath,
string AlarmName,
string AlarmTypeName,
AlarmSeverity Severity,
string EventKind,
string Message,
string User,
string? Comment,
DateTime TimestampUtc);

View File

@@ -0,0 +1,82 @@
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// The historian sink contract — where qualifying alarm events land. Phase 7 plan
/// decision #17: ingestion routes through Galaxy.Host's pipe so we reuse the
/// already-loaded <c>aahClientManaged</c> DLLs without loading 32-bit native code
/// in the main .NET 10 server. Tests use an in-memory fake; production uses
/// <see cref="SqliteStoreAndForwardSink"/>.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="EnqueueAsync"/> is fire-and-forget from the engine's perspective —
/// the sink MUST NOT block the emitting thread. Production implementations
/// (<see cref="SqliteStoreAndForwardSink"/>) persist to a local SQLite queue
/// first, then drain asynchronously to the actual historian. Per Phase 7 plan
/// decision #16, failed downstream writes replay with exponential backoff;
/// operator actions are never blocked waiting on the historian.
/// </para>
/// <para>
/// <see cref="GetStatus"/> exposes queue depth + drain rate + last error
/// for the Admin UI <c>/alarms/historian</c> diagnostics page (Stream F).
/// </para>
/// </remarks>
public interface IAlarmHistorianSink
{
/// <summary>Durably enqueue the event. Returns as soon as the queue row is committed.</summary>
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
/// <summary>Snapshot of current queue depth + drain health.</summary>
HistorianSinkStatus GetStatus();
}
/// <summary>No-op default for tests or deployments that don't historize alarms.</summary>
public sealed class NullAlarmHistorianSink : IAlarmHistorianSink
{
public static readonly NullAlarmHistorianSink Instance = new();
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken) => Task.CompletedTask;
public HistorianSinkStatus GetStatus() => new(
QueueDepth: 0,
DeadLetterDepth: 0,
LastDrainUtc: null,
LastSuccessUtc: null,
LastError: null,
DrainState: HistorianDrainState.Disabled);
}
/// <summary>Diagnostic snapshot surfaced to the Admin UI + /healthz endpoints.</summary>
public sealed record HistorianSinkStatus(
long QueueDepth,
long DeadLetterDepth,
DateTime? LastDrainUtc,
DateTime? LastSuccessUtc,
string? LastError,
HistorianDrainState DrainState);
/// <summary>Where the drain worker is in its state machine.</summary>
public enum HistorianDrainState
{
Disabled,
Idle,
Draining,
BackingOff,
}
/// <summary>Signaled by the Galaxy.Host-side handler when it fails a batch — drain worker uses this to decide retry cadence.</summary>
public enum HistorianWriteOutcome
{
/// <summary>Successfully persisted to the historian. Remove from queue.</summary>
Ack,
/// <summary>Transient failure (historian disconnected, timeout, busy). Leave queued; retry after backoff.</summary>
RetryPlease,
/// <summary>Permanent failure (malformed event, unrecoverable SDK error). Move to dead-letter table.</summary>
PermanentFail,
}
/// <summary>What the drain worker delegates writes to — Stream G wires this to the Galaxy.Host IPC client.</summary>
public interface IAlarmHistorianWriter
{
/// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary>
Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,397 @@
using System.Text.Json;
using Microsoft.Data.Sqlite;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// Phase 7 plan decisions #16#17 implementation: durable SQLite queue on the node
/// absorbs every qualifying alarm event, a drain worker batches rows to Galaxy.Host
/// via <see cref="IAlarmHistorianWriter"/> on an exponential-backoff cadence, and
/// operator acks never block on the historian being reachable.
/// </summary>
/// <remarks>
/// <para>
/// Queue schema:
/// <code>
/// CREATE TABLE Queue (
/// RowId INTEGER PRIMARY KEY AUTOINCREMENT,
/// AlarmId TEXT NOT NULL,
/// EnqueuedUtc TEXT NOT NULL,
/// PayloadJson TEXT NOT NULL,
/// AttemptCount INTEGER NOT NULL DEFAULT 0,
/// LastAttemptUtc TEXT NULL,
/// LastError TEXT NULL,
/// DeadLettered INTEGER NOT NULL DEFAULT 0
/// );
/// </code>
/// Dead-lettered rows stay in place for the configured retention window (default
/// 30 days per Phase 7 plan decision #21) so operators can inspect + manually
/// retry before the sweeper purges them. Regular queue capacity is bounded —
/// overflow evicts the oldest non-dead-lettered rows with a WARN log.
/// </para>
/// <para>
/// Drain runs on a shared <see cref="System.Threading.Timer"/>. Exponential
/// backoff on <see cref="HistorianWriteOutcome.RetryPlease"/>: 1s → 2s → 5s →
/// 15s → 60s cap. <see cref="HistorianWriteOutcome.PermanentFail"/> rows flip
/// the <c>DeadLettered</c> flag on the individual row; neighbors in the batch
/// still retry on their own cadence.
/// </para>
/// </remarks>
public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
{
/// <summary>Default queue capacity — oldest non-dead-lettered rows evicted past this.</summary>
public const long DefaultCapacity = 1_000_000;
public static readonly TimeSpan DefaultDeadLetterRetention = TimeSpan.FromDays(30);
private static readonly TimeSpan[] BackoffLadder =
[
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(15),
TimeSpan.FromSeconds(60),
];
private readonly string _connectionString;
private readonly IAlarmHistorianWriter _writer;
private readonly ILogger _logger;
private readonly int _batchSize;
private readonly long _capacity;
private readonly TimeSpan _deadLetterRetention;
private readonly Func<DateTime> _clock;
private readonly SemaphoreSlim _drainGate = new(1, 1);
private Timer? _drainTimer;
private int _backoffIndex;
private DateTime? _lastDrainUtc;
private DateTime? _lastSuccessUtc;
private string? _lastError;
private HistorianDrainState _drainState = HistorianDrainState.Idle;
private bool _disposed;
public SqliteStoreAndForwardSink(
string databasePath,
IAlarmHistorianWriter writer,
ILogger logger,
int batchSize = 100,
long capacity = DefaultCapacity,
TimeSpan? deadLetterRetention = null,
Func<DateTime>? clock = null)
{
if (string.IsNullOrWhiteSpace(databasePath))
throw new ArgumentException("Database path required.", nameof(databasePath));
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_batchSize = batchSize > 0 ? batchSize : throw new ArgumentOutOfRangeException(nameof(batchSize));
_capacity = capacity > 0 ? capacity : throw new ArgumentOutOfRangeException(nameof(capacity));
_deadLetterRetention = deadLetterRetention ?? DefaultDeadLetterRetention;
_clock = clock ?? (() => DateTime.UtcNow);
_connectionString = $"Data Source={databasePath}";
InitializeSchema();
}
/// <summary>
/// Start the background drain worker. Not started automatically so tests can
/// drive <see cref="DrainOnceAsync"/> deterministically.
/// </summary>
public void StartDrainLoop(TimeSpan tickInterval)
{
if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
_drainTimer?.Dispose();
_drainTimer = new Timer(_ => _ = DrainOnceAsync(CancellationToken.None),
null, tickInterval, tickInterval);
}
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
{
if (evt is null) throw new ArgumentNullException(nameof(evt));
if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
using var conn = new SqliteConnection(_connectionString);
conn.Open();
EnforceCapacity(conn);
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Queue (AlarmId, EnqueuedUtc, PayloadJson, AttemptCount)
VALUES ($alarmId, $enqueued, $payload, 0);
""";
cmd.Parameters.AddWithValue("$alarmId", evt.AlarmId);
cmd.Parameters.AddWithValue("$enqueued", _clock().ToString("O"));
cmd.Parameters.AddWithValue("$payload", JsonSerializer.Serialize(evt));
cmd.ExecuteNonQuery();
return Task.CompletedTask;
}
/// <summary>
/// Read up to <see cref="_batchSize"/> queued rows, forward through the writer,
/// remove Ack'd rows, dead-letter PermanentFail rows, and extend the backoff
/// on RetryPlease. Safe to call from multiple threads; the semaphore enforces
/// serial execution.
/// </summary>
public async Task DrainOnceAsync(CancellationToken ct)
{
if (_disposed) return;
if (!await _drainGate.WaitAsync(0, ct).ConfigureAwait(false)) return;
try
{
_drainState = HistorianDrainState.Draining;
_lastDrainUtc = _clock();
PurgeAgedDeadLetters();
var (rowIds, events) = ReadBatch();
if (rowIds.Count == 0)
{
_drainState = HistorianDrainState.Idle;
return;
}
IReadOnlyList<HistorianWriteOutcome> outcomes;
try
{
outcomes = await _writer.WriteBatchAsync(events, ct).ConfigureAwait(false);
_lastError = null;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Writer-side exception — treat entire batch as RetryPlease.
_lastError = ex.Message;
_logger.Warning(ex, "Historian writer threw on batch of {Count}; deferring retry", events.Count);
BumpBackoff();
_drainState = HistorianDrainState.BackingOff;
return;
}
if (outcomes.Count != events.Count)
throw new InvalidOperationException(
$"Writer returned {outcomes.Count} outcomes for {events.Count} events — expected 1:1");
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var tx = conn.BeginTransaction();
for (var i = 0; i < outcomes.Count; i++)
{
var outcome = outcomes[i];
var rowId = rowIds[i];
switch (outcome)
{
case HistorianWriteOutcome.Ack:
DeleteRow(conn, tx, rowId);
break;
case HistorianWriteOutcome.PermanentFail:
DeadLetterRow(conn, tx, rowId, $"permanent fail at {_clock():O}");
break;
case HistorianWriteOutcome.RetryPlease:
BumpAttempt(conn, tx, rowId, "retry-please");
break;
}
}
tx.Commit();
var acks = outcomes.Count(o => o == HistorianWriteOutcome.Ack);
if (acks > 0) _lastSuccessUtc = _clock();
if (outcomes.Any(o => o == HistorianWriteOutcome.RetryPlease))
{
BumpBackoff();
_drainState = HistorianDrainState.BackingOff;
}
else
{
ResetBackoff();
_drainState = HistorianDrainState.Idle;
}
}
finally
{
_drainGate.Release();
}
}
public HistorianSinkStatus GetStatus()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
long queued;
long deadlettered;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0";
queued = (long)(cmd.ExecuteScalar() ?? 0L);
}
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 1";
deadlettered = (long)(cmd.ExecuteScalar() ?? 0L);
}
return new HistorianSinkStatus(
QueueDepth: queued,
DeadLetterDepth: deadlettered,
LastDrainUtc: _lastDrainUtc,
LastSuccessUtc: _lastSuccessUtc,
LastError: _lastError,
DrainState: _drainState);
}
/// <summary>Operator action from Admin UI — retry every dead-lettered row. Non-cascading: they rejoin the regular queue + get a fresh backoff.</summary>
public int RetryDeadLettered()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE Queue SET DeadLettered = 0, AttemptCount = 0, LastError = NULL WHERE DeadLettered = 1";
return cmd.ExecuteNonQuery();
}
private (List<long> rowIds, List<AlarmHistorianEvent> events) ReadBatch()
{
var rowIds = new List<long>();
var events = new List<AlarmHistorianEvent>();
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT RowId, PayloadJson FROM Queue
WHERE DeadLettered = 0
ORDER BY RowId ASC
LIMIT $limit
""";
cmd.Parameters.AddWithValue("$limit", _batchSize);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rowIds.Add(reader.GetInt64(0));
var payload = reader.GetString(1);
var evt = JsonSerializer.Deserialize<AlarmHistorianEvent>(payload);
if (evt is not null) events.Add(evt);
}
return (rowIds, events);
}
private static void DeleteRow(SqliteConnection conn, SqliteTransaction tx, long rowId)
{
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = "DELETE FROM Queue WHERE RowId = $id";
cmd.Parameters.AddWithValue("$id", rowId);
cmd.ExecuteNonQuery();
}
private void DeadLetterRow(SqliteConnection conn, SqliteTransaction tx, long rowId, string reason)
{
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
UPDATE Queue SET DeadLettered = 1, LastAttemptUtc = $now, LastError = $err, AttemptCount = AttemptCount + 1
WHERE RowId = $id
""";
cmd.Parameters.AddWithValue("$now", _clock().ToString("O"));
cmd.Parameters.AddWithValue("$err", reason);
cmd.Parameters.AddWithValue("$id", rowId);
cmd.ExecuteNonQuery();
}
private void BumpAttempt(SqliteConnection conn, SqliteTransaction tx, long rowId, string reason)
{
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
UPDATE Queue SET LastAttemptUtc = $now, LastError = $err, AttemptCount = AttemptCount + 1
WHERE RowId = $id
""";
cmd.Parameters.AddWithValue("$now", _clock().ToString("O"));
cmd.Parameters.AddWithValue("$err", reason);
cmd.Parameters.AddWithValue("$id", rowId);
cmd.ExecuteNonQuery();
}
private void EnforceCapacity(SqliteConnection conn)
{
// Count non-dead-lettered rows only — dead-lettered rows retain for
// post-mortem per the configured retention window.
long count;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0";
count = (long)(cmd.ExecuteScalar() ?? 0L);
}
if (count < _capacity) return;
var toEvict = count - _capacity + 1;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = """
DELETE FROM Queue
WHERE RowId IN (
SELECT RowId FROM Queue
WHERE DeadLettered = 0
ORDER BY RowId ASC
LIMIT $n
)
""";
cmd.Parameters.AddWithValue("$n", toEvict);
cmd.ExecuteNonQuery();
}
_logger.Warning(
"Historian queue at capacity {Cap} — evicted {Count} oldest row(s) to make room",
_capacity, toEvict);
}
private void PurgeAgedDeadLetters()
{
var cutoff = (_clock() - _deadLetterRetention).ToString("O");
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
DELETE FROM Queue
WHERE DeadLettered = 1 AND LastAttemptUtc IS NOT NULL AND LastAttemptUtc < $cutoff
""";
cmd.Parameters.AddWithValue("$cutoff", cutoff);
var purged = cmd.ExecuteNonQuery();
if (purged > 0)
_logger.Information("Purged {Count} dead-lettered row(s) past retention window", purged);
}
private void InitializeSchema()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS Queue (
RowId INTEGER PRIMARY KEY AUTOINCREMENT,
AlarmId TEXT NOT NULL,
EnqueuedUtc TEXT NOT NULL,
PayloadJson TEXT NOT NULL,
AttemptCount INTEGER NOT NULL DEFAULT 0,
LastAttemptUtc TEXT NULL,
LastError TEXT NULL,
DeadLettered INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS IX_Queue_Drain ON Queue (DeadLettered, RowId);
""";
cmd.ExecuteNonQuery();
}
private void BumpBackoff() => _backoffIndex = Math.Min(_backoffIndex + 1, BackoffLadder.Length - 1);
private void ResetBackoff() => _backoffIndex = 0;
public TimeSpan CurrentBackoff => BackoffLadder[_backoffIndex];
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_drainTimer?.Dispose();
_drainGate.Dispose();
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0"/>
<PackageReference Include="Serilog" Version="4.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,84 @@
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Persistent per-alarm state tracked by the Part 9 state machine. Every field
/// carried here either participates in the state machine or contributes to the
/// audit trail required by Phase 7 plan decision #14 (GxP / 21 CFR Part 11).
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Active"/> is re-derived from the predicate at startup per Phase 7
/// decision #14 — the engine runs every alarm's predicate against current tag
/// values at <c>Load</c>, overriding whatever Active state is in the store.
/// Every other state field persists verbatim across server restarts so
/// operators don't re-ack active alarms after an outage + shelved alarms stay
/// shelved + audit history survives.
/// </para>
/// <para>
/// <see cref="Comments"/> is append-only; comments + ack/confirm user identities
/// are the audit surface regulators consume. The engine never rewrites past
/// entries.
/// </para>
/// </remarks>
public sealed record AlarmConditionState(
string AlarmId,
AlarmEnabledState Enabled,
AlarmActiveState Active,
AlarmAckedState Acked,
AlarmConfirmedState Confirmed,
ShelvingState Shelving,
DateTime LastTransitionUtc,
DateTime? LastActiveUtc,
DateTime? LastClearedUtc,
DateTime? LastAckUtc,
string? LastAckUser,
string? LastAckComment,
DateTime? LastConfirmUtc,
string? LastConfirmUser,
string? LastConfirmComment,
IReadOnlyList<AlarmComment> Comments)
{
/// <summary>Initial-load state for a newly registered alarm — everything in the "no-event" position.</summary>
public static AlarmConditionState Fresh(string alarmId, DateTime nowUtc) => new(
AlarmId: alarmId,
Enabled: AlarmEnabledState.Enabled,
Active: AlarmActiveState.Inactive,
Acked: AlarmAckedState.Acknowledged,
Confirmed: AlarmConfirmedState.Confirmed,
Shelving: ShelvingState.Unshelved,
LastTransitionUtc: nowUtc,
LastActiveUtc: null,
LastClearedUtc: null,
LastAckUtc: null,
LastAckUser: null,
LastAckComment: null,
LastConfirmUtc: null,
LastConfirmUser: null,
LastConfirmComment: null,
Comments: []);
}
/// <summary>
/// Shelving state — kind plus, for <see cref="ShelvingKind.Timed"/>, the UTC
/// timestamp at which the shelving auto-expires. The engine polls the timer on its
/// evaluation cadence; callers should not rely on millisecond-precision expiry.
/// </summary>
public sealed record ShelvingState(ShelvingKind Kind, DateTime? UnshelveAtUtc)
{
public static readonly ShelvingState Unshelved = new(ShelvingKind.Unshelved, null);
}
/// <summary>
/// A single append-only audit record — acknowledgement / confirmation / explicit
/// comment / shelving action. Every entry carries a monotonic UTC timestamp plus the
/// user identity Phase 6.2 authenticated.
/// </summary>
/// <param name="TimestampUtc">When the action happened.</param>
/// <param name="User">OS / LDAP identity of the actor. For engine-internal events (shelving expiry, startup recovery) this is <c>"system"</c>.</param>
/// <param name="Kind">Human-readable classification — "Acknowledge", "Confirm", "ShelveOneShot", "ShelveTimed", "Unshelve", "AddComment", "Enable", "Disable", "AutoUnshelve".</param>
/// <param name="Text">Operator-supplied comment or engine-generated message.</param>
public sealed record AlarmComment(
DateTime TimestampUtc,
string User,
string Kind,
string Text);

View File

@@ -0,0 +1,55 @@
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// <see cref="ScriptContext"/> subclass for alarm predicate evaluation. Reads from
/// the engine's shared tag cache (driver + virtual tags), writes are rejected —
/// predicates must be side-effect free so their output doesn't depend on evaluation
/// order or drive cascade behavior.
/// </summary>
/// <remarks>
/// Per Phase 7 plan Shape A decision, alarm scripts are one-script-per-alarm
/// returning <c>bool</c>. They read any tag they want but should not write
/// anything (the owning alarm's state is tracked by the engine, not the script).
/// </remarks>
public sealed class AlarmPredicateContext : ScriptContext
{
private readonly IReadOnlyDictionary<string, DataValueSnapshot> _readCache;
private readonly Func<DateTime> _clock;
public AlarmPredicateContext(
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
ILogger logger,
Func<DateTime>? clock = null)
{
_readCache = readCache ?? throw new ArgumentNullException(nameof(readCache));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? (() => DateTime.UtcNow);
}
public override DataValueSnapshot GetTag(string path)
{
if (string.IsNullOrWhiteSpace(path))
return new DataValueSnapshot(null, 0x80340000u, null, _clock());
return _readCache.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u, null, _clock());
}
public override void SetVirtualTag(string path, object? value)
{
// Predicates must be pure — writing from an alarm script couples alarm state to
// virtual-tag state in a way that's near-impossible to reason about. Rejected
// at runtime with a clear message; operators see it in the scripts-*.log.
throw new InvalidOperationException(
"Alarm predicate scripts cannot write to virtual tags. Move the write logic " +
"into a virtual tag whose value the alarm predicate then reads.");
}
public override DateTime Now => _clock();
public override ILogger Logger { get; }
}

View File

@@ -0,0 +1,40 @@
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// The concrete OPC UA Part 9 alarm subtype a scripted alarm materializes as. The
/// engine's internal state machine is identical regardless of kind — the
/// <c>AlarmKind</c> only affects how the alarm node appears to OPC UA clients
/// (which ObjectType it maps to) and what diagnostic fields are populated.
/// </summary>
public enum AlarmKind
{
/// <summary>Base AlarmConditionType — no numeric or discrete interpretation.</summary>
AlarmCondition,
/// <summary>LimitAlarmType — the condition reflects a numeric setpoint / threshold breach.</summary>
LimitAlarm,
/// <summary>DiscreteAlarmType — the condition reflects a specific discrete value match.</summary>
DiscreteAlarm,
/// <summary>OffNormalAlarmType — the condition reflects deviation from a configured "normal" state.</summary>
OffNormalAlarm,
}
/// <summary>OPC UA Part 9 EnabledState — operator-controlled alarm enable/disable.</summary>
public enum AlarmEnabledState { Enabled, Disabled }
/// <summary>OPC UA Part 9 ActiveState — reflects the current predicate truth.</summary>
public enum AlarmActiveState { Inactive, Active }
/// <summary>OPC UA Part 9 AckedState — operator has acknowledged the active transition.</summary>
public enum AlarmAckedState { Unacknowledged, Acknowledged }
/// <summary>OPC UA Part 9 ConfirmedState — operator has confirmed the clear transition.</summary>
public enum AlarmConfirmedState { Unconfirmed, Confirmed }
/// <summary>
/// OPC UA Part 9 shelving mode.
/// <see cref="OneShot"/> suppresses the next active transition; once cleared
/// the shelving expires and the alarm returns to normal behavior.
/// <see cref="Timed"/> suppresses until a configured expiry timestamp passes.
/// <see cref="Unshelved"/> is the default state — no suppression.
/// </summary>
public enum ShelvingKind { Unshelved, OneShot, Timed }

View File

@@ -0,0 +1,47 @@
using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Persistence for <see cref="AlarmConditionState"/> across server restarts. Phase 7
/// plan decision #14: operator-supplied state (EnabledState / AckedState /
/// ConfirmedState / ShelvingState + audit trail) persists; ActiveState is
/// recomputed from the live predicate on startup so operators never re-ack.
/// </summary>
/// <remarks>
/// Stream E wires this to a SQL-backed store against the <c>ScriptedAlarmState</c>
/// table with audit logging through <see cref="Core.Abstractions"/> IAuditLogger.
/// Tests + local dev use <see cref="InMemoryAlarmStateStore"/>.
/// </remarks>
public interface IAlarmStateStore
{
Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct);
Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct);
Task SaveAsync(AlarmConditionState state, CancellationToken ct);
Task RemoveAsync(string alarmId, CancellationToken ct);
}
/// <summary>In-memory default — used by tests + by dev deployments without a SQL backend.</summary>
public sealed class InMemoryAlarmStateStore : IAlarmStateStore
{
private readonly ConcurrentDictionary<string, AlarmConditionState> _map
= new(StringComparer.Ordinal);
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
=> Task.FromResult(_map.TryGetValue(alarmId, out var v) ? v : null);
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AlarmConditionState>>(_map.Values.ToArray());
public Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
_map[state.AlarmId] = state;
return Task.CompletedTask;
}
public Task RemoveAsync(string alarmId, CancellationToken ct)
{
_map.TryRemove(alarmId, out _);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,64 @@
using System.Text.RegularExpressions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Per Phase 7 plan decision #13, alarm messages are static-with-substitution
/// templates. The engine resolves <c>{TagPath}</c> tokens at event emission time
/// against current tag values; unresolvable tokens become <c>{?}</c> so the event
/// still fires but the operator sees where the reference broke.
/// </summary>
/// <remarks>
/// <para>
/// Token syntax: <c>{path/with/slashes}</c>. Brace-stripped the contents must
/// match a path the caller's resolver function can look up. No escaping
/// currently — if you need literal braces in the message, reach for a feature
/// request.
/// </para>
/// <para>
/// Pure function. Same inputs always produce the same string. Tests verify the
/// edge cases (no tokens / one token / many / nested / unresolvable / bad
/// quality / null value).
/// </para>
/// </remarks>
public static class MessageTemplate
{
private static readonly Regex TokenRegex = new(@"\{([^{}]+)\}",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Resolve every <c>{path}</c> token in <paramref name="template"/> using
/// <paramref name="resolveTag"/>. Tokens whose returned <see cref="DataValueSnapshot"/>
/// has a non-Good <see cref="DataValueSnapshot.StatusCode"/> or a null
/// <see cref="DataValueSnapshot.Value"/> resolve to <c>{?}</c>.
/// </summary>
public static string Resolve(string template, Func<string, DataValueSnapshot?> resolveTag)
{
if (string.IsNullOrEmpty(template)) return template ?? string.Empty;
if (resolveTag is null) throw new ArgumentNullException(nameof(resolveTag));
return TokenRegex.Replace(template, match =>
{
var path = match.Groups[1].Value.Trim();
if (path.Length == 0) return "{?}";
var snap = resolveTag(path);
if (snap is null) return "{?}";
if (snap.StatusCode != 0u) return "{?}";
return snap.Value?.ToString() ?? "{?}";
});
}
/// <summary>Enumerate the token paths the template references. Used at publish time to validate references exist.</summary>
public static IReadOnlyList<string> ExtractTokenPaths(string? template)
{
if (string.IsNullOrEmpty(template)) return Array.Empty<string>();
var tokens = new List<string>();
foreach (Match m in TokenRegex.Matches(template))
{
var path = m.Groups[1].Value.Trim();
if (path.Length > 0) tokens.Add(path);
}
return tokens;
}
}

View File

@@ -0,0 +1,294 @@
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Pure functions for OPC UA Part 9 alarm-condition state transitions. Input = the
/// current <see cref="AlarmConditionState"/> + the event; output = the new state +
/// optional emission hint. The engine calls these; persistence happens around them.
/// </summary>
/// <remarks>
/// <para>
/// No instance state, no I/O, no mutation of the input record. Every transition
/// returns a fresh record. Makes the state machine trivially unit-testable —
/// tests assert on (input, event) -> (output) without standing anything else up.
/// </para>
/// <para>
/// Two invariants the machine enforces:
/// (1) Disabled alarms never transition ActiveState / AckedState / ConfirmedState
/// — all predicate evaluations while disabled produce a no-op result and a
/// diagnostic log line. Re-enable restores normal flow with ActiveState
/// re-derived from the next predicate evaluation.
/// (2) Shelved alarms (OneShot / Timed) don't fire active transitions to
/// subscribers, but the state record still advances so that when shelving
/// expires the ActiveState reflects current reality. OneShot expires on the
/// next clear; Timed expires at <see cref="ShelvingState.UnshelveAtUtc"/>.
/// </para>
/// </remarks>
public static class Part9StateMachine
{
/// <summary>
/// Apply a predicate re-evaluation result. Handles activation, clearing,
/// branch-stack increment when a new active arrives while prior active is
/// still un-acked, and shelving suppression.
/// </summary>
public static TransitionResult ApplyPredicate(
AlarmConditionState current,
bool predicateTrue,
DateTime nowUtc)
{
if (current.Enabled == AlarmEnabledState.Disabled)
return TransitionResult.NoOp(current, "disabled — predicate result ignored");
// Expire timed shelving if the configured clock has passed.
var shelving = MaybeExpireShelving(current.Shelving, nowUtc);
var stateWithShelving = current with { Shelving = shelving };
// Shelved alarms still update state but skip event emission.
var shelved = shelving.Kind != ShelvingKind.Unshelved;
if (predicateTrue && current.Active == AlarmActiveState.Inactive)
{
// Inactive -> Active transition.
// OneShotShelving is consumed on the NEXT clear, not activation — so we
// still suppress this transition's emission.
var next = stateWithShelving with
{
Active = AlarmActiveState.Active,
Acked = AlarmAckedState.Unacknowledged,
Confirmed = AlarmConfirmedState.Unconfirmed,
LastActiveUtc = nowUtc,
LastTransitionUtc = nowUtc,
};
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Activated);
}
if (!predicateTrue && current.Active == AlarmActiveState.Active)
{
// Active -> Inactive transition.
var next = stateWithShelving with
{
Active = AlarmActiveState.Inactive,
LastClearedUtc = nowUtc,
LastTransitionUtc = nowUtc,
// OneShotShelving expires on clear — resetting here so the next
// activation fires normally.
Shelving = shelving.Kind == ShelvingKind.OneShot
? ShelvingState.Unshelved
: shelving,
};
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Cleared);
}
// Predicate matches current Active — no state change beyond possible shelving
// expiry.
return new TransitionResult(stateWithShelving, EmissionKind.None);
}
/// <summary>Operator acknowledges the currently-active transition.</summary>
public static TransitionResult ApplyAcknowledge(
AlarmConditionState current,
string user,
string? comment,
DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user))
throw new ArgumentException("User identity required for audit.", nameof(user));
if (current.Acked == AlarmAckedState.Acknowledged)
return TransitionResult.NoOp(current, "already acknowledged");
var audit = AppendComment(current.Comments, nowUtc, user, "Acknowledge", comment);
var next = current with
{
Acked = AlarmAckedState.Acknowledged,
LastAckUtc = nowUtc,
LastAckUser = user,
LastAckComment = comment,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Acknowledged);
}
/// <summary>Operator confirms the cleared transition. Part 9 requires confirm after clear for retain-flag alarms.</summary>
public static TransitionResult ApplyConfirm(
AlarmConditionState current,
string user,
string? comment,
DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user))
throw new ArgumentException("User identity required for audit.", nameof(user));
if (current.Confirmed == AlarmConfirmedState.Confirmed)
return TransitionResult.NoOp(current, "already confirmed");
var audit = AppendComment(current.Comments, nowUtc, user, "Confirm", comment);
var next = current with
{
Confirmed = AlarmConfirmedState.Confirmed,
LastConfirmUtc = nowUtc,
LastConfirmUser = user,
LastConfirmComment = comment,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Confirmed);
}
public static TransitionResult ApplyOneShotShelve(
AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Shelving.Kind == ShelvingKind.OneShot)
return TransitionResult.NoOp(current, "already one-shot shelved");
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveOneShot", null);
var next = current with
{
Shelving = new ShelvingState(ShelvingKind.OneShot, null),
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Shelved);
}
public static TransitionResult ApplyTimedShelve(
AlarmConditionState current, string user, DateTime unshelveAtUtc, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (unshelveAtUtc <= nowUtc)
throw new ArgumentOutOfRangeException(nameof(unshelveAtUtc), "Unshelve time must be in the future.");
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveTimed",
$"UnshelveAtUtc={unshelveAtUtc:O}");
var next = current with
{
Shelving = new ShelvingState(ShelvingKind.Timed, unshelveAtUtc),
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Shelved);
}
public static TransitionResult ApplyUnshelve(AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Shelving.Kind == ShelvingKind.Unshelved)
return TransitionResult.NoOp(current, "not shelved");
var audit = AppendComment(current.Comments, nowUtc, user, "Unshelve", null);
var next = current with
{
Shelving = ShelvingState.Unshelved,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Unshelved);
}
public static TransitionResult ApplyEnable(AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Enabled == AlarmEnabledState.Enabled)
return TransitionResult.NoOp(current, "already enabled");
var audit = AppendComment(current.Comments, nowUtc, user, "Enable", null);
var next = current with
{
Enabled = AlarmEnabledState.Enabled,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Enabled);
}
public static TransitionResult ApplyDisable(AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Enabled == AlarmEnabledState.Disabled)
return TransitionResult.NoOp(current, "already disabled");
var audit = AppendComment(current.Comments, nowUtc, user, "Disable", null);
var next = current with
{
Enabled = AlarmEnabledState.Disabled,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Disabled);
}
public static TransitionResult ApplyAddComment(
AlarmConditionState current, string user, string text, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Comment text required.", nameof(text));
var audit = AppendComment(current.Comments, nowUtc, user, "AddComment", text);
var next = current with { Comments = audit };
return new TransitionResult(next, EmissionKind.CommentAdded);
}
/// <summary>
/// Re-evaluate whether a currently timed-shelved alarm has expired. Returns
/// the (possibly unshelved) state + emission hint so the engine knows to
/// publish an Unshelved event at the right moment.
/// </summary>
public static TransitionResult ApplyShelvingCheck(AlarmConditionState current, DateTime nowUtc)
{
if (current.Shelving.Kind != ShelvingKind.Timed) return TransitionResult.None(current);
if (current.Shelving.UnshelveAtUtc is DateTime t && nowUtc >= t)
{
var audit = AppendComment(current.Comments, nowUtc, "system", "AutoUnshelve",
$"Timed shelving expired at {nowUtc:O}");
var next = current with
{
Shelving = ShelvingState.Unshelved,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Unshelved);
}
return TransitionResult.None(current);
}
private static ShelvingState MaybeExpireShelving(ShelvingState s, DateTime nowUtc)
{
if (s.Kind != ShelvingKind.Timed) return s;
return s.UnshelveAtUtc is DateTime t && nowUtc >= t ? ShelvingState.Unshelved : s;
}
private static IReadOnlyList<AlarmComment> AppendComment(
IReadOnlyList<AlarmComment> existing, DateTime ts, string user, string kind, string? text)
{
var list = new List<AlarmComment>(existing.Count + 1);
list.AddRange(existing);
list.Add(new AlarmComment(ts, user, kind, text ?? string.Empty));
return list;
}
}
/// <summary>Result of a state-machine operation — new state + what to emit (if anything).</summary>
public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission)
{
public static TransitionResult None(AlarmConditionState state) => new(state, EmissionKind.None);
public static TransitionResult NoOp(AlarmConditionState state, string reason) => new(state, EmissionKind.None);
}
/// <summary>What kind of event, if any, the engine should emit after a transition.</summary>
public enum EmissionKind
{
/// <summary>State did not change meaningfully — no event to emit.</summary>
None,
/// <summary>Predicate transitioned to true while shelving was suppressing events.</summary>
Suppressed,
Activated,
Cleared,
Acknowledged,
Confirmed,
Shelved,
Unshelved,
Enabled,
Disabled,
CommentAdded,
}

View File

@@ -0,0 +1,50 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Operator-authored scripted-alarm configuration. Phase 7 Stream E (config DB schema)
/// materializes these from the <c>ScriptedAlarm</c> + <c>Script</c> tables on publish.
/// </summary>
/// <param name="AlarmId">
/// Stable identity for the alarm — used as the OPC UA ConditionId + the key in the
/// state store. Should be globally unique within the cluster; convention is
/// <c>{EquipmentPath}::{AlarmName}</c>.
/// </param>
/// <param name="EquipmentPath">
/// UNS path of the Equipment node the alarm hangs under. Alarm browse lives here;
/// ACL binding inherits this equipment's scope per Phase 6.2.
/// </param>
/// <param name="AlarmName">Human-readable alarm name — used in the browse tree + Admin UI.</param>
/// <param name="Kind">Concrete OPC UA Part 9 subtype the alarm materializes as.</param>
/// <param name="Severity">Static severity per Phase 7 plan decision #13; not currently computed by the predicate.</param>
/// <param name="MessageTemplate">
/// Message text with <c>{TagPath}</c> tokens resolved at event-emission time per
/// Phase 7 plan decision #13. Unresolvable tokens emit <c>{?}</c> + a structured
/// error so operators can spot stale references.
/// </param>
/// <param name="PredicateScriptSource">
/// Roslyn C# script returning <c>bool</c>. <c>true</c> = alarm condition currently holds (active);
/// <c>false</c> = condition has cleared. Same sandbox rules as virtual tags per Phase 7 decision #6.
/// </param>
/// <param name="HistorizeToAveva">
/// When true, every transition emission of this alarm flows to the Historian alarm
/// sink (Stream D). Defaults to true — plant alarm history is usually the
/// operator's primary diagnostic. Galaxy-native alarms default false since Galaxy
/// historises them directly.
/// </param>
/// <param name="Retain">
/// Part 9 retain flag — when true, the condition node remains visible after the
/// predicate clears as long as it has un-acknowledged or un-confirmed transitions.
/// Default true.
/// </param>
public sealed record ScriptedAlarmDefinition(
string AlarmId,
string EquipmentPath,
string AlarmName,
AlarmKind Kind,
AlarmSeverity Severity,
string MessageTemplate,
string PredicateScriptSource,
bool HistorizeToAveva = true,
bool Retain = true);

View File

@@ -0,0 +1,429 @@
using System.Collections.Concurrent;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Phase 7 scripted-alarm orchestrator. Compiles every configured alarm's predicate
/// against the Stream A sandbox, subscribes to the referenced upstream tags,
/// re-evaluates the predicate on every input change + on a shelving-check timer,
/// applies the resulting transition through <see cref="Part9StateMachine"/>,
/// persists state via <see cref="IAlarmStateStore"/>, and emits the resulting events
/// through <see cref="ScriptedAlarmSource"/> (which wires into the existing
/// <c>IAlarmSource</c> fan-out).
/// </summary>
/// <remarks>
/// <para>
/// Scripted alarms are leaves in the evaluation DAG — no alarm's state drives
/// another alarm's predicate. The engine maintains only an inverse index from
/// upstream tag path → alarms referencing it; no topological sort needed
/// (unlike the virtual-tag engine).
/// </para>
/// <para>
/// Evaluation errors (script throws, timeout, coercion fail) surface as
/// structured errors in the dedicated scripts-*.log sink plus a WARN companion
/// in the main log. The alarm's ActiveState stays at its prior value — the
/// engine does NOT invent a clear transition just because the predicate broke.
/// Operators investigating a broken predicate shouldn't see a phantom
/// clear-event preceding the failure.
/// </para>
/// </remarks>
public sealed class ScriptedAlarmEngine : IDisposable
{
private readonly ITagUpstreamSource _upstream;
private readonly IAlarmStateStore _store;
private readonly ScriptLoggerFactory _loggerFactory;
private readonly ILogger _engineLogger;
private readonly Func<DateTime> _clock;
private readonly TimeSpan _scriptTimeout;
private readonly Dictionary<string, AlarmState> _alarms = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
= new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _alarmsReferencing
= new(StringComparer.Ordinal); // tag path -> alarm ids
private readonly List<IDisposable> _upstreamSubscriptions = [];
private readonly SemaphoreSlim _evalGate = new(1, 1);
private Timer? _shelvingTimer;
private bool _loaded;
private bool _disposed;
public ScriptedAlarmEngine(
ITagUpstreamSource upstream,
IAlarmStateStore store,
ScriptLoggerFactory loggerFactory,
ILogger engineLogger,
Func<DateTime>? clock = null,
TimeSpan? scriptTimeout = null)
{
_upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
_store = store ?? throw new ArgumentNullException(nameof(store));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
_clock = clock ?? (() => DateTime.UtcNow);
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator<AlarmPredicateContext, bool>.DefaultTimeout;
}
/// <summary>Raised for every emission the Part9StateMachine produces that the engine should publish.</summary>
public event EventHandler<ScriptedAlarmEvent>? OnEvent;
public IReadOnlyCollection<string> LoadedAlarmIds => _alarms.Keys;
/// <summary>
/// Load a batch of alarm definitions. Compiles every predicate, aggregates any
/// compile failures into one <see cref="InvalidOperationException"/>, subscribes
/// to upstream input tags, seeds the value cache, loads persisted state from
/// the store (falling back to Fresh for first-load alarms), and recomputes
/// ActiveState per Phase 7 plan decision #14 (startup recovery).
/// </summary>
public async Task LoadAsync(IReadOnlyList<ScriptedAlarmDefinition> definitions, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
if (definitions is null) throw new ArgumentNullException(nameof(definitions));
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
UnsubscribeFromUpstream();
_alarms.Clear();
_alarmsReferencing.Clear();
var compileFailures = new List<string>();
foreach (var def in definitions)
{
try
{
var extraction = DependencyExtractor.Extract(def.PredicateScriptSource);
if (!extraction.IsValid)
{
var joined = string.Join("; ", extraction.Rejections.Select(r => r.Message));
compileFailures.Add($"{def.AlarmId}: dependency extraction rejected — {joined}");
continue;
}
var evaluator = ScriptEvaluator<AlarmPredicateContext, bool>.Compile(def.PredicateScriptSource);
var timed = new TimedScriptEvaluator<AlarmPredicateContext, bool>(evaluator, _scriptTimeout);
var logger = _loggerFactory.Create(def.AlarmId);
var templateTokens = MessageTemplate.ExtractTokenPaths(def.MessageTemplate);
var allInputs = new HashSet<string>(extraction.Reads, StringComparer.Ordinal);
foreach (var t in templateTokens) allInputs.Add(t);
_alarms[def.AlarmId] = new AlarmState(def, timed, extraction.Reads, templateTokens, logger,
AlarmConditionState.Fresh(def.AlarmId, _clock()));
foreach (var path in allInputs)
{
if (!_alarmsReferencing.TryGetValue(path, out var set))
_alarmsReferencing[path] = set = new HashSet<string>(StringComparer.Ordinal);
set.Add(def.AlarmId);
}
}
catch (Exception ex)
{
compileFailures.Add($"{def.AlarmId}: {ex.Message}");
}
}
if (compileFailures.Count > 0)
{
throw new InvalidOperationException(
$"ScriptedAlarmEngine load failed. {compileFailures.Count} alarm(s) did not compile:\n "
+ string.Join("\n ", compileFailures));
}
// Seed the value cache with current upstream values + subscribe for changes.
foreach (var path in _alarmsReferencing.Keys)
{
_valueCache[path] = _upstream.ReadTag(path);
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
}
// Restore persisted state, falling back to Fresh where nothing was saved,
// then re-derive ActiveState from the current predicate per decision #14.
foreach (var (alarmId, state) in _alarms)
{
var persisted = await _store.LoadAsync(alarmId, ct).ConfigureAwait(false);
var seed = persisted ?? state.Condition;
var afterPredicate = await EvaluatePredicateToStateAsync(state, seed, nowUtc: _clock(), ct)
.ConfigureAwait(false);
_alarms[alarmId] = state with { Condition = afterPredicate };
await _store.SaveAsync(afterPredicate, ct).ConfigureAwait(false);
}
_loaded = true;
_engineLogger.Information("ScriptedAlarmEngine loaded {Count} alarm(s)", _alarms.Count);
// Start the shelving-check timer — ticks every 5s, expires any timed shelves
// that have passed their UnshelveAtUtc.
_shelvingTimer = new Timer(_ => RunShelvingCheck(),
null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
finally
{
_evalGate.Release();
}
}
/// <summary>
/// Current persisted state for <paramref name="alarmId"/>. Returns null for
/// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
/// </summary>
public AlarmConditionState? GetState(string alarmId)
=> _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
=> _alarms.Values.Select(a => a.Condition).ToArray();
public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
public Task EnableAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
public Task DisableAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
private async Task ApplyAsync(string alarmId, CancellationToken ct, Func<AlarmConditionState, TransitionResult> op)
{
EnsureLoaded();
if (!_alarms.TryGetValue(alarmId, out var state))
throw new ArgumentException($"Unknown alarm {alarmId}", nameof(alarmId));
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var result = op(state.Condition);
_alarms[alarmId] = state with { Condition = result.State };
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
if (result.Emission != EmissionKind.None) EmitEvent(state, result.State, result.Emission);
}
finally { _evalGate.Release(); }
}
/// <summary>
/// Upstream-change callback. Updates the value cache + enqueues predicate
/// re-evaluation for every alarm referencing the changed path. Fire-and-forget
/// so driver-side dispatch isn't blocked.
/// </summary>
internal void OnUpstreamChange(string path, DataValueSnapshot value)
{
_valueCache[path] = value;
if (_alarmsReferencing.TryGetValue(path, out var alarmIds))
{
_ = ReevaluateAsync(alarmIds.ToArray(), CancellationToken.None);
}
}
private async Task ReevaluateAsync(IReadOnlyList<string> alarmIds, CancellationToken ct)
{
try
{
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
foreach (var id in alarmIds)
{
if (!_alarms.TryGetValue(id, out var state)) continue;
var newState = await EvaluatePredicateToStateAsync(
state, state.Condition, _clock(), ct).ConfigureAwait(false);
if (!ReferenceEquals(newState, state.Condition))
{
_alarms[id] = state with { Condition = newState };
await _store.SaveAsync(newState, ct).ConfigureAwait(false);
}
}
}
finally { _evalGate.Release(); }
}
catch (Exception ex)
{
_engineLogger.Error(ex, "ScriptedAlarmEngine reevaluate failed");
}
}
/// <summary>
/// Evaluate the predicate + apply the resulting state-machine transition.
/// Returns the new condition state. Emits the appropriate event if the
/// transition produces one.
/// </summary>
private async Task<AlarmConditionState> EvaluatePredicateToStateAsync(
AlarmState state, AlarmConditionState seed, DateTime nowUtc, CancellationToken ct)
{
var inputs = BuildReadCache(state.Inputs);
var context = new AlarmPredicateContext(inputs, state.Logger, _clock);
bool predicateTrue;
try
{
predicateTrue = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (ScriptTimeoutException tex)
{
state.Logger.Warning("Alarm predicate timed out after {Timeout} — state unchanged", tex.Timeout);
return seed;
}
catch (Exception ex)
{
state.Logger.Error(ex, "Alarm predicate threw — state unchanged");
return seed;
}
var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc);
if (result.Emission != EmissionKind.None)
EmitEvent(state, result.State, result.Emission);
return result.State;
}
private IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(IReadOnlySet<string> inputs)
{
var d = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var p in inputs)
d[p] = _valueCache.TryGetValue(p, out var v) ? v : _upstream.ReadTag(p);
return d;
}
private void EmitEvent(AlarmState state, AlarmConditionState condition, EmissionKind kind)
{
// Suppressed kind means shelving ate the emission — we don't fire for subscribers
// but the state record still advanced so startup recovery reflects reality.
if (kind == EmissionKind.Suppressed || kind == EmissionKind.None) return;
var message = MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup);
var evt = new ScriptedAlarmEvent(
AlarmId: state.Definition.AlarmId,
EquipmentPath: state.Definition.EquipmentPath,
AlarmName: state.Definition.AlarmName,
Kind: state.Definition.Kind,
Severity: state.Definition.Severity,
Message: message,
Condition: condition,
Emission: kind,
TimestampUtc: _clock());
try { OnEvent?.Invoke(this, evt); }
catch (Exception ex)
{
_engineLogger.Warning(ex, "ScriptedAlarmEngine OnEvent subscriber threw for {AlarmId}", state.Definition.AlarmId);
}
}
private DataValueSnapshot? TryLookup(string path)
=> _valueCache.TryGetValue(path, out var v) ? v : null;
private void RunShelvingCheck()
{
if (_disposed) return;
var ids = _alarms.Keys.ToArray();
_ = ShelvingCheckAsync(ids, CancellationToken.None);
}
private async Task ShelvingCheckAsync(IReadOnlyList<string> alarmIds, CancellationToken ct)
{
try
{
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var now = _clock();
foreach (var id in alarmIds)
{
if (!_alarms.TryGetValue(id, out var state)) continue;
var result = Part9StateMachine.ApplyShelvingCheck(state.Condition, now);
if (!ReferenceEquals(result.State, state.Condition))
{
_alarms[id] = state with { Condition = result.State };
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
if (result.Emission != EmissionKind.None)
EmitEvent(state, result.State, result.Emission);
}
}
}
finally { _evalGate.Release(); }
}
catch (Exception ex)
{
_engineLogger.Warning(ex, "ScriptedAlarmEngine shelving-check failed");
}
}
private void UnsubscribeFromUpstream()
{
foreach (var s in _upstreamSubscriptions)
{
try { s.Dispose(); } catch { }
}
_upstreamSubscriptions.Clear();
}
private void EnsureLoaded()
{
if (!_loaded) throw new InvalidOperationException(
"ScriptedAlarmEngine not loaded. Call LoadAsync first.");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_shelvingTimer?.Dispose();
UnsubscribeFromUpstream();
_alarms.Clear();
_alarmsReferencing.Clear();
}
private sealed record AlarmState(
ScriptedAlarmDefinition Definition,
TimedScriptEvaluator<AlarmPredicateContext, bool> Evaluator,
IReadOnlySet<string> Inputs,
IReadOnlyList<string> TemplateTokens,
ILogger Logger,
AlarmConditionState Condition);
}
/// <summary>
/// One alarm emission the engine pushed to subscribers. Carries everything
/// downstream consumers (OPC UA alarm-source adapter + historian sink) need to
/// publish the event without re-querying the engine.
/// </summary>
public sealed record ScriptedAlarmEvent(
string AlarmId,
string EquipmentPath,
string AlarmName,
AlarmKind Kind,
AlarmSeverity Severity,
string Message,
AlarmConditionState Condition,
EmissionKind Emission,
DateTime TimestampUtc);
/// <summary>
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
/// engine's so Stream G can compose them behind one driver bridge.
/// </summary>
public interface ITagUpstreamSource
{
DataValueSnapshot ReadTag(string path);
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
}

View File

@@ -0,0 +1,122 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Adapter that exposes <see cref="ScriptedAlarmEngine"/> through the driver-agnostic
/// <see cref="IAlarmSource"/> surface. The existing Phase 6.1 <c>AlarmTracker</c>
/// composition fan-out consumes this alongside Galaxy / AB CIP / FOCAS alarm
/// sources — no per-source branching in the fan-out.
/// </summary>
/// <remarks>
/// <para>
/// Per Phase 7 plan Stream C.6, ack / confirm / shelve / unshelve are OPC UA
/// method calls per-condition. This adapter implements <see cref="AcknowledgeAsync"/>
/// from the base interface; the richer Part 9 methods (Confirm / Shelve /
/// Unshelve / AddComment) live directly on the engine, invoked from OPC UA
/// method handlers wired up in Stream G.
/// </para>
/// <para>
/// SubscribeAlarmsAsync takes a list of source-node-id filters (typically an
/// Equipment path prefix). When the list is empty every alarm matches. The
/// adapter doesn't maintain per-subscription state beyond the filter set — it
/// checks each emission against every live subscription.
/// </para>
/// </remarks>
public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
{
private readonly ScriptedAlarmEngine _engine;
private readonly ConcurrentDictionary<string, Subscription> _subscriptions
= new(StringComparer.Ordinal);
private bool _disposed;
public ScriptedAlarmSource(ScriptedAlarmEngine engine)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_engine.OnEvent += OnEngineEvent;
}
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
if (sourceNodeIds is null) throw new ArgumentNullException(nameof(sourceNodeIds));
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
_subscriptions[handle.DiagnosticId] = new Subscription(handle,
new HashSet<string>(sourceNodeIds, StringComparer.Ordinal));
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
}
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is null) throw new ArgumentNullException(nameof(handle));
_subscriptions.TryRemove(handle.DiagnosticId, out _);
return Task.CompletedTask;
}
public async Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
if (acknowledgements is null) throw new ArgumentNullException(nameof(acknowledgements));
foreach (var a in acknowledgements)
{
// The base interface doesn't carry a user identity — Stream G provides the
// authenticated principal at the OPC UA dispatch layer + proxies through
// the engine's richer AcknowledgeAsync. Here we default to "opcua-client"
// so callers using the raw IAlarmSource still produce an audit entry.
await _engine.AcknowledgeAsync(a.ConditionId, "opcua-client", a.Comment, cancellationToken)
.ConfigureAwait(false);
}
}
private void OnEngineEvent(object? sender, ScriptedAlarmEvent evt)
{
if (_disposed) return;
foreach (var sub in _subscriptions.Values)
{
if (!Matches(sub, evt)) continue;
var payload = new AlarmEventArgs(
SubscriptionHandle: sub.Handle,
SourceNodeId: evt.EquipmentPath,
ConditionId: evt.AlarmId,
AlarmType: evt.Kind.ToString(),
Message: evt.Message,
Severity: evt.Severity,
SourceTimestampUtc: evt.TimestampUtc);
try { OnAlarmEvent?.Invoke(this, payload); }
catch { /* subscriber exceptions don't crash the adapter */ }
}
}
private static bool Matches(Subscription sub, ScriptedAlarmEvent evt)
{
if (sub.Filter.Count == 0) return true;
// A subscription matches if any filter is a prefix of the alarm's equipment
// path — typical use is "Enterprise/Site/Area/Line" filtering a whole line.
foreach (var f in sub.Filter)
{
if (evt.EquipmentPath.Equals(f, StringComparison.Ordinal)) return true;
if (evt.EquipmentPath.StartsWith(f + "/", StringComparison.Ordinal)) return true;
}
return false;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_engine.OnEvent -= OnEngineEvent;
_subscriptions.Clear();
}
private sealed class SubscriptionHandle : IAlarmSubscriptionHandle
{
public SubscriptionHandle(string id) { DiagnosticId = id; }
public string DiagnosticId { get; }
}
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlySet<string> Filter);
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,83 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Source-hash-keyed compile cache for user scripts. Roslyn compilation is the most
/// expensive step in the evaluator pipeline (5-20ms per script depending on size);
/// re-compiling on every value-change event would starve the virtual-tag engine.
/// The cache is generic on the <see cref="ScriptContext"/> subclass + result type so
/// different engines (virtual-tag / alarm-predicate / future alarm-action) each get
/// their own cache instance — there's no cross-type pollution.
/// </summary>
/// <remarks>
/// <para>
/// Concurrent-safe: <see cref="ConcurrentDictionary{TKey, TValue}"/> of
/// <see cref="Lazy{T}"/> means a miss on two threads compiles exactly once.
/// <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> guarantees other
/// threads block on the in-flight compile rather than racing to duplicate work.
/// </para>
/// <para>
/// Cache is keyed on SHA-256 of the UTF-8 bytes of the source — collision-free in
/// practice. Whitespace changes therefore miss the cache on purpose; operators
/// see re-compile time on their first evaluation after a format-only edit which
/// is rare and benign.
/// </para>
/// <para>
/// No capacity bound. Virtual-tag + alarm scripts are operator-authored and
/// bounded by config DB (typically low thousands). If that changes in v3, add an
/// LRU eviction policy — the API stays the same.
/// </para>
/// </remarks>
public sealed class CompiledScriptCache<TContext, TResult>
where TContext : ScriptContext
{
private readonly ConcurrentDictionary<string, Lazy<ScriptEvaluator<TContext, TResult>>> _cache = new();
/// <summary>
/// Return the compiled evaluator for <paramref name="scriptSource"/>, compiling
/// on first sight + reusing thereafter. If the source fails to compile, the
/// original Roslyn / sandbox exception propagates; the cache entry is removed so
/// the next call retries (useful during Admin UI authoring when the operator is
/// still fixing syntax).
/// </summary>
public ScriptEvaluator<TContext, TResult> GetOrCompile(string scriptSource)
{
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
var key = HashSource(scriptSource);
var lazy = _cache.GetOrAdd(key, _ => new Lazy<ScriptEvaluator<TContext, TResult>>(
() => ScriptEvaluator<TContext, TResult>.Compile(scriptSource),
LazyThreadSafetyMode.ExecutionAndPublication));
try
{
return lazy.Value;
}
catch
{
// Failed compile — evict so a retry with corrected source can succeed.
_cache.TryRemove(key, out _);
throw;
}
}
/// <summary>Current entry count. Exposed for Admin UI diagnostics / tests.</summary>
public int Count => _cache.Count;
/// <summary>Drop every cached compile. Used on config generation publish + tests.</summary>
public void Clear() => _cache.Clear();
/// <summary>True when the exact source has been compiled at least once + is still cached.</summary>
public bool Contains(string scriptSource)
=> _cache.ContainsKey(HashSource(scriptSource));
private static string HashSource(string source)
{
var bytes = Encoding.UTF8.GetBytes(source);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash);
}
}

View File

@@ -0,0 +1,137 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Parses a script's source text + extracts every <c>ctx.GetTag("literal")</c> and
/// <c>ctx.SetVirtualTag("literal", ...)</c> call. Outputs the static dependency set
/// the virtual-tag engine uses to build its change-trigger subscription graph (Phase
/// 7 plan decision #7 — AST inference, operator doesn't maintain a separate list).
/// </summary>
/// <remarks>
/// <para>
/// The tag-path argument MUST be a literal string expression. Variables,
/// concatenation, interpolation, and method-returned strings are rejected because
/// the extractor can't statically know what tag they'll resolve to at evaluation
/// time — the dependency graph needs to know every possible input up front.
/// Rejections carry the exact source span so the Admin UI can point at the offending
/// token.
/// </para>
/// <para>
/// Identifier matching is by spelling: the extractor looks for
/// <c>ctx.GetTag(...)</c> / <c>ctx.SetVirtualTag(...)</c> literally. A deliberately
/// misspelled method call (<c>ctx.GetTagz</c>) is not picked up but will also fail
/// to compile against <see cref="ScriptContext"/>, so there's no way to smuggle a
/// dependency past the extractor while still having a working script.
/// </para>
/// </remarks>
public static class DependencyExtractor
{
/// <summary>
/// Parse <paramref name="scriptSource"/> + return the inferred read + write tag
/// paths, or a list of rejection messages if non-literal paths were used.
/// </summary>
public static DependencyExtractionResult Extract(string scriptSource)
{
if (string.IsNullOrWhiteSpace(scriptSource))
return new DependencyExtractionResult(
Reads: new HashSet<string>(StringComparer.Ordinal),
Writes: new HashSet<string>(StringComparer.Ordinal),
Rejections: []);
var tree = CSharpSyntaxTree.ParseText(scriptSource, options:
new CSharpParseOptions(kind: SourceCodeKind.Script));
var root = tree.GetRoot();
var walker = new Walker();
walker.Visit(root);
return new DependencyExtractionResult(
Reads: walker.Reads,
Writes: walker.Writes,
Rejections: walker.Rejections);
}
private sealed class Walker : CSharpSyntaxWalker
{
private readonly HashSet<string> _reads = new(StringComparer.Ordinal);
private readonly HashSet<string> _writes = new(StringComparer.Ordinal);
private readonly List<DependencyRejection> _rejections = [];
public IReadOnlySet<string> Reads => _reads;
public IReadOnlySet<string> Writes => _writes;
public IReadOnlyList<DependencyRejection> Rejections => _rejections;
public override void VisitInvocationExpression(InvocationExpressionSyntax node)
{
// Only interested in member-access form: ctx.GetTag(...) / ctx.SetVirtualTag(...).
// Anything else (free functions, chained calls, static calls) is ignored — but
// still visit children in case a ctx.GetTag call is nested inside.
if (node.Expression is MemberAccessExpressionSyntax member)
{
var methodName = member.Name.Identifier.ValueText;
if (methodName is nameof(ScriptContext.GetTag) or nameof(ScriptContext.SetVirtualTag))
{
HandleTagCall(node, methodName);
}
}
base.VisitInvocationExpression(node);
}
private void HandleTagCall(InvocationExpressionSyntax node, string methodName)
{
var args = node.ArgumentList.Arguments;
if (args.Count == 0)
{
_rejections.Add(new DependencyRejection(
Span: node.Span,
Message: $"Call to ctx.{methodName} has no arguments. " +
"The tag path must be the first argument."));
return;
}
var pathArg = args[0].Expression;
if (pathArg is not LiteralExpressionSyntax literal
|| !literal.Token.IsKind(SyntaxKind.StringLiteralToken))
{
_rejections.Add(new DependencyRejection(
Span: pathArg.Span,
Message: $"Tag path passed to ctx.{methodName} must be a string literal. " +
$"Dynamic paths (variables, concatenation, interpolation, method " +
$"calls) are rejected at publish so the dependency graph can be " +
$"built statically. Got: {pathArg.Kind()} ({pathArg})"));
return;
}
var path = (string?)literal.Token.Value ?? string.Empty;
if (string.IsNullOrWhiteSpace(path))
{
_rejections.Add(new DependencyRejection(
Span: literal.Span,
Message: $"Tag path passed to ctx.{methodName} is empty or whitespace."));
return;
}
if (methodName == nameof(ScriptContext.GetTag))
_reads.Add(path);
else
_writes.Add(path);
}
}
}
/// <summary>Output of <see cref="DependencyExtractor.Extract"/>.</summary>
public sealed record DependencyExtractionResult(
IReadOnlySet<string> Reads,
IReadOnlySet<string> Writes,
IReadOnlyList<DependencyRejection> Rejections)
{
/// <summary>True when no rejections were recorded — safe to publish.</summary>
public bool IsValid => Rejections.Count == 0;
}
/// <summary>A single non-literal-path rejection with the exact source span for UI pointing.</summary>
public sealed record DependencyRejection(TextSpan Span, string Message);

View File

@@ -0,0 +1,152 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Post-compile sandbox guard. <c>ScriptOptions</c> alone can't reliably
/// constrain the type surface a script can reach because .NET 10's type-forwarding
/// system resolves many BCL types through multiple assemblies — restricting the
/// reference list doesn't stop <c>System.Net.Http.HttpClient</c> from being found if
/// any transitive reference forwards to <c>System.Net.Http</c>. This analyzer walks
/// the script's syntax tree after compile, uses the <see cref="SemanticModel"/> to
/// resolve every type / member reference, and rejects any whose containing namespace
/// matches a deny-list pattern.
/// </summary>
/// <remarks>
/// <para>
/// Deny-list is the authoritative Phase 7 plan decision #6 set:
/// <c>System.IO</c>, <c>System.Net</c>, <c>System.Diagnostics.Process</c>,
/// <c>System.Reflection</c>, <c>System.Threading.Thread</c>,
/// <c>System.Runtime.InteropServices</c>. <c>System.Environment</c> (for process
/// env-var read) is explicitly left allowed — it's read-only process state, doesn't
/// persist outside, and the test file pins this compromise so tightening later is
/// a deliberate plan decision.
/// </para>
/// <para>
/// Deny-list prefix match. <c>System.Net</c> catches <c>System.Net.Http</c>,
/// <c>System.Net.Sockets</c>, <c>System.Net.NetworkInformation</c>, etc. — every
/// subnamespace. If a script needs something under a denied prefix, Phase 7's
/// operator audience authors it through a helper the plan team adds as part of
/// the <see cref="ScriptContext"/> surface, not by unlocking the namespace.
/// </para>
/// </remarks>
public static class ForbiddenTypeAnalyzer
{
/// <summary>
/// Namespace prefixes scripts are NOT allowed to reference. Each string is
/// matched as a prefix against the resolved symbol's namespace name (dot-
/// delimited), so <c>System.IO</c> catches <c>System.IO.File</c>,
/// <c>System.IO.Pipes</c>, and any future subnamespace without needing explicit
/// enumeration.
/// </summary>
public static readonly IReadOnlyList<string> ForbiddenNamespacePrefixes =
[
"System.IO",
"System.Net",
"System.Diagnostics", // catches Process, ProcessStartInfo, EventLog, Trace/Debug file sinks
"System.Reflection",
"System.Threading.Thread", // raw Thread — Tasks stay allowed (different namespace)
"System.Runtime.InteropServices",
"Microsoft.Win32", // registry
];
/// <summary>
/// Scan the <paramref name="compilation"/> for references to forbidden types.
/// Returns empty list when the script is clean; non-empty list means the script
/// must be rejected at publish with the rejections surfaced to the operator.
/// </summary>
public static IReadOnlyList<ForbiddenTypeRejection> Analyze(Compilation compilation)
{
if (compilation is null) throw new ArgumentNullException(nameof(compilation));
var rejections = new List<ForbiddenTypeRejection>();
foreach (var tree in compilation.SyntaxTrees)
{
var semantic = compilation.GetSemanticModel(tree);
var root = tree.GetRoot();
foreach (var node in root.DescendantNodes())
{
switch (node)
{
case ObjectCreationExpressionSyntax obj:
CheckSymbol(semantic.GetSymbolInfo(obj.Type).Symbol, obj.Type.Span, rejections);
break;
case InvocationExpressionSyntax inv when inv.Expression is MemberAccessExpressionSyntax memberAcc:
CheckSymbol(semantic.GetSymbolInfo(memberAcc.Expression).Symbol, memberAcc.Expression.Span, rejections);
CheckSymbol(semantic.GetSymbolInfo(inv).Symbol, inv.Span, rejections);
break;
case MemberAccessExpressionSyntax mem:
// Catches static calls like System.IO.File.ReadAllText(...) — the
// MemberAccess "System.IO.File" resolves to the File type symbol
// whose containing namespace is System.IO, triggering a rejection.
CheckSymbol(semantic.GetSymbolInfo(mem.Expression).Symbol, mem.Expression.Span, rejections);
break;
case IdentifierNameSyntax id when node.Parent is not MemberAccessExpressionSyntax:
CheckSymbol(semantic.GetSymbolInfo(id).Symbol, id.Span, rejections);
break;
}
}
}
return rejections;
}
private static void CheckSymbol(ISymbol? symbol, TextSpan span, List<ForbiddenTypeRejection> rejections)
{
if (symbol is null) return;
var typeSymbol = symbol switch
{
ITypeSymbol t => t,
IMethodSymbol m => m.ContainingType,
IPropertySymbol p => p.ContainingType,
IFieldSymbol f => f.ContainingType,
_ => null,
};
if (typeSymbol is null) return;
var ns = typeSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty;
foreach (var forbidden in ForbiddenNamespacePrefixes)
{
if (ns == forbidden || ns.StartsWith(forbidden + ".", StringComparison.Ordinal))
{
rejections.Add(new ForbiddenTypeRejection(
Span: span,
TypeName: typeSymbol.ToDisplayString(),
Namespace: ns,
Message: $"Type '{typeSymbol.ToDisplayString()}' is in the forbidden namespace '{ns}'. " +
$"Scripts cannot reach {forbidden}* per Phase 7 sandbox rules."));
return;
}
}
}
}
/// <summary>A single forbidden-type reference in a user script.</summary>
public sealed record ForbiddenTypeRejection(
TextSpan Span,
string TypeName,
string Namespace,
string Message);
/// <summary>Thrown from <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> when the
/// post-compile forbidden-type analyzer finds references to denied namespaces.</summary>
public sealed class ScriptSandboxViolationException : Exception
{
public IReadOnlyList<ForbiddenTypeRejection> Rejections { get; }
public ScriptSandboxViolationException(IReadOnlyList<ForbiddenTypeRejection> rejections)
: base(BuildMessage(rejections))
{
Rejections = rejections;
}
private static string BuildMessage(IReadOnlyList<ForbiddenTypeRejection> rejections)
{
var lines = rejections.Select(r => $" - {r.Message}");
return "Script references types outside the Phase 7 sandbox allow-list:\n"
+ string.Join("\n", lines);
}
}

View File

@@ -0,0 +1,80 @@
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// The API user scripts see as the global <c>ctx</c>. Abstract — concrete subclasses
/// (e.g. <c>VirtualTagScriptContext</c>, <c>AlarmScriptContext</c>) plug in the
/// actual tag-backend + logger + virtual-tag writer for each evaluation. Phase 7 plan
/// decision #6: scripts can read any tag, write only to virtual tags, and have no
/// other .NET reach — no HttpClient, no File, no Process, no reflection.
/// </summary>
/// <remarks>
/// <para>
/// Every member on this type MUST be serializable in the narrow sense that
/// <see cref="DependencyExtractor"/> can recognize tag-access call sites from the
/// script AST. Method names used from scripts are locked — renaming
/// <see cref="GetTag"/> or <see cref="SetVirtualTag"/> is a breaking change for every
/// authored script and the dependency extractor must update in lockstep.
/// </para>
/// <para>
/// New helpers (<see cref="Now"/>, <see cref="Deadband"/>) are additive: adding a
/// method doesn't invalidate existing scripts. Do not remove or rename without a
/// plan-level decision + migration for authored scripts.
/// </para>
/// </remarks>
public abstract class ScriptContext
{
/// <summary>
/// Read a tag's current value + quality + source timestamp. Path syntax is
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c> (forward-slash delimited,
/// matching the Equipment-namespace browse tree). Returns a
/// <see cref="DataValueSnapshot"/> so scripts branch on quality without a second
/// call.
/// </summary>
/// <remarks>
/// <paramref name="path"/> MUST be a string literal in the script source — dynamic
/// paths (variables, concatenation, method-returned strings) are rejected at
/// publish by <see cref="DependencyExtractor"/>. This is intentional: the static
/// dependency set is required for the change-driven scheduler to subscribe to the
/// right upstream tags at load time.
/// </remarks>
public abstract DataValueSnapshot GetTag(string path);
/// <summary>
/// Write a value to a virtual tag. Operator scripts cannot write to driver-sourced
/// tags — the OPC UA dispatch in <c>DriverNodeManager</c> rejects that separately
/// per ADR-002 with <c>BadUserAccessDenied</c>. This method is the only write path
/// virtual tags have.
/// </summary>
/// <remarks>
/// Path rules identical to <see cref="GetTag"/> — literal only, dependency
/// extractor tracks the write targets so the engine knows what downstream
/// subscribers to notify.
/// </remarks>
public abstract void SetVirtualTag(string path, object? value);
/// <summary>
/// Current UTC timestamp. Prefer this over <see cref="DateTime.UtcNow"/> in
/// scripts so the harness can supply a deterministic clock for tests.
/// </summary>
public abstract DateTime Now { get; }
/// <summary>
/// Per-script Serilog logger. Output lands in the dedicated <c>scripts-*.log</c>
/// sink with structured property <c>ScriptName</c> = the script's configured name.
/// Use at error level to surface problems; main <c>opcua-*.log</c> receives a
/// companion WARN entry so operators see script errors in the primary log.
/// </summary>
public abstract ILogger Logger { get; }
/// <summary>
/// Deadband helper — returns <c>true</c> when <paramref name="current"/> differs
/// from <paramref name="previous"/> by more than <paramref name="tolerance"/>.
/// Useful for alarm predicates that shouldn't flicker on small noise. Pure
/// function; no side effects.
/// </summary>
public static bool Deadband(double current, double previous, double tolerance)
=> Math.Abs(current - previous) > tolerance;
}

View File

@@ -0,0 +1,75 @@
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Compiles + runs user scripts against a <see cref="ScriptContext"/> subclass. Core
/// evaluator — no caching, no timeout, no logging side-effects yet (those land in
/// Stream A.3, A.4, A.5 respectively). Stream B + C wrap this with the dependency
/// scheduler + alarm state machine.
/// </summary>
/// <remarks>
/// <para>
/// Scripts are compiled against <see cref="ScriptGlobals{TContext}"/> so the
/// context member is named <c>ctx</c> in the script, matching the
/// <see cref="DependencyExtractor"/>'s walker and the Admin UI type stub.
/// </para>
/// <para>
/// Compile pipeline is a three-step gate: (1) Roslyn compile — catches syntax
/// errors + type-resolution failures, throws <see cref="CompilationErrorException"/>;
/// (2) <see cref="ForbiddenTypeAnalyzer"/> runs against the semantic model —
/// catches sandbox escapes that slipped past reference restrictions due to .NET's
/// type forwarding, throws <see cref="ScriptSandboxViolationException"/>; (3)
/// delegate creation — throws at this layer only for internal Roslyn bugs, not
/// user error.
/// </para>
/// <para>
/// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag
/// engine (Stream B) catches them per-tag + maps to <c>BadInternalError</c>
/// quality per Phase 7 decision #11 — this layer doesn't swallow anything so
/// tests can assert on the original exception type.
/// </para>
/// </remarks>
public sealed class ScriptEvaluator<TContext, TResult>
where TContext : ScriptContext
{
private readonly ScriptRunner<TResult> _runner;
private ScriptEvaluator(ScriptRunner<TResult> runner)
{
_runner = runner;
}
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
{
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
var options = ScriptSandbox.Build(typeof(TContext));
var script = CSharpScript.Create<TResult>(
code: scriptSource,
options: options,
globalsType: typeof(ScriptGlobals<TContext>));
// Step 1 — Roslyn compile. Throws CompilationErrorException on syntax / type errors.
var diagnostics = script.Compile();
// Step 2 — forbidden-type semantic analysis. Defense-in-depth against reference-list
// leaks due to type forwarding.
var rejections = ForbiddenTypeAnalyzer.Analyze(script.GetCompilation());
if (rejections.Count > 0)
throw new ScriptSandboxViolationException(rejections);
// Step 3 — materialize the callable delegate.
var runner = script.CreateDelegate();
return new ScriptEvaluator<TContext, TResult>(runner);
}
/// <summary>Run against an already-constructed context.</summary>
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
{
if (context is null) throw new ArgumentNullException(nameof(context));
var globals = new ScriptGlobals<TContext> { ctx = context };
return _runner(globals, ct);
}
}

View File

@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Wraps a <see cref="ScriptContext"/> as a named field so user scripts see
/// <c>ctx.GetTag(...)</c> instead of the bare <c>GetTag(...)</c> that Roslyn's
/// globalsType convention would produce. Keeps the script ergonomics operators
/// author against consistent with the dependency extractor (which looks for the
/// <c>ctx.</c> prefix) and with the Admin UI hand-written type stub.
/// </summary>
/// <remarks>
/// Generic on <typeparamref name="TContext"/> so alarm predicates can use a richer
/// context (e.g. with an <c>Alarm</c> property carrying the owning condition's
/// metadata) without affecting virtual-tag contexts.
/// </remarks>
public class ScriptGlobals<TContext>
where TContext : ScriptContext
{
public TContext ctx { get; set; } = default!;
}

View File

@@ -0,0 +1,65 @@
using Serilog;
using Serilog.Core;
using Serilog.Events;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Serilog sink that mirrors script log events at <see cref="LogEventLevel.Error"/>
/// or higher to a companion logger (typically the main <c>opcua-*.log</c>) at
/// <see cref="LogEventLevel.Warning"/>. Lets operators see script errors in the
/// primary server log without drowning it in Debug/Info/Warning noise from scripts.
/// </summary>
/// <remarks>
/// <para>
/// Registered alongside the dedicated <c>scripts-*.log</c> rolling file sink in
/// the root script-logger configuration — events below Error land only in the
/// scripts file; Error/Fatal events land in both the scripts file (at original
/// level) and the main log (downgraded to Warning since the main log's audience
/// is server operators, not script authors).
/// </para>
/// <para>
/// The forwarded message preserves the <c>ScriptName</c> property so operators
/// reading the main log can tell which script raised the error at a glance.
/// Original exception (if any) is attached so the main log's diagnostics keep
/// the full stack trace.
/// </para>
/// </remarks>
public sealed class ScriptLogCompanionSink : ILogEventSink
{
private readonly ILogger _mainLogger;
private readonly LogEventLevel _minMirrorLevel;
public ScriptLogCompanionSink(ILogger mainLogger, LogEventLevel minMirrorLevel = LogEventLevel.Error)
{
_mainLogger = mainLogger ?? throw new ArgumentNullException(nameof(mainLogger));
_minMirrorLevel = minMirrorLevel;
}
public void Emit(LogEvent logEvent)
{
if (logEvent is null) return;
if (logEvent.Level < _minMirrorLevel) return;
var scriptName = "unknown";
if (logEvent.Properties.TryGetValue(ScriptLoggerFactory.ScriptNameProperty, out var prop)
&& prop is ScalarValue sv && sv.Value is string s)
{
scriptName = s;
}
var rendered = logEvent.RenderMessage();
if (logEvent.Exception is not null)
{
_mainLogger.Warning(logEvent.Exception,
"[Script] {ScriptName} emitted {OriginalLevel}: {ScriptMessage}",
scriptName, logEvent.Level, rendered);
}
else
{
_mainLogger.Warning(
"[Script] {ScriptName} emitted {OriginalLevel}: {ScriptMessage}",
scriptName, logEvent.Level, rendered);
}
}
}

View File

@@ -0,0 +1,48 @@
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Creates per-script Serilog <see cref="ILogger"/> instances with the
/// <c>ScriptName</c> structured property pre-bound. Every log call from a user
/// script carries the owning virtual-tag or alarm name so operators can filter the
/// dedicated <c>scripts-*.log</c> sink by script in the Admin UI.
/// </summary>
/// <remarks>
/// <para>
/// Factory-based — the engine (Stream B / C) constructs exactly one instance
/// from the root script-logger pipeline at startup, then derives a per-script
/// logger for each <see cref="ScriptContext"/> it builds. No per-evaluation
/// allocation in the hot path.
/// </para>
/// <para>
/// The wrapped root logger is responsible for output wiring — typically a
/// rolling file sink to <c>scripts-*.log</c> plus a
/// <see cref="ScriptLogCompanionSink"/> that forwards Error-or-higher events
/// to the main server log at Warning level so operators see script errors
/// in the primary log without drowning it in Info noise.
/// </para>
/// </remarks>
public sealed class ScriptLoggerFactory
{
/// <summary>Structured property name the enricher binds. Stable for log filtering.</summary>
public const string ScriptNameProperty = "ScriptName";
private readonly ILogger _rootLogger;
public ScriptLoggerFactory(ILogger rootLogger)
{
_rootLogger = rootLogger ?? throw new ArgumentNullException(nameof(rootLogger));
}
/// <summary>
/// Create a per-script logger. Every event it emits carries
/// <c>ScriptName=<paramref name="scriptName"/></c> as a structured property.
/// </summary>
public ILogger Create(string scriptName)
{
if (string.IsNullOrWhiteSpace(scriptName))
throw new ArgumentException("Script name is required.", nameof(scriptName));
return _rootLogger.ForContext(ScriptNameProperty, scriptName);
}
}

View File

@@ -0,0 +1,87 @@
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Factory for the <see cref="ScriptOptions"/> every user script is compiled against.
/// Implements Phase 7 plan decision #6 (read-only sandbox) by whitelisting only the
/// assemblies + namespaces the script API needs; no <c>System.IO</c>, no
/// <c>System.Net</c>, no <c>System.Diagnostics.Process</c>, no
/// <c>System.Reflection</c>. Attempts to reference those types in a script fail at
/// compile with a compiler error that points at the exact span — the operator sees
/// the rejection before publish, not at evaluation.
/// </summary>
/// <remarks>
/// <para>
/// Roslyn's default <see cref="ScriptOptions"/> references <c>mscorlib</c> /
/// <c>System.Runtime</c> transitively which pulls in every type in the BCL — this
/// class overrides that with an explicit minimal allow-list.
/// </para>
/// <para>
/// Namespaces pre-imported so scripts don't have to write <c>using</c> clauses:
/// <c>System</c>, <c>System.Math</c>-style statics are reachable via
/// <see cref="Math"/>, and <c>ZB.MOM.WW.OtOpcUa.Core.Abstractions</c> so scripts
/// can name <see cref="DataValueSnapshot"/> directly.
/// </para>
/// <para>
/// The sandbox cannot prevent a script from allocating unbounded memory or
/// spinning in a tight loop — those are budget concerns, handled by the
/// per-evaluation timeout (Stream A.4) + the test-harness (Stream F.4) that lets
/// operators preview output before publishing.
/// </para>
/// </remarks>
public static class ScriptSandbox
{
/// <summary>
/// Build the <see cref="ScriptOptions"/> used for every virtual-tag / alarm
/// script. <paramref name="contextType"/> is the concrete
/// <see cref="ScriptContext"/> subclass the globals will be of — the compiler
/// uses its type to resolve <c>ctx.GetTag(...)</c> calls.
/// </summary>
public static ScriptOptions Build(Type contextType)
{
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
if (!typeof(ScriptContext).IsAssignableFrom(contextType))
throw new ArgumentException(
$"Script context type must derive from {nameof(ScriptContext)}", nameof(contextType));
// Allow-listed assemblies — each explicitly chosen. Adding here is a
// plan-level decision; do not expand casually. HashSet so adding the
// contextType's assembly is idempotent when it happens to be Core.Scripting
// already.
var allowedAssemblies = new HashSet<System.Reflection.Assembly>
{
// System.Private.CoreLib — primitives (int, double, bool, string, DateTime,
// TimeSpan, Math, Convert, nullable<T>). Can't practically script without it.
typeof(object).Assembly,
// System.Linq — IEnumerable extensions (Where / Select / Sum / Average / etc.).
typeof(System.Linq.Enumerable).Assembly,
// Core.Abstractions — DataValueSnapshot + DriverDataType so scripts can name
// the types they receive from ctx.GetTag.
typeof(DataValueSnapshot).Assembly,
// Core.Scripting itself — ScriptContext base class + Deadband static.
typeof(ScriptContext).Assembly,
// Serilog.ILogger — script-side logger type.
typeof(Serilog.ILogger).Assembly,
// Concrete context type's assembly — production contexts subclass
// ScriptContext in Core.VirtualTags / Core.ScriptedAlarms; tests use their
// own subclass. The globals wrapper is generic on this type so Roslyn must
// be able to resolve it during compilation.
contextType.Assembly,
};
var allowedImports = new[]
{
"System",
"System.Linq",
"ZB.MOM.WW.OtOpcUa.Core.Abstractions",
"ZB.MOM.WW.OtOpcUa.Core.Scripting",
};
return ScriptOptions.Default
.WithReferences(allowedAssemblies)
.WithImports(allowedImports);
}
}

View File

@@ -0,0 +1,102 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Wraps a <see cref="ScriptEvaluator{TContext, TResult}"/> with a per-evaluation
/// wall-clock timeout. Default is 250ms per Phase 7 plan Stream A.4; configurable
/// per tag so deployments with slower backends can widen it.
/// </summary>
/// <remarks>
/// <para>
/// Implemented with <see cref="Task.WaitAsync(TimeSpan, CancellationToken)"/>
/// rather than a cancellation-token-only approach because Roslyn-compiled
/// scripts don't internally poll the cancellation token unless the user code
/// does async work. A CPU-bound infinite loop in a script won't honor a
/// cooperative cancel — <c>WaitAsync</c> returns control when the timeout fires
/// regardless of whether the inner task completes.
/// </para>
/// <para>
/// <b>Known limitation:</b> when a script times out, the underlying ScriptRunner
/// task continues running on a thread-pool thread until the Roslyn runtime
/// returns. In the CPU-bound-infinite-loop case that's effectively "leaked" —
/// the thread is tied up until the runtime decides to return, which it may
/// never do. Phase 7 plan Stream A.4 accepts this as a known trade-off; tighter
/// CPU budgeting would require an out-of-process script runner, which is a v3
/// concern. In practice, the timeout + structured warning log surfaces the
/// offending script so the operator can fix it; the orphan thread is rare.
/// </para>
/// <para>
/// Caller-supplied <see cref="CancellationToken"/> is honored — if the caller
/// cancels before the timeout fires, the caller's cancel wins and the
/// <see cref="OperationCanceledException"/> propagates (not wrapped as
/// <see cref="ScriptTimeoutException"/>). That distinction matters: the
/// virtual-tag engine's shutdown path cancels scripts on dispose; it shouldn't
/// see those as timeouts.
/// </para>
/// </remarks>
public sealed class TimedScriptEvaluator<TContext, TResult>
where TContext : ScriptContext
{
/// <summary>Default timeout per Phase 7 plan Stream A.4 — 250ms.</summary>
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(250);
private readonly ScriptEvaluator<TContext, TResult> _inner;
/// <summary>Wall-clock budget per evaluation. Script exceeding this throws <see cref="ScriptTimeoutException"/>.</summary>
public TimeSpan Timeout { get; }
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner)
: this(inner, DefaultTimeout)
{
}
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner, TimeSpan timeout)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
if (timeout <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive.");
Timeout = timeout;
}
public async Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
{
if (context is null) throw new ArgumentNullException(nameof(context));
// Push evaluation to a thread-pool thread so a CPU-bound script (e.g. a tight
// loop with no async work) doesn't hog the caller's thread before WaitAsync
// gets to register its timeout. Without this, Roslyn's ScriptRunner executes
// synchronously on the calling thread and returns an already-completed Task,
// so WaitAsync sees a completed task and never fires the timeout.
var runTask = Task.Run(() => _inner.RunAsync(context, ct), ct);
try
{
return await runTask.WaitAsync(Timeout, ct).ConfigureAwait(false);
}
catch (TimeoutException)
{
// WaitAsync's synthesized timeout — the inner task may still be running
// on its thread-pool thread (known leak documented in the class summary).
// Wrap so callers can distinguish from user-written timeout logic.
throw new ScriptTimeoutException(Timeout);
}
}
}
/// <summary>
/// Thrown when a script evaluation exceeds its configured timeout. The virtual-tag
/// engine (Stream B) catches this + maps the owning tag's quality to
/// <c>BadInternalError</c> per Phase 7 plan decision #11, logging a structured
/// warning with the offending script name so operators can locate + fix it.
/// </summary>
public sealed class ScriptTimeoutException : Exception
{
public TimeSpan Timeout { get; }
public ScriptTimeoutException(TimeSpan timeout)
: base($"Script evaluation exceeded the configured timeout of {timeout.TotalMilliseconds:F1} ms. " +
"The script was either CPU-bound or blocked on a slow operation; check ctx.Logger output " +
"around the timeout and consider widening the timeout per tag, simplifying the script, or " +
"moving heavy work out of the evaluation path.")
{
Timeout = timeout;
}
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Scripting</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Roslyn scripting API — compiles user C# snippets with a constrained ScriptOptions
allow-list so scripts can't reach Process/File/HttpClient/reflection. Per Phase 7
plan decisions #1 + #6. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.12.0"/>
<PackageReference Include="Serilog" Version="4.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,271 @@
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Directed dependency graph over tag paths. Nodes are tag paths (either driver
/// tags — leaves — or virtual tags — internal nodes). Edges run from a virtual tag
/// to each tag it reads via <c>ctx.GetTag(...)</c>. Supports cycle detection at
/// publish time and topological sort for evaluation ordering.
/// </summary>
/// <remarks>
/// <para>
/// Cycle detection uses Tarjan's strongly-connected-components algorithm,
/// iterative implementation (no recursion) so deeply-nested graphs can't blow
/// the stack. A cycle of length > 1 (or a self-loop) is a publish-time error;
/// the engine refuses to load such a config.
/// </para>
/// <para>
/// Topological sort uses Kahn's algorithm. The output order guarantees that when
/// tag X depends on tag Y, Y appears before X — so a change cascade starting at
/// Y can evaluate the full downstream closure in one serial pass without needing
/// a second iteration.
/// </para>
/// <para>
/// Missing leaf dependencies (a virtual tag reads a driver tag that doesn't
/// exist in the live config) are NOT rejected here — the graph treats any
/// unregistered path as an implicit leaf. Leaf validity is a separate concern
/// handled at engine-load time against the authoritative tag catalog.
/// </para>
/// </remarks>
public sealed class DependencyGraph
{
private readonly Dictionary<string, HashSet<string>> _dependsOn = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _dependents = new(StringComparer.Ordinal);
/// <summary>
/// Register a node and the set of tags it depends on. Idempotent — re-adding
/// the same node overwrites the prior dependency set, so re-publishing an edited
/// script works without a separate "remove" call.
/// </summary>
public void Add(string nodeId, IReadOnlySet<string> dependsOn)
{
if (string.IsNullOrWhiteSpace(nodeId)) throw new ArgumentException("Node id required.", nameof(nodeId));
if (dependsOn is null) throw new ArgumentNullException(nameof(dependsOn));
// Remove any prior dependents pointing at the previous version of this node.
if (_dependsOn.TryGetValue(nodeId, out var previous))
{
foreach (var dep in previous)
{
if (_dependents.TryGetValue(dep, out var set))
set.Remove(nodeId);
}
}
_dependsOn[nodeId] = new HashSet<string>(dependsOn, StringComparer.Ordinal);
foreach (var dep in dependsOn)
{
if (!_dependents.TryGetValue(dep, out var set))
_dependents[dep] = set = new HashSet<string>(StringComparer.Ordinal);
set.Add(nodeId);
}
}
/// <summary>Tag paths <paramref name="nodeId"/> directly reads.</summary>
public IReadOnlySet<string> DirectDependencies(string nodeId) =>
_dependsOn.TryGetValue(nodeId, out var set) ? set : (IReadOnlySet<string>)new HashSet<string>();
/// <summary>
/// Tags whose evaluation depends on <paramref name="nodeId"/> — i.e. when
/// <paramref name="nodeId"/> changes, these need to re-evaluate. Direct only;
/// transitive propagation falls out of the topological sort.
/// </summary>
public IReadOnlySet<string> DirectDependents(string nodeId) =>
_dependents.TryGetValue(nodeId, out var set) ? set : (IReadOnlySet<string>)new HashSet<string>();
/// <summary>
/// Full transitive dependent closure of <paramref name="nodeId"/> in topological
/// order (direct dependents first, then their dependents, and so on). Used by the
/// change-trigger dispatcher to schedule the right sequence of re-evaluations
/// when a single upstream value changes.
/// </summary>
public IReadOnlyList<string> TransitiveDependentsInOrder(string nodeId)
{
if (string.IsNullOrWhiteSpace(nodeId)) return [];
var result = new List<string>();
var visited = new HashSet<string>(StringComparer.Ordinal);
var order = TopologicalSort();
var rank = new Dictionary<string, int>(StringComparer.Ordinal);
for (var i = 0; i < order.Count; i++) rank[order[i]] = i;
// DFS from the changed node collecting every reachable dependent.
var stack = new Stack<string>();
stack.Push(nodeId);
while (stack.Count > 0)
{
var cur = stack.Pop();
foreach (var dep in DirectDependents(cur))
{
if (visited.Add(dep))
{
result.Add(dep);
stack.Push(dep);
}
}
}
// Sort by topological rank so when re-evaluation runs serial, earlier entries
// are computed before later entries that might depend on them.
result.Sort((a, b) =>
{
var ra = rank.TryGetValue(a, out var va) ? va : int.MaxValue;
var rb = rank.TryGetValue(b, out var vb) ? vb : int.MaxValue;
return ra.CompareTo(rb);
});
return result;
}
/// <summary>Iterable of every registered node id (inputs-only tags excluded).</summary>
public IReadOnlyCollection<string> RegisteredNodes => _dependsOn.Keys;
/// <summary>
/// Produce an evaluation order where every node appears after all its
/// dependencies. Throws <see cref="DependencyCycleException"/> if any cycle
/// exists. Implemented via Kahn's algorithm.
/// </summary>
public IReadOnlyList<string> TopologicalSort()
{
// Kahn's framing: edge u -> v means "u must come before v". For dependencies,
// if X depends on Y, Y must come before X, so the edge runs Y -> X and X has
// an incoming edge from Y. inDegree[X] = count of X's registered (virtual) deps
// — leaf driver-tag deps don't contribute to ordering since they're never emitted.
var inDegree = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var node in _dependsOn.Keys) inDegree[node] = 0;
foreach (var kv in _dependsOn)
{
var nodeId = kv.Key;
foreach (var dep in kv.Value)
{
if (_dependsOn.ContainsKey(dep))
inDegree[nodeId]++;
}
}
var ready = new Queue<string>(inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
var result = new List<string>();
while (ready.Count > 0)
{
var n = ready.Dequeue();
result.Add(n);
// In our edge direction (node -> deps), removing n means decrementing in-degree
// of every node that DEPENDS on n.
foreach (var dependent in DirectDependents(n))
{
if (inDegree.TryGetValue(dependent, out var d))
{
inDegree[dependent] = d - 1;
if (inDegree[dependent] == 0) ready.Enqueue(dependent);
}
}
}
if (result.Count != inDegree.Count)
{
var cycles = DetectCycles();
throw new DependencyCycleException(cycles);
}
return result;
}
/// <summary>
/// Returns every strongly-connected component of size &gt; 1 + every self-loop.
/// Empty list means the graph is a DAG. Useful for surfacing every cycle in one
/// rejection pass so operators see all of them, not just one at a time.
/// </summary>
public IReadOnlyList<IReadOnlyList<string>> DetectCycles()
{
// Iterative Tarjan's SCC. Avoids recursion so deep graphs don't StackOverflow.
var index = 0;
var indexOf = new Dictionary<string, int>(StringComparer.Ordinal);
var lowlinkOf = new Dictionary<string, int>(StringComparer.Ordinal);
var onStack = new HashSet<string>(StringComparer.Ordinal);
var sccStack = new Stack<string>();
var cycles = new List<IReadOnlyList<string>>();
foreach (var root in _dependsOn.Keys)
{
if (indexOf.ContainsKey(root)) continue;
var work = new Stack<(string node, IEnumerator<string> iter)>();
indexOf[root] = index;
lowlinkOf[root] = index;
index++;
onStack.Add(root);
sccStack.Push(root);
work.Push((root, _dependsOn[root].GetEnumerator()));
while (work.Count > 0)
{
var (v, iter) = work.Peek();
if (iter.MoveNext())
{
var w = iter.Current;
if (!_dependsOn.ContainsKey(w))
continue; // leaf — not part of any cycle with us
if (!indexOf.ContainsKey(w))
{
indexOf[w] = index;
lowlinkOf[w] = index;
index++;
onStack.Add(w);
sccStack.Push(w);
work.Push((w, _dependsOn[w].GetEnumerator()));
}
else if (onStack.Contains(w))
{
lowlinkOf[v] = Math.Min(lowlinkOf[v], indexOf[w]);
}
}
else
{
// v fully explored — unwind
work.Pop();
if (lowlinkOf[v] == indexOf[v])
{
var component = new List<string>();
string w;
do
{
w = sccStack.Pop();
onStack.Remove(w);
component.Add(w);
} while (w != v);
if (component.Count > 1 || _dependsOn[v].Contains(v))
cycles.Add(component);
}
else if (work.Count > 0)
{
var parent = work.Peek().node;
lowlinkOf[parent] = Math.Min(lowlinkOf[parent], lowlinkOf[v]);
}
}
}
}
return cycles;
}
public void Clear()
{
_dependsOn.Clear();
_dependents.Clear();
}
}
/// <summary>Thrown when <see cref="DependencyGraph.TopologicalSort"/> finds one or more cycles.</summary>
public sealed class DependencyCycleException : Exception
{
public IReadOnlyList<IReadOnlyList<string>> Cycles { get; }
public DependencyCycleException(IReadOnlyList<IReadOnlyList<string>> cycles)
: base(BuildMessage(cycles))
{
Cycles = cycles;
}
private static string BuildMessage(IReadOnlyList<IReadOnlyList<string>> cycles)
{
var lines = cycles.Select(c => " - " + string.Join(" -> ", c) + " -> " + c[0]);
return "Virtual-tag dependency graph contains cycle(s):\n" + string.Join("\n", lines);
}
}

View File

@@ -0,0 +1,25 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Sink for virtual-tag evaluation results that the operator marked
/// <c>Historize = true</c>. Stream G wires this to the existing history-write path
/// drivers use; tests inject a fake recorder.
/// </summary>
/// <remarks>
/// Emission is fire-and-forget from the evaluation pipeline — a slow historian must
/// not block script evaluations. Implementations queue internally and drain on their
/// own cadence.
/// </remarks>
public interface IHistoryWriter
{
void Record(string path, DataValueSnapshot value);
}
/// <summary>No-op default used when no historian is configured.</summary>
public sealed class NullHistoryWriter : IHistoryWriter
{
public static readonly NullHistoryWriter Instance = new();
public void Record(string path, DataValueSnapshot value) { }
}

View File

@@ -0,0 +1,40 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// What the virtual-tag engine pulls driver-tag values from. Implementations
/// shipped in Stream G bridge this to <see cref="IReadable"/> + <see cref="ISubscribable"/>
/// on the live driver instances; tests use an in-memory fake.
/// </summary>
/// <remarks>
/// <para>
/// The read path is synchronous because user scripts call
/// <c>ctx.GetTag(path)</c> inline — blocking on a driver wire call per-script
/// evaluation would kill throughput. Implementations are expected to serve
/// from a last-known-value cache populated by the subscription callbacks.
/// </para>
/// <para>
/// The subscription path feeds the engine's <c>ChangeTriggerDispatcher</c> so
/// change-driven virtual tags re-evaluate on any upstream delta (value, status,
/// or timestamp). One subscription per distinct upstream tag path; the engine
/// tracks the mapping itself.
/// </para>
/// </remarks>
public interface ITagUpstreamSource
{
/// <summary>
/// Synchronous read returning the last-known value + quality for
/// <paramref name="path"/>. Returns a <c>BadNodeIdUnknown</c>-quality snapshot
/// when the path isn't configured.
/// </summary>
DataValueSnapshot ReadTag(string path);
/// <summary>
/// Register an observer that fires every time the upstream value at
/// <paramref name="path"/> changes. Returns an <see cref="IDisposable"/> the
/// engine disposes when the virtual-tag config is reloaded or the engine shuts
/// down, so source-side subscriptions don't leak.
/// </summary>
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
}

View File

@@ -0,0 +1,83 @@
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Periodic re-evaluation scheduler for tags with a non-null
/// <see cref="VirtualTagDefinition.TimerInterval"/>. Independent of the
/// change-trigger path — a tag can be timer-only, change-only, or both. One
/// <see cref="System.Threading.Timer"/> per interval-group keeps the wire count
/// low regardless of tag count.
/// </summary>
public sealed class TimerTriggerScheduler : IDisposable
{
private readonly VirtualTagEngine _engine;
private readonly ILogger _logger;
private readonly List<Timer> _timers = [];
private readonly CancellationTokenSource _cts = new();
private bool _disposed;
public TimerTriggerScheduler(VirtualTagEngine engine, ILogger logger)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Stand up one <see cref="Timer"/> per unique interval. All tags with
/// matching interval share a timer; each tick triggers re-evaluation of the
/// group in topological order so cascades are consistent with change-triggered
/// behavior.
/// </summary>
public void Start(IReadOnlyList<VirtualTagDefinition> definitions)
{
if (_disposed) throw new ObjectDisposedException(nameof(TimerTriggerScheduler));
var byInterval = definitions
.Where(d => d.TimerInterval.HasValue && d.TimerInterval.Value > TimeSpan.Zero)
.GroupBy(d => d.TimerInterval!.Value);
foreach (var group in byInterval)
{
var paths = group.Select(d => d.Path).ToArray();
var interval = group.Key;
var timer = new Timer(_ => Tick(paths), null, interval, interval);
_timers.Add(timer);
_logger.Information("TimerTriggerScheduler: {TagCount} tag(s) on {Interval} cadence",
paths.Length, interval);
}
}
private void Tick(IReadOnlyList<string> paths)
{
if (_cts.IsCancellationRequested) return;
foreach (var p in paths)
{
try
{
_engine.EvaluateOneAsync(p, _cts.Token).GetAwaiter().GetResult();
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
_logger.Error(ex, "TimerTriggerScheduler evaluate failed for {Path}", p);
}
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cts.Cancel();
foreach (var t in _timers)
{
try { t.Dispose(); } catch { }
}
_timers.Clear();
_cts.Dispose();
}
}

View File

@@ -0,0 +1,64 @@
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Per-evaluation <see cref="ScriptContext"/> for a virtual-tag script. Reads come
/// out of the engine's last-known-value cache (driver tags updated via the
/// <see cref="ITagUpstreamSource"/> subscription, virtual tags updated by prior
/// evaluations). Writes route through the engine's <c>SetVirtualTag</c> callback so
/// cross-tag write side effects still participate in change-trigger cascades.
/// </summary>
/// <remarks>
/// <para>
/// Context instances are evaluation-scoped, not tag-scoped. The engine
/// constructs a fresh context for every run — cheap because the constructor
/// just captures references — so scripts can't cache mutable state across runs
/// via <c>ctx</c>. Mutable state across runs is a future decision (e.g. a
/// dedicated <c>ctx.Memory</c> dictionary); not in scope for Phase 7.
/// </para>
/// <para>
/// The <see cref="Now"/> clock is injectable so tests can pin time
/// deterministically. Production wires to <see cref="DateTime.UtcNow"/>.
/// </para>
/// </remarks>
public sealed class VirtualTagContext : ScriptContext
{
private readonly IReadOnlyDictionary<string, DataValueSnapshot> _readCache;
private readonly Action<string, object?> _setVirtualTag;
private readonly Func<DateTime> _clock;
public VirtualTagContext(
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
Action<string, object?> setVirtualTag,
ILogger logger,
Func<DateTime>? clock = null)
{
_readCache = readCache ?? throw new ArgumentNullException(nameof(readCache));
_setVirtualTag = setVirtualTag ?? throw new ArgumentNullException(nameof(setVirtualTag));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? (() => DateTime.UtcNow);
}
public override DataValueSnapshot GetTag(string path)
{
if (string.IsNullOrWhiteSpace(path))
return new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
return _readCache.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
}
public override void SetVirtualTag(string path, object? value)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Virtual tag path required.", nameof(path));
_setVirtualTag(path, value);
}
public override DateTime Now => _clock();
public override ILogger Logger { get; }
}

View File

@@ -0,0 +1,41 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Operator-authored virtual-tag configuration row. Phase 7 Stream E (config DB
/// schema) materializes these from the <c>VirtualTag</c> + <c>Script</c> tables on
/// publish; the engine ingests a list of them at load time.
/// </summary>
/// <param name="Path">
/// UNS tag path — <c>Enterprise/Site/Area/Line/Equipment/TagName</c>. Used both as
/// the engine's internal id and the OPC UA browse path.
/// </param>
/// <param name="DataType">
/// Expected return type. The evaluator coerces the script's return value to this
/// type before publishing; mismatch surfaces as <c>BadTypeMismatch</c> quality on
/// the tag.
/// </param>
/// <param name="ScriptSource">Roslyn C# script source. Must compile under <c>ScriptSandbox</c>.</param>
/// <param name="ChangeTriggered">
/// True if any input tag's change (value / status / timestamp delta) should trigger
/// re-evaluation. Operator picks per tag — usually true for inputs that change at
/// protocol rates.
/// </param>
/// <param name="TimerInterval">
/// Optional periodic re-evaluation cadence. Null = timer-driven disabled. Both can
/// be enabled simultaneously; independent scheduling paths both feed
/// <c>EvaluationPipeline</c>.
/// </param>
/// <param name="Historize">
/// When true, every evaluation result is forwarded to the configured
/// <see cref="IHistoryWriter"/>. Operator-set per tag; the Admin UI exposes as a
/// checkbox.
/// </param>
public sealed record VirtualTagDefinition(
string Path,
DriverDataType DataType,
string ScriptSource,
bool ChangeTriggered = true,
TimeSpan? TimerInterval = null,
bool Historize = false);

View File

@@ -0,0 +1,385 @@
using System.Collections.Concurrent;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// The Phase 7 virtual-tag evaluation engine. Ingests a set of
/// <see cref="VirtualTagDefinition"/>s at load time, compiles each script against
/// <see cref="ScriptSandbox"/>, builds the dependency graph, subscribes to every
/// referenced upstream tag, and schedules re-evaluations on change + on timer.
/// </summary>
/// <remarks>
/// <para>
/// Evaluation order is topological per ADR-001 / Phase 7 plan decision #19 —
/// serial for the v1 rollout, parallel promoted to a follow-up. When upstream
/// tag X changes, the engine computes the transitive dependent closure of X in
/// topological rank and evaluates each in turn, so a cascade through multiple
/// levels of virtual tags settles within one change-trigger pass.
/// </para>
/// <para>
/// Per-tag error isolation per Phase 7 plan decision #11 — a script exception
/// (or timeout) fails that tag's latest value with <c>BadInternalError</c> or
/// <c>BadTypeMismatch</c> quality and logs a structured error; every other tag
/// keeps evaluating. The engine itself never faults from a user script.
/// </para>
/// </remarks>
public sealed class VirtualTagEngine : IDisposable
{
private readonly ITagUpstreamSource _upstream;
private readonly IHistoryWriter _history;
private readonly ScriptLoggerFactory _loggerFactory;
private readonly ILogger _engineLogger;
private readonly Func<DateTime> _clock;
private readonly TimeSpan _scriptTimeout;
private readonly DependencyGraph _graph = new();
private readonly Dictionary<string, VirtualTagState> _tags = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _observers
= new(StringComparer.Ordinal);
private readonly List<IDisposable> _upstreamSubscriptions = [];
private readonly SemaphoreSlim _evalGate = new(1, 1);
private bool _loaded;
private bool _disposed;
public VirtualTagEngine(
ITagUpstreamSource upstream,
ScriptLoggerFactory loggerFactory,
ILogger engineLogger,
IHistoryWriter? historyWriter = null,
Func<DateTime>? clock = null,
TimeSpan? scriptTimeout = null)
{
_upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
_history = historyWriter ?? NullHistoryWriter.Instance;
_clock = clock ?? (() => DateTime.UtcNow);
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator<VirtualTagContext, object?>.DefaultTimeout;
}
/// <summary>Registered tag paths, in topological order. Empty before <see cref="Load"/>.</summary>
public IReadOnlyCollection<string> LoadedTagPaths => _tags.Keys;
/// <summary>Compile + register every tag in <paramref name="definitions"/>. Throws on cycle or any compile failure.</summary>
public void Load(IReadOnlyList<VirtualTagDefinition> definitions)
{
if (_disposed) throw new ObjectDisposedException(nameof(VirtualTagEngine));
if (definitions is null) throw new ArgumentNullException(nameof(definitions));
// Start from a clean slate — supports config-publish reloads.
UnsubscribeFromUpstream();
_tags.Clear();
_graph.Clear();
var compileFailures = new List<string>();
foreach (var def in definitions)
{
try
{
var extraction = DependencyExtractor.Extract(def.ScriptSource);
if (!extraction.IsValid)
{
var msgs = string.Join("; ", extraction.Rejections.Select(r => r.Message));
compileFailures.Add($"{def.Path}: dependency extraction rejected — {msgs}");
continue;
}
var evaluator = ScriptEvaluator<VirtualTagContext, object?>.Compile(def.ScriptSource);
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _scriptTimeout);
var scriptLogger = _loggerFactory.Create(def.Path);
_tags[def.Path] = new VirtualTagState(def, timed, extraction.Reads, extraction.Writes, scriptLogger);
_graph.Add(def.Path, extraction.Reads);
}
catch (Exception ex)
{
compileFailures.Add($"{def.Path}: {ex.Message}");
}
}
if (compileFailures.Count > 0)
{
var joined = string.Join("\n ", compileFailures);
throw new InvalidOperationException(
$"Virtual-tag engine load failed. {compileFailures.Count} script(s) did not compile:\n {joined}");
}
// Cycle check — throws DependencyCycleException on offense.
_ = _graph.TopologicalSort();
// Subscribe to every referenced upstream path (driver tags only — virtual tags
// cascade internally). Seed the cache with current upstream values so first
// evaluations see something real.
var upstreamPaths = definitions
.SelectMany(d => _tags[d.Path].Reads)
.Where(p => !_tags.ContainsKey(p))
.Distinct(StringComparer.Ordinal);
foreach (var path in upstreamPaths)
{
_valueCache[path] = _upstream.ReadTag(path);
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
}
_loaded = true;
_engineLogger.Information(
"VirtualTagEngine loaded {TagCount} tag(s), {UpstreamCount} upstream subscription(s)",
_tags.Count, _upstreamSubscriptions.Count);
}
/// <summary>
/// Evaluate every registered tag once in topological order — used at startup so
/// virtual tags have a defined initial value rather than inheriting the cache
/// default. Also called after a config reload.
/// </summary>
public async Task EvaluateAllAsync(CancellationToken ct = default)
{
EnsureLoaded();
var order = _graph.TopologicalSort();
foreach (var path in order)
{
if (_tags.ContainsKey(path))
await EvaluateOneAsync(path, ct).ConfigureAwait(false);
}
}
/// <summary>Evaluate a single tag — used by the timer trigger + test hooks.</summary>
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
{
EnsureLoaded();
if (!_tags.ContainsKey(path))
throw new ArgumentException($"Not a registered virtual tag: {path}", nameof(path));
return EvaluateInternalAsync(path, ct);
}
/// <summary>
/// Read the most recently evaluated value for <paramref name="path"/>. Driver
/// tags return the last-known upstream value; virtual tags return their last
/// evaluation result.
/// </summary>
public DataValueSnapshot Read(string path)
{
if (string.IsNullOrWhiteSpace(path))
return new DataValueSnapshot(null, 0x80340000u, null, _clock());
return _valueCache.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
}
/// <summary>
/// Register an observer that fires on every evaluation of the given tag.
/// Returns an <see cref="IDisposable"/> to unsubscribe. Does NOT fire a seed
/// value — subscribers call <see cref="Read"/> for the current value if needed.
/// </summary>
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
{
var list = _observers.GetOrAdd(path, _ => []);
lock (list) { list.Add(observer); }
return new Unsub(this, path, observer);
}
/// <summary>
/// Change-trigger entry point — called by the upstream subscription callback.
/// Updates the cache, fans out to observers (so OPC UA clients see the upstream
/// change too if they subscribed via the engine), and schedules every
/// change-triggered dependent for re-evaluation in topological order.
/// </summary>
internal void OnUpstreamChange(string path, DataValueSnapshot value)
{
_valueCache[path] = value;
NotifyObservers(path, value);
// Fire-and-forget — the upstream subscription callback must not block the
// driver's dispatcher. Exceptions during cascade are handled per-tag inside
// EvaluateInternalAsync.
_ = CascadeAsync(path, CancellationToken.None);
}
private async Task CascadeAsync(string upstreamPath, CancellationToken ct)
{
try
{
var dependents = _graph.TransitiveDependentsInOrder(upstreamPath);
foreach (var dep in dependents)
{
if (_tags.TryGetValue(dep, out var state) && state.Definition.ChangeTriggered)
await EvaluateInternalAsync(dep, ct).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_engineLogger.Error(ex, "VirtualTagEngine cascade failed for upstream {Path}", upstreamPath);
}
}
private async Task EvaluateInternalAsync(string path, CancellationToken ct)
{
if (!_tags.TryGetValue(path, out var state)) return;
// Serial evaluation across all tags. Phase 7 plan decision #19 — parallel is a
// follow-up. The semaphore bounds the evaluation graph so two cascades don't
// interleave, which would break the "earlier nodes computed first" invariant.
// SemaphoreSlim.WaitAsync is async-safe where Monitor.Enter is not (Monitor
// ownership is thread-local and lost across await).
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var ctxCache = BuildReadCache(state.Reads);
var context = new VirtualTagContext(
ctxCache,
(p, v) => OnScriptSetVirtualTag(p, v),
state.Logger,
_clock);
DataValueSnapshot result;
try
{
var raw = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false);
var coerced = CoerceResult(raw, state.Definition.DataType);
result = new DataValueSnapshot(coerced, 0u, _clock(), _clock());
}
catch (ScriptTimeoutException tex)
{
state.Logger.Warning("Script timed out after {Timeout}", tex.Timeout);
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
}
catch (OperationCanceledException)
{
throw; // shutdown path — don't misclassify
}
catch (Exception ex)
{
state.Logger.Error(ex, "Virtual-tag script threw");
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
}
_valueCache[path] = result;
NotifyObservers(path, result);
if (state.Definition.Historize) _history.Record(path, result);
}
finally
{
_evalGate.Release();
}
}
private IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(IReadOnlySet<string> reads)
{
var map = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var r in reads)
{
map[r] = _valueCache.TryGetValue(r, out var v)
? v
: _upstream.ReadTag(r);
}
return map;
}
private void OnScriptSetVirtualTag(string path, object? value)
{
if (!_tags.ContainsKey(path))
{
_engineLogger.Warning(
"Script attempted ctx.SetVirtualTag on non-virtual or non-registered path {Path}", path);
return;
}
var snap = new DataValueSnapshot(value, 0u, _clock(), _clock());
_valueCache[path] = snap;
NotifyObservers(path, snap);
if (_tags[path].Definition.Historize) _history.Record(path, snap);
}
private void NotifyObservers(string path, DataValueSnapshot value)
{
if (!_observers.TryGetValue(path, out var list)) return;
Action<string, DataValueSnapshot>[] snapshot;
lock (list) { snapshot = list.ToArray(); }
foreach (var obs in snapshot)
{
try { obs(path, value); }
catch (Exception ex)
{
_engineLogger.Warning(ex, "Virtual-tag observer for {Path} threw", path);
}
}
}
private static object? CoerceResult(object? raw, DriverDataType target)
{
if (raw is null) return null;
try
{
return target switch
{
DriverDataType.Boolean => Convert.ToBoolean(raw),
DriverDataType.Int32 => Convert.ToInt32(raw),
DriverDataType.Int64 => Convert.ToInt64(raw),
DriverDataType.Float32 => Convert.ToSingle(raw),
DriverDataType.Float64 => Convert.ToDouble(raw),
DriverDataType.String => Convert.ToString(raw) ?? string.Empty,
DriverDataType.DateTime => raw is DateTime dt ? dt : Convert.ToDateTime(raw),
_ => raw,
};
}
catch
{
// Caller logs + maps to BadTypeMismatch — we let null propagate so the
// outer evaluation path sets the Bad quality.
return null;
}
}
private void UnsubscribeFromUpstream()
{
foreach (var s in _upstreamSubscriptions)
{
try { s.Dispose(); } catch { /* best effort */ }
}
_upstreamSubscriptions.Clear();
}
private void EnsureLoaded()
{
if (!_loaded) throw new InvalidOperationException(
"VirtualTagEngine not loaded. Call Load(definitions) first.");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
UnsubscribeFromUpstream();
_tags.Clear();
_graph.Clear();
}
internal DependencyGraph GraphForTesting => _graph;
private sealed class Unsub : IDisposable
{
private readonly VirtualTagEngine _engine;
private readonly string _path;
private readonly Action<string, DataValueSnapshot> _observer;
public Unsub(VirtualTagEngine e, string path, Action<string, DataValueSnapshot> observer)
{
_engine = e; _path = path; _observer = observer;
}
public void Dispose()
{
if (_engine._observers.TryGetValue(_path, out var list))
{
lock (list) { list.Remove(_observer); }
}
}
}
internal sealed record VirtualTagState(
VirtualTagDefinition Definition,
TimedScriptEvaluator<VirtualTagContext, object?> Evaluator,
IReadOnlySet<string> Reads,
IReadOnlySet<string> Writes,
ILogger Logger);
}

View File

@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Implements the driver-agnostic capability surface the
/// <c>DriverNodeManager</c> dispatches to when a node resolves to
/// <c>NodeSource.Virtual</c> per ADR-002. Reads return the engine's last-known
/// evaluation result; subscriptions forward engine-emitted change events as
/// <see cref="ISubscribable.OnDataChange"/> events.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="IWritable"/> is deliberately not implemented — OPC UA client
/// writes to virtual tags are rejected in <c>DriverNodeManager</c> before they
/// reach here per Phase 7 decision #6. Scripts are the only write path, routed
/// through <c>ctx.SetVirtualTag</c>.
/// </para>
/// </remarks>
public sealed class VirtualTagSource : IReadable, ISubscribable
{
private readonly VirtualTagEngine _engine;
private readonly ConcurrentDictionary<string, Subscription> _subs = new(StringComparer.Ordinal);
public VirtualTagSource(VirtualTagEngine engine)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
}
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
results[i] = _engine.Read(fullReferences[i]);
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
}
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences,
TimeSpan publishingInterval,
CancellationToken cancellationToken)
{
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
var observers = new List<IDisposable>(fullReferences.Count);
foreach (var path in fullReferences)
{
observers.Add(_engine.Subscribe(path, (p, snap) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, p, snap))));
}
_subs[handle.DiagnosticId] = new Subscription(handle, observers);
// OPC UA convention: emit initial-data callback for each path with the current value.
foreach (var path in fullReferences)
{
var snap = _engine.Read(path);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, path, snap));
}
return Task.FromResult<ISubscriptionHandle>(handle);
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is null) throw new ArgumentNullException(nameof(handle));
if (_subs.TryRemove(handle.DiagnosticId, out var sub))
{
foreach (var d in sub.Observers)
{
try { d.Dispose(); } catch { }
}
}
return Task.CompletedTask;
}
private sealed class SubscriptionHandle : ISubscriptionHandle
{
public SubscriptionHandle(string id) { DiagnosticId = id; }
public string DiagnosticId { get; }
}
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlyList<IDisposable> Observers);
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.VirtualTags</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -87,6 +87,16 @@ public static class EquipmentNodeWalker
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var virtualTagsByEquipment = (content.VirtualTags ?? [])
.Where(v => v.Enabled)
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? [])
.Where(a => a.Enabled)
.GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
{
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
@@ -103,9 +113,17 @@ public static class EquipmentNodeWalker
AddIdentifierProperties(equipmentBuilder, equipment);
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
foreach (var tag in equipmentTags)
AddTagVariable(equipmentBuilder, tag);
if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
foreach (var tag in equipmentTags)
AddTagVariable(equipmentBuilder, tag);
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
foreach (var vtag in vTags)
AddVirtualTagVariable(equipmentBuilder, vtag);
if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms))
foreach (var alarm in alarms)
AddScriptedAlarmVariable(equipmentBuilder, alarm);
}
}
}
@@ -157,6 +175,55 @@ public static class EquipmentNodeWalker
/// </summary>
private static DriverDataType ParseDriverDataType(string raw) =>
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
/// <summary>
/// Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
/// variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
/// addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
/// driver.
/// </summary>
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
{
var attr = new DriverAttributeInfo(
FullName: vtag.VirtualTagId,
DriverDataType: ParseDriverDataType(vtag.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: vtag.Historize,
IsAlarm: false,
WriteIdempotent: false,
Source: NodeSourceKind.Virtual,
VirtualTagId: vtag.VirtualTagId,
ScriptedAlarmId: null);
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
}
/// <summary>
/// Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
/// node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
/// per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
/// anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
/// materialization path.
/// </summary>
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
{
var attr = new DriverAttributeInfo(
FullName: alarm.ScriptedAlarmId,
DriverDataType: DriverDataType.Boolean,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: false,
IsAlarm: true,
WriteIdempotent: false,
Source: NodeSourceKind.ScriptedAlarm,
VirtualTagId: null,
ScriptedAlarmId: alarm.ScriptedAlarmId);
equipmentBuilder.Variable(alarm.Name, alarm.Name, attr);
}
}
/// <summary>
@@ -170,4 +237,6 @@ public sealed record EquipmentNamespaceContent(
IReadOnlyList<UnsArea> Areas,
IReadOnlyList<UnsLine> Lines,
IReadOnlyList<Equipment> Equipment,
IReadOnlyList<Tag> Tags);
IReadOnlyList<Tag> Tags,
IReadOnlyList<VirtualTag>? VirtualTags = null,
IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);

View File

@@ -0,0 +1,122 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
/// <summary>
/// In-memory <see cref="IFocasBackend"/> for tests + an operational stub mode when
/// <c>OTOPCUA_FOCAS_BACKEND=fake</c>. Keeps per-address values keyed by a canonical
/// string; RMW semantics honor PMC bit-writes against the containing byte so the
/// <c>PmcBitWriteRequest</c> path can be exercised end-to-end without hardware.
/// </summary>
public sealed class FakeFocasBackend : IFocasBackend
{
private readonly object _gate = new();
private long _nextSessionId;
private readonly HashSet<long> _openSessions = [];
private readonly Dictionary<string, byte[]> _pmcValues = [];
private readonly Dictionary<string, byte[]> _paramValues = [];
private readonly Dictionary<string, byte[]> _macroValues = [];
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest request, CancellationToken ct)
{
lock (_gate)
{
var id = ++_nextSessionId;
_openSessions.Add(id);
return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id });
}
}
public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct)
{
lock (_gate) { _openSessions.Remove(request.SessionId); }
return Task.CompletedTask;
}
public Task<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct)
{
lock (_gate)
{
if (!_openSessions.Contains(request.SessionId))
return Task.FromResult(new ReadResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
var store = StoreFor(request.Address.Kind);
var key = CanonicalKey(request.Address);
store.TryGetValue(key, out var value);
return Task.FromResult(new ReadResponse
{
Success = true,
StatusCode = 0,
ValueBytes = value ?? MessagePackSerializer.Serialize((int)0),
ValueTypeCode = request.DataType,
SourceTimestampUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
}
}
public Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct)
{
lock (_gate)
{
if (!_openSessions.Contains(request.SessionId))
return Task.FromResult(new WriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
var store = StoreFor(request.Address.Kind);
store[CanonicalKey(request.Address)] = request.ValueBytes ?? [];
return Task.FromResult(new WriteResponse { Success = true, StatusCode = 0 });
}
}
public Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct)
{
lock (_gate)
{
if (!_openSessions.Contains(request.SessionId))
return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
if (request.BitIndex is < 0 or > 7)
return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x803C0000u, Error = "bit-out-of-range" });
var key = CanonicalKey(request.Address);
_pmcValues.TryGetValue(key, out var current);
current ??= MessagePackSerializer.Serialize((byte)0);
var b = MessagePackSerializer.Deserialize<byte>(current);
var mask = (byte)(1 << request.BitIndex);
b = request.Value ? (byte)(b | mask) : (byte)(b & ~mask);
_pmcValues[key] = MessagePackSerializer.Serialize(b);
return Task.FromResult(new PmcBitWriteResponse { Success = true, StatusCode = 0 });
}
}
public Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct)
{
lock (_gate)
{
return Task.FromResult(new ProbeResponse
{
Healthy = _openSessions.Contains(request.SessionId),
ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
}
}
private Dictionary<string, byte[]> StoreFor(int kind) => kind switch
{
0 => _pmcValues,
1 => _paramValues,
2 => _macroValues,
_ => _pmcValues,
};
private static string CanonicalKey(FocasAddressDto addr) =>
addr.Kind switch
{
0 => $"{addr.PmcLetter}{addr.Number}",
1 => $"P{addr.Number}",
2 => $"M{addr.Number}",
_ => $"?{addr.Number}",
};
}

View File

@@ -0,0 +1,24 @@
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
/// <summary>
/// The Host's view of a FOCAS session. One implementation wraps the real
/// <c>Fwlib32.dll</c> via P/Invoke (lands with the real Fwlib32 integration follow-up,
/// since no hardware is available today); a second implementation —
/// <see cref="FakeFocasBackend"/> — is used by tests.
/// Both live on .NET 4.8 x86 so the Host can be deployed in either mode without
/// changing the pipe server.
/// Invoked via <c>FwlibFrameHandler</c> in the Ipc namespace.
/// </summary>
public interface IFocasBackend
{
Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest request, CancellationToken ct);
Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct);
Task<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct);
Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct);
Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct);
Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct);
}

View File

@@ -0,0 +1,37 @@
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
/// <summary>
/// Safe default when the deployment hasn't configured a real Fwlib32 backend.
/// Returns structured failure responses instead of throwing so the Proxy can map the
/// error to <c>BadDeviceFailure</c> and surface a clear operator message pointing at
/// <c>docs/v2/focas-deployment.md</c>. Used when <c>OTOPCUA_FOCAS_BACKEND</c> is unset
/// or set to <c>unconfigured</c>.
/// </summary>
public sealed class UnconfiguredFocasBackend : IFocasBackend
{
private const uint BadDeviceFailure = 0x80550000u;
private const string Reason =
"FOCAS Host is running without a real Fwlib32 backend. Set OTOPCUA_FOCAS_BACKEND=fwlib32 " +
"and ensure Fwlib32.dll is on PATH — see docs/v2/focas-deployment.md.";
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest request, CancellationToken ct) =>
Task.FromResult(new OpenSessionResponse { Success = false, Error = Reason, ErrorCode = "NoFwlibBackend" });
public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct) => Task.CompletedTask;
public Task<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct) =>
Task.FromResult(new ReadResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
public Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct) =>
Task.FromResult(new WriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
public Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct) =>
Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
public Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct) =>
Task.FromResult(new ProbeResponse { Healthy = false, Error = Reason, ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
}

View File

@@ -0,0 +1,111 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
/// <summary>
/// Real FOCAS frame handler. Deserializes each request DTO, delegates to
/// <see cref="IFocasBackend"/>, re-serializes the response. The backend owns the
/// Fwlib32 handle + STA thread — the handler is pure dispatch.
/// </summary>
public sealed class FwlibFrameHandler : IFrameHandler
{
private readonly IFocasBackend _backend;
private readonly ILogger _logger;
public FwlibFrameHandler(IFocasBackend backend, ILogger logger)
{
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
{
try
{
switch (kind)
{
case FocasMessageKind.Heartbeat:
{
var hb = MessagePackSerializer.Deserialize<Heartbeat>(body);
await writer.WriteAsync(FocasMessageKind.HeartbeatAck,
new HeartbeatAck
{
MonotonicTicks = hb.MonotonicTicks,
HostUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
}, ct).ConfigureAwait(false);
return;
}
case FocasMessageKind.OpenSessionRequest:
{
var req = MessagePackSerializer.Deserialize<OpenSessionRequest>(body);
var resp = await _backend.OpenSessionAsync(req, ct).ConfigureAwait(false);
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, resp, ct).ConfigureAwait(false);
return;
}
case FocasMessageKind.CloseSessionRequest:
{
var req = MessagePackSerializer.Deserialize<CloseSessionRequest>(body);
await _backend.CloseSessionAsync(req, ct).ConfigureAwait(false);
return;
}
case FocasMessageKind.ReadRequest:
{
var req = MessagePackSerializer.Deserialize<ReadRequest>(body);
var resp = await _backend.ReadAsync(req, ct).ConfigureAwait(false);
await writer.WriteAsync(FocasMessageKind.ReadResponse, resp, ct).ConfigureAwait(false);
return;
}
case FocasMessageKind.WriteRequest:
{
var req = MessagePackSerializer.Deserialize<WriteRequest>(body);
var resp = await _backend.WriteAsync(req, ct).ConfigureAwait(false);
await writer.WriteAsync(FocasMessageKind.WriteResponse, resp, ct).ConfigureAwait(false);
return;
}
case FocasMessageKind.PmcBitWriteRequest:
{
var req = MessagePackSerializer.Deserialize<PmcBitWriteRequest>(body);
var resp = await _backend.PmcBitWriteAsync(req, ct).ConfigureAwait(false);
await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse, resp, ct).ConfigureAwait(false);
return;
}
case FocasMessageKind.ProbeRequest:
{
var req = MessagePackSerializer.Deserialize<ProbeRequest>(body);
var resp = await _backend.ProbeAsync(req, ct).ConfigureAwait(false);
await writer.WriteAsync(FocasMessageKind.ProbeResponse, resp, ct).ConfigureAwait(false);
return;
}
default:
await writer.WriteAsync(FocasMessageKind.ErrorResponse,
new ErrorResponse { Code = "unknown-kind", Message = $"Kind {kind} is not handled by the Host" },
ct).ConfigureAwait(false);
return;
}
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.Error(ex, "FwlibFrameHandler error processing {Kind}", kind);
await writer.WriteAsync(FocasMessageKind.ErrorResponse,
new ErrorResponse { Code = "backend-exception", Message = ex.Message },
ct).ConfigureAwait(false);
}
}
public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance;
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
/// <summary>
/// Dispatches a single IPC frame to the backend. Implementations own the FOCAS session
/// state and translate request DTOs into Fwlib32 calls.
/// </summary>
public interface IFrameHandler
{
Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
/// <summary>
/// Called once per accepted connection after the Hello handshake. Lets the handler
/// attach server-pushed event sinks (data-change notifications, runtime-status
/// changes) to the connection's <paramref name="writer"/>. Returns an
/// <see cref="IDisposable"/> the pipe server disposes when the connection closes —
/// backends use it to unsubscribe from their push sources.
/// </summary>
IDisposable AttachConnection(FrameWriter writer);
public sealed class NoopAttachment : IDisposable
{
public static readonly NoopAttachment Instance = new();
public void Dispose() { }
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
/// <summary>
/// Builds the <see cref="PipeSecurity"/> for the FOCAS Host pipe. Same pattern as
/// Galaxy.Host: only the configured OtOpcUa server principal SID gets
/// <c>ReadWrite | Synchronize</c>; LocalSystem + Administrators are explicitly denied
/// so a compromised service account on the same host can't escalate via the pipe.
/// </summary>
public static class PipeAcl
{
public static PipeSecurity Create(SecurityIdentifier allowedSid)
{
if (allowedSid is null) throw new ArgumentNullException(nameof(allowedSid));
var security = new PipeSecurity();
security.AddAccessRule(new PipeAccessRule(
allowedSid,
PipeAccessRights.ReadWrite | PipeAccessRights.Synchronize,
AccessControlType.Allow));
var localSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
var admins = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
if (allowedSid != localSystem)
security.AddAccessRule(new PipeAccessRule(localSystem, PipeAccessRights.FullControl, AccessControlType.Deny));
if (allowedSid != admins)
security.AddAccessRule(new PipeAccessRule(admins, PipeAccessRights.FullControl, AccessControlType.Deny));
security.SetOwner(allowedSid);
return security;
}
}

View File

@@ -0,0 +1,152 @@
using System;
using System.IO.Pipes;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
/// <summary>
/// Accepts one client connection at a time on the FOCAS Host's named pipe with the
/// strict ACL from <see cref="PipeAcl"/>. Verifies the peer SID + per-process shared
/// secret before any RPC frame is accepted. Mirrors the Galaxy.Host pipe server byte for
/// byte — different MessageKind enum, same negotiation semantics.
/// </summary>
public sealed class PipeServer : IDisposable
{
private readonly string _pipeName;
private readonly SecurityIdentifier _allowedSid;
private readonly string _sharedSecret;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts = new();
private NamedPipeServerStream? _current;
public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger)
{
_pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName));
_allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid));
_sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
var acl = PipeAcl.Create(_allowedSid);
_current = new NamedPipeServerStream(
_pipeName,
PipeDirection.InOut,
maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous,
inBufferSize: 64 * 1024,
outBufferSize: 64 * 1024,
pipeSecurity: acl);
try
{
await _current.WaitForConnectionAsync(linked.Token).ConfigureAwait(false);
if (!VerifyCaller(_current, out var reason))
{
_logger.Warning("FOCAS IPC caller rejected: {Reason}", reason);
_current.Disconnect();
return;
}
using var reader = new FrameReader(_current, leaveOpen: true);
using var writer = new FrameWriter(_current, leaveOpen: true);
var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
if (first is null || first.Value.Kind != FocasMessageKind.Hello)
{
_logger.Warning("FOCAS IPC first frame was not Hello; dropping");
return;
}
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
{
await writer.WriteAsync(FocasMessageKind.HelloAck,
new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" },
linked.Token).ConfigureAwait(false);
_logger.Warning("FOCAS IPC Hello rejected: shared-secret-mismatch");
return;
}
if (hello.ProtocolMajor != Hello.CurrentMajor)
{
await writer.WriteAsync(FocasMessageKind.HelloAck,
new HelloAck
{
Accepted = false,
RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}",
},
linked.Token).ConfigureAwait(false);
_logger.Warning("FOCAS IPC Hello rejected: major mismatch peer={Peer} server={Server}",
hello.ProtocolMajor, Hello.CurrentMajor);
return;
}
await writer.WriteAsync(FocasMessageKind.HelloAck,
new HelloAck { Accepted = true, HostName = Environment.MachineName },
linked.Token).ConfigureAwait(false);
using var attachment = handler.AttachConnection(writer);
while (!linked.Token.IsCancellationRequested)
{
var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
if (frame is null) break;
await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
}
}
finally
{
_current.Dispose();
_current = null;
}
}
public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.Error(ex, "FOCAS IPC connection loop error — accepting next"); }
}
}
private bool VerifyCaller(NamedPipeServerStream pipe, out string reason)
{
try
{
pipe.RunAsClient(() =>
{
using var wi = WindowsIdentity.GetCurrent();
if (wi.User is null)
throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller");
if (wi.User != _allowedSid)
throw new UnauthorizedAccessException(
$"caller SID {wi.User.Value} does not match allowed {_allowedSid.Value}");
});
reason = string.Empty;
return true;
}
catch (Exception ex) { reason = ex.Message; return false; }
}
public void Dispose()
{
_cts.Cancel();
_current?.Dispose();
_cts.Dispose();
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
/// <summary>
/// Placeholder handler that returns <c>ErrorResponse{Code=not-implemented}</c> for every
/// FOCAS data-plane request. Exists so PR B can ship the pipe server + ACL + handshake
/// plumbing before PR C moves the Fwlib32 calls. Heartbeats are handled fully so the
/// supervisor's liveness detector stays happy.
/// </summary>
public sealed class StubFrameHandler : IFrameHandler
{
public Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
{
if (kind == FocasMessageKind.Heartbeat)
{
var hb = MessagePackSerializer.Deserialize<Heartbeat>(body);
return writer.WriteAsync(FocasMessageKind.HeartbeatAck,
new HeartbeatAck
{
MonotonicTicks = hb.MonotonicTicks,
HostUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
}, ct);
}
return writer.WriteAsync(FocasMessageKind.ErrorResponse,
new ErrorResponse
{
Code = "not-implemented",
Message = $"Kind {kind} is stubbed — Fwlib32 lift lands in PR C",
},
ct);
}
public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance;
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Security.Principal;
using System.Threading;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host;
/// <summary>
/// Entry point for the <c>OtOpcUaFocasHost</c> Windows service / console host. The
/// supervisor (Proxy-side) spawns this process per FOCAS driver instance and passes the
/// pipe name, allowed-SID, and per-process shared secret as environment variables. In
/// PR B the backend is <see cref="StubFrameHandler"/> — PR C swaps in the real
/// Fwlib32-backed handler once the session state + STA thread move out of the .NET 10
/// driver.
/// </summary>
public static class Program
{
public static int Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
@"%ProgramData%\OtOpcUa\focas-host-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)),
rollingInterval: RollingInterval.Day)
.CreateLogger();
try
{
var pipeName = Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_PIPE") ?? "OtOpcUaFocas";
var allowedSidValue = Environment.GetEnvironmentVariable("OTOPCUA_ALLOWED_SID")
?? throw new InvalidOperationException(
"OTOPCUA_ALLOWED_SID not set — the FOCAS Proxy supervisor must pass the server principal SID");
var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_SECRET")
?? throw new InvalidOperationException(
"OTOPCUA_FOCAS_SECRET not set — the FOCAS Proxy supervisor must pass the per-process secret at spawn time");
var allowedSid = new SecurityIdentifier(allowedSidValue);
using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger);
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
Log.Information("OtOpcUaFocasHost starting — pipe={Pipe} allowedSid={Sid}",
pipeName, allowedSidValue);
var backendKind = (Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_BACKEND") ?? "unconfigured")
.ToLowerInvariant();
IFocasBackend backend = backendKind switch
{
"fake" => new FakeFocasBackend(),
"unconfigured" => new UnconfiguredFocasBackend(),
"fwlib32" => new UnconfiguredFocasBackend(), // real Fwlib32 backend lands with hardware integration follow-up
_ => new UnconfiguredFocasBackend(),
};
Log.Information("OtOpcUaFocasHost backend={Backend}", backendKind);
var handler = new FwlibFrameHandler(backend, Log.Logger);
server.RunAsync(handler, cts.Token).GetAwaiter().GetResult();
Log.Information("OtOpcUaFocasHost stopped cleanly");
return 0;
}
catch (Exception ex)
{
Log.Fatal(ex, "OtOpcUaFocasHost fatal");
return 2;
}
finally { Log.CloseAndFlush(); }
}
}

View File

@@ -0,0 +1,133 @@
using System;
using System.IO;
using System.IO.MemoryMappedFiles;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability;
/// <summary>
/// Ring-buffer of the last N IPC operations, written into a memory-mapped file. On a
/// hard crash the Proxy-side supervisor reads the MMF after the corpse is gone to see
/// what was in flight at the moment the Host died. Single-writer (the Host), multi-reader
/// (the supervisor) — the file format is identical to the Galaxy Tier-C
/// <c>PostMortemMmf</c> so a single reader tool can work both.
/// </summary>
/// <remarks>
/// File layout:
/// <code>
/// [16-byte header: magic(4) | version(4) | capacity(4) | writeIndex(4)]
/// [capacity × 256-byte entries: each is [8-byte utcUnixMs | 8-byte opKind | 240-byte UTF-8 message]]
/// </code>
/// Magic is 'OFPC' (0x4F46_5043) to distinguish a FOCAS file from the Galaxy MMF.
/// </remarks>
public sealed class PostMortemMmf : IDisposable
{
private const int Magic = 0x4F465043; // 'OFPC'
private const int Version = 1;
private const int HeaderBytes = 16;
public const int EntryBytes = 256;
private const int MessageOffset = 16;
private const int MessageCapacity = EntryBytes - MessageOffset;
public int Capacity { get; }
public string Path { get; }
private readonly MemoryMappedFile _mmf;
private readonly MemoryMappedViewAccessor _accessor;
private readonly object _writeGate = new();
public PostMortemMmf(string path, int capacity = 1000)
{
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
Capacity = capacity;
Path = path;
var fileBytes = HeaderBytes + capacity * EntryBytes;
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read);
fs.SetLength(fileBytes);
_mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
_accessor = _mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
if (_accessor.ReadInt32(0) != Magic)
{
_accessor.Write(0, Magic);
_accessor.Write(4, Version);
_accessor.Write(8, capacity);
_accessor.Write(12, 0);
}
}
public void Write(long opKind, string message)
{
lock (_writeGate)
{
var idx = _accessor.ReadInt32(12);
var offset = HeaderBytes + idx * EntryBytes;
_accessor.Write(offset + 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
_accessor.Write(offset + 8, opKind);
var msgBytes = Encoding.UTF8.GetBytes(message ?? string.Empty);
var copy = Math.Min(msgBytes.Length, MessageCapacity - 1);
_accessor.WriteArray(offset + MessageOffset, msgBytes, 0, copy);
_accessor.Write(offset + MessageOffset + copy, (byte)0);
var next = (idx + 1) % Capacity;
_accessor.Write(12, next);
}
}
public PostMortemEntry[] ReadAll()
{
var magic = _accessor.ReadInt32(0);
if (magic != Magic) return new PostMortemEntry[0];
var capacity = _accessor.ReadInt32(8);
var writeIndex = _accessor.ReadInt32(12);
var entries = new PostMortemEntry[capacity];
var count = 0;
for (var i = 0; i < capacity; i++)
{
var slot = (writeIndex + i) % capacity;
var offset = HeaderBytes + slot * EntryBytes;
var ts = _accessor.ReadInt64(offset + 0);
if (ts == 0) continue;
var op = _accessor.ReadInt64(offset + 8);
var msgBuf = new byte[MessageCapacity];
_accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
var nulTerm = Array.IndexOf<byte>(msgBuf, 0);
var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
entries[count++] = new PostMortemEntry(ts, op, msg);
}
Array.Resize(ref entries, count);
return entries;
}
public void Dispose()
{
_accessor.Dispose();
_mmf.Dispose();
}
}
public readonly struct PostMortemEntry
{
public long UtcUnixMs { get; }
public long OpKind { get; }
public string Message { get; }
public PostMortemEntry(long utcUnixMs, long opKind, string message)
{
UtcUnixMs = utcUnixMs;
OpKind = opKind;
Message = message;
}
}

View File

@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<!-- Fwlib32.dll is 32-bit only — x86 target is mandatory. Matches the Galaxy.Host
bitness constraint but for a different native library. -->
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host</RootNamespace>
<AssemblyName>OtOpcUa.Driver.FOCAS.Host</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0"/>
<PackageReference Include="System.Memory" Version="4.5.5"/>
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4"/>
<PackageReference Include="Serilog" Version="4.2.0"/>
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,120 @@
using System.IO;
using System.IO.Pipes;
using MessagePack;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
/// <summary>
/// Proxy-side IPC channel to a running <c>Driver.FOCAS.Host</c>. Owns the pipe connection
/// and serializes request/response round-trips through a single call gate so
/// concurrent callers don't interleave frames. One instance per FOCAS Host session.
/// </summary>
public sealed class FocasIpcClient : IAsyncDisposable
{
private readonly Stream _stream;
private readonly FrameReader _reader;
private readonly FrameWriter _writer;
private readonly SemaphoreSlim _callGate = new(1, 1);
private FocasIpcClient(Stream stream)
{
_stream = stream;
_reader = new FrameReader(stream, leaveOpen: true);
_writer = new FrameWriter(stream, leaveOpen: true);
}
/// <summary>Named-pipe factory: connects, sends Hello, awaits HelloAck.</summary>
public static async Task<FocasIpcClient> ConnectAsync(
string pipeName, string sharedSecret, TimeSpan connectTimeout, CancellationToken ct)
{
var stream = new NamedPipeClientStream(
serverName: ".",
pipeName: pipeName,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
await stream.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct);
return await HandshakeAsync(stream, sharedSecret, ct).ConfigureAwait(false);
}
/// <summary>
/// Stream factory — used by tests that wire the Proxy against an in-memory stream
/// pair instead of a real pipe. <paramref name="stream"/> is owned by the caller
/// until <see cref="DisposeAsync"/>.
/// </summary>
public static Task<FocasIpcClient> ConnectAsync(Stream stream, string sharedSecret, CancellationToken ct)
=> HandshakeAsync(stream, sharedSecret, ct);
private static async Task<FocasIpcClient> HandshakeAsync(Stream stream, string sharedSecret, CancellationToken ct)
{
var client = new FocasIpcClient(stream);
try
{
await client._writer.WriteAsync(FocasMessageKind.Hello,
new Hello { PeerName = "FOCAS.Proxy", SharedSecret = sharedSecret }, ct).ConfigureAwait(false);
var ack = await client._reader.ReadFrameAsync(ct).ConfigureAwait(false);
if (ack is null || ack.Value.Kind != FocasMessageKind.HelloAck)
throw new InvalidOperationException("Did not receive HelloAck from FOCAS.Host");
var ackMsg = FrameReader.Deserialize<HelloAck>(ack.Value.Body);
if (!ackMsg.Accepted)
throw new UnauthorizedAccessException($"FOCAS.Host rejected Hello: {ackMsg.RejectReason}");
return client;
}
catch
{
await client.DisposeAsync().ConfigureAwait(false);
throw;
}
}
public async Task<TResp> CallAsync<TReq, TResp>(
FocasMessageKind requestKind, TReq request, FocasMessageKind expectedResponseKind, CancellationToken ct)
{
await _callGate.WaitAsync(ct).ConfigureAwait(false);
try
{
await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false);
var frame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false);
if (frame is null) throw new EndOfStreamException("FOCAS IPC peer closed before response");
if (frame.Value.Kind == FocasMessageKind.ErrorResponse)
{
var err = MessagePackSerializer.Deserialize<ErrorResponse>(frame.Value.Body);
throw new FocasIpcException(err.Code, err.Message);
}
if (frame.Value.Kind != expectedResponseKind)
throw new InvalidOperationException(
$"Expected {expectedResponseKind}, got {frame.Value.Kind}");
return MessagePackSerializer.Deserialize<TResp>(frame.Value.Body);
}
finally { _callGate.Release(); }
}
public async Task SendOneWayAsync<TReq>(FocasMessageKind requestKind, TReq request, CancellationToken ct)
{
await _callGate.WaitAsync(ct).ConfigureAwait(false);
try { await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false); }
finally { _callGate.Release(); }
}
public async ValueTask DisposeAsync()
{
_callGate.Dispose();
_reader.Dispose();
_writer.Dispose();
await _stream.DisposeAsync().ConfigureAwait(false);
}
}
public sealed class FocasIpcException(string code, string message) : Exception($"[{code}] {message}")
{
public string Code { get; } = code;
}

View File

@@ -0,0 +1,199 @@
using MessagePack;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
/// <summary>
/// <see cref="IFocasClient"/> implementation that forwards every operation over a
/// <see cref="FocasIpcClient"/> to a <c>Driver.FOCAS.Host</c> process. Keeps the
/// <c>Fwlib32.dll</c> P/Invoke out of the main server process so a native crash
/// blast-radius stops at the Host boundary.
/// </summary>
/// <remarks>
/// Session lifecycle: <see cref="ConnectAsync"/> sends <c>OpenSessionRequest</c> and
/// caches the returned <c>SessionId</c>. Subsequent <see cref="ReadAsync"/> /
/// <see cref="WriteAsync"/> / <see cref="ProbeAsync"/> calls thread that session id
/// onto each request DTO. <see cref="Dispose"/> sends <c>CloseSessionRequest</c> +
/// disposes the underlying pipe.
/// </remarks>
public sealed class IpcFocasClient : IFocasClient
{
private readonly FocasIpcClient _ipc;
private readonly FocasCncSeries _series;
private long _sessionId;
private bool _connected;
public IpcFocasClient(FocasIpcClient ipc, FocasCncSeries series = FocasCncSeries.Unknown)
{
_ipc = ipc ?? throw new ArgumentNullException(nameof(ipc));
_series = series;
}
public bool IsConnected => _connected;
public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{
if (_connected) return;
var resp = await _ipc.CallAsync<OpenSessionRequest, OpenSessionResponse>(
FocasMessageKind.OpenSessionRequest,
new OpenSessionRequest
{
HostAddress = $"{address.Host}:{address.Port}",
TimeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds),
CncSeries = (int)_series,
},
FocasMessageKind.OpenSessionResponse,
cancellationToken).ConfigureAwait(false);
if (!resp.Success)
throw new InvalidOperationException(
$"FOCAS Host rejected OpenSession for {address}: {resp.ErrorCode ?? "?"} — {resp.Error}");
_sessionId = resp.SessionId;
_connected = true;
}
public async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
if (!_connected) return (null, FocasStatusMapper.BadCommunicationError);
var resp = await _ipc.CallAsync<ReadRequest, ReadResponse>(
FocasMessageKind.ReadRequest,
new ReadRequest
{
SessionId = _sessionId,
Address = ToDto(address),
DataType = (int)type,
},
FocasMessageKind.ReadResponse,
cancellationToken).ConfigureAwait(false);
if (!resp.Success) return (null, resp.StatusCode);
var value = DecodeValue(resp.ValueBytes, resp.ValueTypeCode);
return (value, resp.StatusCode);
}
public async Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
{
if (!_connected) return FocasStatusMapper.BadCommunicationError;
// PMC bit writes get the first-class RMW frame so the critical section stays on the Host.
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
{
var bitResp = await _ipc.CallAsync<PmcBitWriteRequest, PmcBitWriteResponse>(
FocasMessageKind.PmcBitWriteRequest,
new PmcBitWriteRequest
{
SessionId = _sessionId,
Address = ToDto(address),
BitIndex = bit,
Value = Convert.ToBoolean(value),
},
FocasMessageKind.PmcBitWriteResponse,
cancellationToken).ConfigureAwait(false);
return bitResp.StatusCode;
}
var resp = await _ipc.CallAsync<WriteRequest, WriteResponse>(
FocasMessageKind.WriteRequest,
new WriteRequest
{
SessionId = _sessionId,
Address = ToDto(address),
DataType = (int)type,
ValueTypeCode = (int)type,
ValueBytes = EncodeValue(value, type),
},
FocasMessageKind.WriteResponse,
cancellationToken).ConfigureAwait(false);
return resp.StatusCode;
}
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
{
if (!_connected) return false;
try
{
var resp = await _ipc.CallAsync<ProbeRequest, ProbeResponse>(
FocasMessageKind.ProbeRequest,
new ProbeRequest { SessionId = _sessionId },
FocasMessageKind.ProbeResponse,
cancellationToken).ConfigureAwait(false);
return resp.Healthy;
}
catch { return false; }
}
public void Dispose()
{
if (_connected)
{
try
{
_ipc.SendOneWayAsync(FocasMessageKind.CloseSessionRequest,
new CloseSessionRequest { SessionId = _sessionId }, CancellationToken.None)
.GetAwaiter().GetResult();
}
catch { /* best effort */ }
_connected = false;
}
_ipc.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
private static FocasAddressDto ToDto(FocasAddress addr) => new()
{
Kind = (int)addr.Kind,
PmcLetter = addr.PmcLetter,
Number = addr.Number,
BitIndex = addr.BitIndex,
};
private static byte[]? EncodeValue(object? value, FocasDataType type)
{
if (value is null) return null;
return type switch
{
FocasDataType.Bit => MessagePackSerializer.Serialize(Convert.ToBoolean(value)),
FocasDataType.Byte => MessagePackSerializer.Serialize(Convert.ToByte(value)),
FocasDataType.Int16 => MessagePackSerializer.Serialize(Convert.ToInt16(value)),
FocasDataType.Int32 => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
FocasDataType.Float32 => MessagePackSerializer.Serialize(Convert.ToSingle(value)),
FocasDataType.Float64 => MessagePackSerializer.Serialize(Convert.ToDouble(value)),
FocasDataType.String => MessagePackSerializer.Serialize(Convert.ToString(value) ?? string.Empty),
_ => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
};
}
private static object? DecodeValue(byte[]? bytes, int typeCode)
{
if (bytes is null) return null;
return typeCode switch
{
FocasDataTypeCode.Bit => MessagePackSerializer.Deserialize<bool>(bytes),
FocasDataTypeCode.Byte => MessagePackSerializer.Deserialize<byte>(bytes),
FocasDataTypeCode.Int16 => MessagePackSerializer.Deserialize<short>(bytes),
FocasDataTypeCode.Int32 => MessagePackSerializer.Deserialize<int>(bytes),
FocasDataTypeCode.Float32 => MessagePackSerializer.Deserialize<float>(bytes),
FocasDataTypeCode.Float64 => MessagePackSerializer.Deserialize<double>(bytes),
FocasDataTypeCode.String => MessagePackSerializer.Deserialize<string>(bytes),
_ => MessagePackSerializer.Deserialize<int>(bytes),
};
}
}
/// <summary>
/// Factory producing <see cref="IpcFocasClient"/>s. One pipe connection per
/// <c>IFocasClient</c> — matches the driver's one-client-per-device invariant. The
/// deployment wires this into the DI container in place of
/// <see cref="UnimplementedFocasClientFactory"/>.
/// </summary>
public sealed class IpcFocasClientFactory(Func<FocasIpcClient> ipcClientFactory, FocasCncSeries series = FocasCncSeries.Unknown)
: IFocasClientFactory
{
public IFocasClient Create() => new IpcFocasClient(ipcClientFactory(), series);
}

View File

@@ -0,0 +1,30 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// Respawn-with-backoff schedule for the FOCAS Host process. Matches Galaxy Tier-C:
/// 5s → 15s → 60s cap. A sustained stable run (default 2 min) resets the index so a
/// one-off crash after hours of steady-state doesn't start from the top of the ladder.
/// </summary>
public sealed class Backoff
{
public static TimeSpan[] DefaultSequence { get; } =
[TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(60)];
public TimeSpan StableRunThreshold { get; init; } = TimeSpan.FromMinutes(2);
private readonly TimeSpan[] _sequence;
private int _index;
public Backoff(TimeSpan[]? sequence = null) => _sequence = sequence ?? DefaultSequence;
public TimeSpan Next()
{
var delay = _sequence[Math.Min(_index, _sequence.Length - 1)];
_index++;
return delay;
}
public void RecordStableRun() => _index = 0;
public int AttemptIndex => _index;
}

View File

@@ -0,0 +1,69 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// Crash-loop circuit breaker for the FOCAS Host. Matches Galaxy Tier-C defaults:
/// 3 crashes within 5 minutes opens the breaker; cooldown escalates 1h → 4h → manual
/// reset. A sticky alert stays live until the operator explicitly clears it so
/// recurring crashes can't silently burn through the cooldown ladder overnight.
/// </summary>
public sealed class CircuitBreaker
{
public int CrashesAllowedPerWindow { get; init; } = 3;
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(5);
public TimeSpan[] CooldownEscalation { get; init; } =
[TimeSpan.FromHours(1), TimeSpan.FromHours(4), TimeSpan.MaxValue];
private readonly List<DateTime> _crashesUtc = [];
private DateTime? _openSinceUtc;
private int _escalationLevel;
public bool StickyAlertActive { get; private set; }
/// <summary>
/// Records a crash + returns <c>true</c> if the supervisor may respawn. On
/// <c>false</c>, <paramref name="cooldownRemaining"/> is how long to wait before
/// trying again (<c>TimeSpan.MaxValue</c> means manual reset required).
/// </summary>
public bool TryRecordCrash(DateTime utcNow, out TimeSpan cooldownRemaining)
{
if (_openSinceUtc is { } openedAt)
{
var cooldown = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
if (cooldown == TimeSpan.MaxValue)
{
cooldownRemaining = TimeSpan.MaxValue;
return false;
}
if (utcNow - openedAt < cooldown)
{
cooldownRemaining = cooldown - (utcNow - openedAt);
return false;
}
_openSinceUtc = null;
_escalationLevel++;
}
_crashesUtc.RemoveAll(t => utcNow - t > Window);
_crashesUtc.Add(utcNow);
if (_crashesUtc.Count > CrashesAllowedPerWindow)
{
_openSinceUtc = utcNow;
StickyAlertActive = true;
cooldownRemaining = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
return false;
}
cooldownRemaining = TimeSpan.Zero;
return true;
}
public void ManualReset()
{
_crashesUtc.Clear();
_openSinceUtc = null;
_escalationLevel = 0;
StickyAlertActive = false;
}
}

View File

@@ -0,0 +1,159 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// Ties <see cref="IHostProcessLauncher"/> + <see cref="Backoff"/> +
/// <see cref="CircuitBreaker"/> + <see cref="HeartbeatMonitor"/> into one object the
/// driver asks for <c>IFocasClient</c>s. On a detected crash (process exit or
/// heartbeat loss) the supervisor fans out <c>BadCommunicationError</c> to all
/// subscribers via the <see cref="OnUnavailable"/> callback, then respawns with
/// backoff unless the breaker is open.
/// </summary>
/// <remarks>
/// The supervisor itself is I/O-free — it doesn't know how to spawn processes, probe
/// pipes, or send heartbeats. Production wires the concrete
/// <see cref="IHostProcessLauncher"/> over <c>FocasIpcClient</c> + <c>Process</c>;
/// tests drive the same state machine with a deterministic launcher stub.
/// </remarks>
public sealed class FocasHostSupervisor : IDisposable
{
private readonly IHostProcessLauncher _launcher;
private readonly Backoff _backoff;
private readonly CircuitBreaker _breaker;
private readonly Func<DateTime> _clock;
private IFocasClient? _current;
private DateTime _currentStartedUtc;
private bool _disposed;
public FocasHostSupervisor(
IHostProcessLauncher launcher,
Backoff? backoff = null,
CircuitBreaker? breaker = null,
Func<DateTime>? clock = null)
{
_launcher = launcher ?? throw new ArgumentNullException(nameof(launcher));
_backoff = backoff ?? new Backoff();
_breaker = breaker ?? new CircuitBreaker();
_clock = clock ?? (() => DateTime.UtcNow);
}
/// <summary>Raised with a short reason string whenever the Host goes unavailable (crash / heartbeat loss / breaker-open).</summary>
public event Action<string>? OnUnavailable;
/// <summary>Crash count observed in the current process lifetime. Exposed for /hosts Admin telemetry.</summary>
public int ObservedCrashes { get; private set; }
/// <summary><c>true</c> if the crash-loop breaker has latched a sticky alert that needs operator reset.</summary>
public bool StickyAlertActive => _breaker.StickyAlertActive;
public int BackoffAttempt => _backoff.AttemptIndex;
/// <summary>
/// Returns the current live client. If none, tries to launch — applying the
/// backoff schedule between attempts and stopping once the breaker opens.
/// </summary>
public async Task<IFocasClient> GetOrLaunchAsync(CancellationToken ct)
{
ThrowIfDisposed();
if (_current is not null && _launcher.IsProcessAlive) return _current;
return await LaunchWithBackoffAsync(ct).ConfigureAwait(false);
}
/// <summary>
/// Called by the heartbeat task each time a miss threshold is crossed.
/// Treated as a crash: fan out Bad status + attempt respawn.
/// </summary>
public async Task NotifyHostDeadAsync(string reason, CancellationToken ct)
{
ThrowIfDisposed();
OnUnavailable?.Invoke(reason);
ObservedCrashes++;
try { await _launcher.TerminateAsync(ct).ConfigureAwait(false); }
catch { /* best effort */ }
_current?.Dispose();
_current = null;
if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
{
OnUnavailable?.Invoke(cooldown == TimeSpan.MaxValue
? "circuit-breaker-open-manual-reset-required"
: $"circuit-breaker-open-cooldown-{cooldown:g}");
return;
}
// Successful crash recording — do not respawn synchronously; GetOrLaunchAsync will
// pick up the attempt on the next call. Keeps the fan-out fast.
}
/// <summary>Operator action — clear the sticky alert + reset the breaker.</summary>
public void AcknowledgeAndReset()
{
_breaker.ManualReset();
_backoff.RecordStableRun();
}
private async Task<IFocasClient> LaunchWithBackoffAsync(CancellationToken ct)
{
while (true)
{
if (_breaker.StickyAlertActive)
{
if (!_breaker.TryRecordCrash(_clock(), out var cooldown) && cooldown == TimeSpan.MaxValue)
throw new InvalidOperationException(
"FOCAS Host circuit breaker is open and awaiting manual reset. " +
"See Admin /hosts; call AcknowledgeAndReset after investigating the Host log.");
}
try
{
_current = await _launcher.LaunchAsync(ct).ConfigureAwait(false);
_currentStartedUtc = _clock();
// If the launch sequence itself takes long enough to count as a stable run,
// reset the backoff ladder immediately.
if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
_backoff.RecordStableRun();
return _current;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
OnUnavailable?.Invoke($"launch-failed: {ex.Message}");
ObservedCrashes++;
if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
{
var hint = cooldown == TimeSpan.MaxValue
? "manual reset required"
: $"cooldown {cooldown:g}";
throw new InvalidOperationException(
$"FOCAS Host circuit breaker opened after {ObservedCrashes} crashes — {hint}.", ex);
}
var delay = _backoff.Next();
await Task.Delay(delay, ct).ConfigureAwait(false);
}
}
}
/// <summary>Called from the heartbeat loop after a successful ack run — relaxes the backoff ladder.</summary>
public void NotifyStableRun()
{
if (_current is null) return;
if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
_backoff.RecordStableRun();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try { _launcher.TerminateAsync(CancellationToken.None).GetAwaiter().GetResult(); }
catch { /* best effort */ }
_current?.Dispose();
_current = null;
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(FocasHostSupervisor));
}
}

View File

@@ -0,0 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// Tracks missed heartbeats from the FOCAS Host. 2s cadence + 3 consecutive misses =
/// host declared dead (~6s detection). Same defaults as Galaxy Tier-C so operators
/// see the same cadence across hosts on the /hosts Admin page.
/// </summary>
public sealed class HeartbeatMonitor
{
public int MissesUntilDead { get; init; } = 3;
public TimeSpan Cadence { get; init; } = TimeSpan.FromSeconds(2);
public int ConsecutiveMisses { get; private set; }
public DateTime? LastAckUtc { get; private set; }
public void RecordAck(DateTime utcNow)
{
ConsecutiveMisses = 0;
LastAckUtc = utcNow;
}
/// <summary>Records a missed heartbeat; returns <c>true</c> when the death threshold is crossed.</summary>
public bool RecordMiss()
{
ConsecutiveMisses++;
return ConsecutiveMisses >= MissesUntilDead;
}
}

View File

@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// Abstraction over the act of spawning a FOCAS Host process and obtaining an
/// <see cref="IFocasClient"/> connected to it. Production wires this to a real
/// <c>Process.Start</c> + <c>FocasIpcClient.ConnectAsync</c>; tests use a fake that
/// exposes deterministic failure modes so the supervisor logic can be stressed
/// without spawning actual exes.
/// </summary>
public interface IHostProcessLauncher
{
/// <summary>
/// Spawn a new Host process (if one isn't already running) and return a live
/// client session. Throws on unrecoverable errors; transient errors (e.g. Host
/// not ready yet) should throw <see cref="TimeoutException"/> so the supervisor
/// applies the backoff ladder.
/// </summary>
Task<IFocasClient> LaunchAsync(CancellationToken ct);
/// <summary>
/// Terminate the Host process if one is running. Called on Dispose and after a
/// heartbeat loss is detected.
/// </summary>
Task TerminateAsync(CancellationToken ct);
/// <summary>
/// <c>true</c> when the most recently spawned Host process is still alive.
/// Supervisor polls this at heartbeat cadence; going <c>false</c> without a
/// clean shutdown counts as a crash.
/// </summary>
bool IsProcessAlive { get; }
}

View File

@@ -0,0 +1,57 @@
using System.IO.MemoryMappedFiles;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// Proxy-side reader for the Host's post-mortem MMF. After a Host crash the supervisor
/// opens the file (which persists beyond the process lifetime) and enumerates the last
/// few thousand IPC operations that were in flight. Format matches
/// <c>Driver.FOCAS.Host.Stability.PostMortemMmf</c> — magic 'OFPC' / 256-byte entries.
/// </summary>
public sealed class PostMortemReader
{
private const int Magic = 0x4F465043; // 'OFPC'
private const int HeaderBytes = 16;
private const int EntryBytes = 256;
private const int MessageOffset = 16;
private const int MessageCapacity = EntryBytes - MessageOffset;
public string Path { get; }
public PostMortemReader(string path) => Path = path;
public PostMortemEntry[] ReadAll()
{
if (!File.Exists(Path)) return [];
using var mmf = MemoryMappedFile.CreateFromFile(Path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
using var accessor = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
if (accessor.ReadInt32(0) != Magic) return [];
var capacity = accessor.ReadInt32(8);
var writeIndex = accessor.ReadInt32(12);
var entries = new PostMortemEntry[capacity];
var count = 0;
for (var i = 0; i < capacity; i++)
{
var slot = (writeIndex + i) % capacity;
var offset = HeaderBytes + slot * EntryBytes;
var ts = accessor.ReadInt64(offset + 0);
if (ts == 0) continue;
var op = accessor.ReadInt64(offset + 8);
var msgBuf = new byte[MessageCapacity];
accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
var nulTerm = Array.IndexOf<byte>(msgBuf, 0);
var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
entries[count++] = new PostMortemEntry(ts, op, msg);
}
Array.Resize(ref entries, count);
return entries;
}
}
public readonly record struct PostMortemEntry(long UtcUnixMs, long OpKind, string Message);

View File

@@ -0,0 +1,113 @@
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// Production <see cref="IHostProcessLauncher"/>. Spawns <c>OtOpcUa.Driver.FOCAS.Host.exe</c>
/// with the pipe name / allowed-SID / per-spawn shared secret in the environment, waits for
/// the pipe to come up, then connects a <see cref="FocasIpcClient"/> and wraps it in an
/// <see cref="IpcFocasClient"/>. On <see cref="TerminateAsync"/> best-effort kills the
/// process and closes the IPC stream.
/// </summary>
public sealed class ProcessHostLauncher : IHostProcessLauncher
{
private readonly ProcessHostLauncherOptions _options;
private Process? _process;
private FocasIpcClient? _ipc;
public ProcessHostLauncher(ProcessHostLauncherOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public bool IsProcessAlive => _process is { HasExited: false };
public async Task<IFocasClient> LaunchAsync(CancellationToken ct)
{
await TerminateAsync(ct).ConfigureAwait(false);
var secret = _options.SharedSecret ?? Guid.NewGuid().ToString("N");
var psi = new ProcessStartInfo
{
FileName = _options.HostExePath,
Arguments = _options.Arguments ?? string.Empty,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.Environment["OTOPCUA_FOCAS_PIPE"] = _options.PipeName;
psi.Environment["OTOPCUA_ALLOWED_SID"] = _options.AllowedSid;
psi.Environment["OTOPCUA_FOCAS_SECRET"] = secret;
psi.Environment["OTOPCUA_FOCAS_BACKEND"] = _options.Backend;
_process = Process.Start(psi)
?? throw new InvalidOperationException($"Failed to start {_options.HostExePath}");
// Poll for pipe readiness up to the configured connect timeout.
var deadline = DateTime.UtcNow + _options.ConnectTimeout;
while (true)
{
ct.ThrowIfCancellationRequested();
if (_process.HasExited)
throw new InvalidOperationException(
$"FOCAS Host exited before pipe was ready (ExitCode={_process.ExitCode}).");
try
{
_ipc = await FocasIpcClient.ConnectAsync(
_options.PipeName, secret, TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
break;
}
catch (TimeoutException)
{
if (DateTime.UtcNow >= deadline)
throw new TimeoutException(
$"FOCAS Host pipe {_options.PipeName} did not come up within {_options.ConnectTimeout:g}.");
await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
}
}
return new IpcFocasClient(_ipc, _options.Series);
}
public async Task TerminateAsync(CancellationToken ct)
{
if (_ipc is not null)
{
try { await _ipc.DisposeAsync().ConfigureAwait(false); }
catch { /* best effort */ }
_ipc = null;
}
if (_process is not null)
{
try
{
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
await _process.WaitForExitAsync(ct).ConfigureAwait(false);
}
}
catch { /* best effort */ }
finally
{
_process.Dispose();
_process = null;
}
}
}
}
public sealed record ProcessHostLauncherOptions(
string HostExePath,
string PipeName,
string AllowedSid)
{
public string? SharedSecret { get; init; }
public string? Arguments { get; init; }
public string Backend { get; init; } = "fwlib32";
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15);
public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown;
}

View File

@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
</ItemGroup>
<!--

View File

@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
using IpcHostConnectivityStatus = ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts.HostConnectivityStatus;
@@ -22,6 +23,7 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
IHistoryProvider,
IRediscoverable,
IHostConnectivityProbe,
IAlarmHistorianWriter,
IDisposable
{
private GalaxyIpcClient? _client;
@@ -511,6 +513,23 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
_ => AlarmSeverity.Critical,
};
/// <summary>
/// Phase 7 follow-up #247 — IAlarmHistorianWriter implementation. Forwards alarm
/// batches to Galaxy.Host over the existing IPC channel, reusing the connection
/// the driver already established for data-plane traffic. Throws
/// <see cref="InvalidOperationException"/> when called before
/// <see cref="InitializeAsync"/> has connected the client; the SQLite drain worker
/// translates that to whole-batch RetryPlease per its catch contract.
/// </summary>
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
{
if (_client is null)
throw new InvalidOperationException(
"GalaxyProxyDriver IPC client not connected — historian writes rejected until InitializeAsync completes");
return new GalaxyHistorianWriter(_client).WriteBatchAsync(batch, cancellationToken);
}
public void Dispose() => _client?.DisposeAsync().AsTask().GetAwaiter().GetResult();
}

View File

@@ -0,0 +1,90 @@
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
/// <summary>
/// Phase 7 follow-up (task #247) — bridges <see cref="SqliteStoreAndForwardSink"/>'s
/// drain worker to <c>Driver.Galaxy.Host</c> over the existing <see cref="GalaxyIpcClient"/>
/// pipe. Translates <see cref="AlarmHistorianEvent"/> batches into the
/// <see cref="HistorianAlarmEventDto"/> wire format the Host expects + maps per-event
/// <see cref="HistorianAlarmEventOutcomeDto"/> responses back to
/// <see cref="HistorianWriteOutcome"/> so the SQLite queue knows what to ack /
/// dead-letter / retry.
/// </summary>
/// <remarks>
/// <para>
/// Reuses the IPC channel <see cref="GalaxyProxyDriver"/> already opens for the
/// Galaxy data plane — no second pipe to <c>Driver.Galaxy.Host</c>, no separate
/// auth handshake. The IPC client's call gate serializes historian batches with
/// driver Reads/Writes/Subscribes; historian batches are infrequent (every few
/// seconds at most under the SQLite sink's drain cadence) so the contention is
/// negligible compared to per-tag-read pressure.
/// </para>
/// <para>
/// Pipe-level transport faults (broken pipe, host crash) bubble up as
/// <see cref="GalaxyIpcException"/> which the SQLite sink's drain worker catches +
/// translates to a whole-batch RetryPlease per the
/// <see cref="SqliteStoreAndForwardSink"/> docstring — failed events stay queued
/// for the next drain tick after backoff.
/// </para>
/// </remarks>
public sealed class GalaxyHistorianWriter : IAlarmHistorianWriter
{
private readonly GalaxyIpcClient _client;
public GalaxyHistorianWriter(GalaxyIpcClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public async Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(batch);
if (batch.Count == 0) return [];
var request = new HistorianAlarmEventRequest
{
Events = batch.Select(ToDto).ToArray(),
};
var response = await _client.CallAsync<HistorianAlarmEventRequest, HistorianAlarmEventResponse>(
requestKind: MessageKind.HistorianAlarmEventRequest,
request: request,
expectedResponseKind: MessageKind.HistorianAlarmEventResponse,
ct: cancellationToken).ConfigureAwait(false);
if (response.Outcomes.Length != batch.Count)
throw new InvalidOperationException(
$"Galaxy.Host returned {response.Outcomes.Length} outcomes for a batch of {batch.Count} — protocol mismatch");
var outcomes = new HistorianWriteOutcome[response.Outcomes.Length];
for (var i = 0; i < response.Outcomes.Length; i++)
outcomes[i] = MapOutcome(response.Outcomes[i]);
return outcomes;
}
internal static HistorianAlarmEventDto ToDto(AlarmHistorianEvent e) => new()
{
AlarmId = e.AlarmId,
EquipmentPath = e.EquipmentPath,
AlarmName = e.AlarmName,
AlarmTypeName = e.AlarmTypeName,
Severity = (int)e.Severity,
EventKind = e.EventKind,
Message = e.Message,
User = e.User,
Comment = e.Comment,
TimestampUtcUnixMs = new DateTimeOffset(e.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
};
internal static HistorianWriteOutcome MapOutcome(HistorianAlarmEventOutcomeDto wire) => wire switch
{
HistorianAlarmEventOutcomeDto.Ack => HistorianWriteOutcome.Ack,
HistorianAlarmEventOutcomeDto.RetryPlease => HistorianWriteOutcome.RetryPlease,
HistorianAlarmEventOutcomeDto.PermanentFail => HistorianWriteOutcome.PermanentFail,
_ => throw new InvalidOperationException($"Unknown HistorianAlarmEventOutcomeDto byte {(byte)wire}"),
};
}

View File

@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
</ItemGroup>
<ItemGroup>

View File

@@ -60,6 +60,14 @@ public enum MessageKind : byte
HostConnectivityStatus = 0x70,
RuntimeStatusChange = 0x71,
// Phase 7 Stream D — historian alarm sink. Main server → Galaxy.Host batched
// writes into the Aveva Historian alarm schema via the already-loaded
// aahClientManaged DLLs. HistorianConnectivityStatus fires proactively from the
// Host when the SDK session transitions so diagnostics flip promptly.
HistorianAlarmEventRequest = 0x80,
HistorianAlarmEventResponse = 0x81,
HistorianConnectivityStatus = 0x82,
RecycleHostRequest = 0xF0,
RecycleStatusResponse = 0xF1,

View File

@@ -0,0 +1,92 @@
using System;
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
/// <summary>
/// Phase 7 Stream D — IPC contracts for routing Part 9 alarm transitions from the
/// main .NET 10 server into Galaxy.Host's already-loaded <c>aahClientManaged</c>
/// DLLs. Reuses the Tier-C isolation + licensing pathway rather than loading 32-bit
/// native historian code into the main server.
/// </summary>
/// <remarks>
/// <para>
/// Batched on the wire to amortize IPC overhead — the main server's SqliteStoreAndForwardSink
/// ships up to 100 events per request per Phase 7 plan Stream D.5.
/// </para>
/// <para>
/// Per-event outcomes (Ack / RetryPlease / PermanentFail) let the drain worker
/// dead-letter malformed events without blocking neighbors in the batch.
/// <see cref="HistorianConnectivityStatusNotification"/> fires proactively from
/// the Host when the SDK session drops so the /hosts + /alarms/historian Admin
/// diagnostics pages flip to red promptly instead of waiting for the next
/// drain cycle.
/// </para>
/// </remarks>
[MessagePackObject]
public sealed class HistorianAlarmEventRequest
{
[Key(0)] public HistorianAlarmEventDto[] Events { get; set; } = Array.Empty<HistorianAlarmEventDto>();
}
[MessagePackObject]
public sealed class HistorianAlarmEventResponse
{
/// <summary>Per-event outcome, same order as the request.</summary>
[Key(0)] public HistorianAlarmEventOutcomeDto[] Outcomes { get; set; } = Array.Empty<HistorianAlarmEventOutcomeDto>();
}
/// <summary>Outcome enum — bytes on the wire so it stays compact.</summary>
public enum HistorianAlarmEventOutcomeDto : byte
{
/// <summary>Successfully persisted to the historian — remove from queue.</summary>
Ack = 0,
/// <summary>Transient failure (historian disconnected, timeout, busy) — retry after backoff.</summary>
RetryPlease = 1,
/// <summary>Permanent failure (malformed, unrecoverable SDK error) — move to dead-letter.</summary>
PermanentFail = 2,
}
/// <summary>One alarm-transition payload. Fields mirror <c>Core.AlarmHistorian.AlarmHistorianEvent</c>.</summary>
[MessagePackObject]
public sealed class HistorianAlarmEventDto
{
[Key(0)] public string AlarmId { get; set; } = string.Empty;
[Key(1)] public string EquipmentPath { get; set; } = string.Empty;
[Key(2)] public string AlarmName { get; set; } = string.Empty;
/// <summary>Concrete Part 9 subtype name — "LimitAlarm" / "OffNormalAlarm" / "AlarmCondition" / "DiscreteAlarm".</summary>
[Key(3)] public string AlarmTypeName { get; set; } = string.Empty;
/// <summary>Numeric severity the Host maps to the historian's priority scale.</summary>
[Key(4)] public int Severity { get; set; }
/// <summary>Which transition this event represents — "Activated" / "Cleared" / "Acknowledged" / etc.</summary>
[Key(5)] public string EventKind { get; set; } = string.Empty;
/// <summary>Pre-rendered message — template tokens resolved upstream.</summary>
[Key(6)] public string Message { get; set; } = string.Empty;
/// <summary>Operator who triggered the transition. "system" for engine-driven events.</summary>
[Key(7)] public string User { get; set; } = "system";
/// <summary>Operator-supplied free-form comment, if any.</summary>
[Key(8)] public string? Comment { get; set; }
/// <summary>Source timestamp (UTC Unix milliseconds).</summary>
[Key(9)] public long TimestampUtcUnixMs { get; set; }
}
/// <summary>
/// Proactive notification — Galaxy.Host pushes this when the historian SDK session
/// transitions (connected / disconnected / degraded). The main server reflects this
/// into the historian sink status so Admin UI surfaces the problem without the
/// operator having to scrutinize drain cadence.
/// </summary>
[MessagePackObject]
public sealed class HistorianConnectivityStatusNotification
{
[Key(0)] public string Status { get; set; } = "unknown"; // connected | disconnected | degraded
[Key(1)] public string? Detail { get; set; }
[Key(2)] public long ObservedAtUtcUnixMs { get; set; }
}

View File

@@ -68,9 +68,18 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private readonly AuthorizationGate? _authzGate;
private readonly NodeScopeResolver? _scopeResolver;
// Phase 7 Stream G follow-up — per-variable NodeSourceKind so OnReadValue can dispatch
// to the VirtualTagEngine / ScriptedAlarmEngine instead of the driver's IReadable per
// ADR-002. Absent entries default to Driver so drivers registered before Phase 7
// keep working unchanged.
private readonly Dictionary<string, NodeSourceKind> _sourceByFullRef = new(StringComparer.OrdinalIgnoreCase);
private readonly IReadable? _virtualReadable;
private readonly IReadable? _scriptedAlarmReadable;
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null)
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null)
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
{
_driver = driver;
@@ -80,6 +89,8 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
_invoker = invoker;
_authzGate = authzGate;
_scopeResolver = scopeResolver;
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
_logger = logger;
}
@@ -185,6 +196,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
_variablesByFullRef[attributeInfo.FullName] = v;
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
_writeIdempotentByFullRef[attributeInfo.FullName] = attributeInfo.WriteIdempotent;
_sourceByFullRef[attributeInfo.FullName] = attributeInfo.Source;
v.OnReadValue = OnReadValue;
v.OnWriteValue = OnWriteValue;
@@ -216,16 +228,18 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private ServiceResult OnReadValue(ISystemContext context, NodeState node, NumericRange indexRange,
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
{
if (_readable is null)
var fullRef = node.NodeId.Identifier as string ?? "";
var source = _sourceByFullRef.TryGetValue(fullRef, out var s) ? s : NodeSourceKind.Driver;
var readable = SelectReadable(source, _readable, _virtualReadable, _scriptedAlarmReadable);
if (readable is null)
{
statusCode = StatusCodes.BadNotReadable;
statusCode = source == NodeSourceKind.Driver ? StatusCodes.BadNotReadable : StatusCodes.BadNotFound;
return ServiceResult.Good;
}
try
{
var fullRef = node.NodeId.Identifier as string ?? "";
// Phase 6.2 Stream C — authorization gate. Runs ahead of the invoker so a denied
// read never hits the driver. Returns true in lax mode when identity lacks LDAP
// groups; strict mode denies those cases. See AuthorizationGate remarks.
@@ -242,7 +256,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var result = _invoker.ExecuteAsync(
DriverCapability.Read,
ResolveHostFor(fullRef),
async ct => (IReadOnlyList<DataValueSnapshot>)await _readable.ReadAsync([fullRef], ct).ConfigureAwait(false),
async ct => (IReadOnlyList<DataValueSnapshot>)await readable.ReadAsync([fullRef], ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
if (result.Count == 0)
{
@@ -262,6 +276,32 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
return ServiceResult.Good;
}
/// <summary>
/// Picks the <see cref="IReadable"/> the dispatch layer routes through based on the
/// node's Phase 7 source kind (ADR-002). Extracted as a pure function for unit test
/// coverage — the full dispatch requires the OPC UA server stack, but this kernel is
/// deterministic and small.
/// </summary>
internal static IReadable? SelectReadable(
NodeSourceKind source,
IReadable? driverReadable,
IReadable? virtualReadable,
IReadable? scriptedAlarmReadable) => source switch
{
NodeSourceKind.Virtual => virtualReadable,
NodeSourceKind.ScriptedAlarm => scriptedAlarmReadable,
_ => driverReadable,
};
/// <summary>
/// Plan decision #6 gate — returns true only when the write is allowed. Virtual tags
/// and scripted alarms reject OPC UA writes because the write path for virtual tags
/// is <c>ctx.SetVirtualTag</c> from within a script, and the write path for alarm
/// state is the Part 9 method nodes (Acknowledge / Confirm / Shelve).
/// </summary>
internal static bool IsWriteAllowedBySource(NodeSourceKind source) =>
source == NodeSourceKind.Driver;
private static NodeId MapDataType(DriverDataType t) => t switch
{
DriverDataType.Boolean => DataTypeIds.Boolean,
@@ -414,10 +454,19 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private ServiceResult OnWriteValue(ISystemContext context, NodeState node, NumericRange indexRange,
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
{
if (_writable is null) return StatusCodes.BadNotWritable;
var fullRef = node.NodeId.Identifier as string;
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
// Per Phase 7 plan decision #6 — virtual tags + scripted alarms reject direct
// OPC UA writes with BadUserAccessDenied. Scripts can write to virtual tags
// via ctx.SetVirtualTag; operators cannot. Operator alarm actions go through
// the Part 9 method nodes (Acknowledge / Confirm / Shelve), not through the
// variable-value write path.
if (_sourceByFullRef.TryGetValue(fullRef!, out var source) && !IsWriteAllowedBySource(source))
return new ServiceResult(StatusCodes.BadUserAccessDenied);
if (_writable is null) return StatusCodes.BadNotWritable;
// PR 26: server-layer write authorization. Look up the attribute's classification
// (populated during Variable() in Discover) and check the session's roles against the
// policy table. Drivers don't participate in this decision — IWritable.WriteAsync

View File

@@ -30,6 +30,16 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
private readonly Func<string, string?>? _resilienceConfigLookup;
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? _equipmentContentLookup;
// Phase 7 Stream G follow-up (task #239). When composed with the VirtualTagEngine +
// ScriptedAlarmEngine sources these route node reads to the engines instead of the
// driver. Null = Phase 7 engines not enabled for this deployment (identical to pre-
// Phase-7 behaviour). Late-bindable via SetPhase7Sources because the engines need
// the bootstrapped generation id before they can compose, which is only known after
// the host has been DI-constructed (task #246).
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable;
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application;
@@ -45,7 +55,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
StaleConfigFlag? staleConfigFlag = null,
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
Func<string, string?>? resilienceConfigLookup = null,
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null)
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null,
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable = null,
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable = null)
{
_options = options;
_driverHost = driverHost;
@@ -57,12 +69,32 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_tierLookup = tierLookup;
_resilienceConfigLookup = resilienceConfigLookup;
_equipmentContentLookup = equipmentContentLookup;
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
_loggerFactory = loggerFactory;
_logger = logger;
}
public OtOpcUaServer? Server => _server;
/// <summary>
/// Late-bind the Phase 7 engine-backed <c>IReadable</c> sources. Must be
/// called BEFORE <see cref="StartAsync"/> — once the OPC UA server starts, the
/// <see cref="OtOpcUaServer"/> ctor captures the field values + per-node
/// <see cref="DriverNodeManager"/>s are constructed. Calling this after start has
/// no effect on already-materialized node managers.
/// </summary>
public void SetPhase7Sources(
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable,
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable)
{
if (_server is not null)
throw new InvalidOperationException(
"Phase 7 sources must be set before OpcUaApplicationHost.StartAsync; the OtOpcUaServer + DriverNodeManagers have already captured the previous values.");
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
}
/// <summary>
/// Builds the <see cref="ApplicationConfiguration"/>, validates/creates the application
/// certificate, constructs + starts the <see cref="OtOpcUaServer"/>, then drives
@@ -85,7 +117,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
authzGate: _authzGate, scopeResolver: _scopeResolver,
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup);
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
await _application.Start(_server).ConfigureAwait(false);
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",

View File

@@ -25,6 +25,15 @@ public sealed class OtOpcUaServer : StandardServer
private readonly NodeScopeResolver? _scopeResolver;
private readonly Func<string, DriverTier>? _tierLookup;
private readonly Func<string, string?>? _resilienceConfigLookup;
// Phase 7 Stream G follow-up wiring (task #239). Shared across every DriverNodeManager
// instantiated by this server so virtual-tag reads and scripted-alarm reads from any
// driver's address-space subtree route to the same engine. When null (no Phase 7
// engines composed for this deployment) DriverNodeManager falls back to driver-only
// dispatch — identical to pre-Phase-7 behaviour.
private readonly IReadable? _virtualReadable;
private readonly IReadable? _scriptedAlarmReadable;
private readonly ILoggerFactory _loggerFactory;
private readonly List<DriverNodeManager> _driverNodeManagers = new();
@@ -36,7 +45,9 @@ public sealed class OtOpcUaServer : StandardServer
AuthorizationGate? authzGate = null,
NodeScopeResolver? scopeResolver = null,
Func<string, DriverTier>? tierLookup = null,
Func<string, string?>? resilienceConfigLookup = null)
Func<string, string?>? resilienceConfigLookup = null,
IReadable? virtualReadable = null,
IReadable? scriptedAlarmReadable = null)
{
_driverHost = driverHost;
_authenticator = authenticator;
@@ -45,6 +56,8 @@ public sealed class OtOpcUaServer : StandardServer
_scopeResolver = scopeResolver;
_tierLookup = tierLookup;
_resilienceConfigLookup = resilienceConfigLookup;
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
_loggerFactory = loggerFactory;
}
@@ -77,7 +90,8 @@ public sealed class OtOpcUaServer : StandardServer
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
authzGate: _authzGate, scopeResolver: _scopeResolver);
authzGate: _authzGate, scopeResolver: _scopeResolver,
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
_driverNodeManagers.Add(manager);
}

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
namespace ZB.MOM.WW.OtOpcUa.Server;
@@ -17,6 +18,7 @@ public sealed class OpcUaServerService(
DriverHost driverHost,
OpcUaApplicationHost applicationHost,
DriverEquipmentContentRegistry equipmentContentRegistry,
Phase7Composer phase7Composer,
IServiceScopeFactory scopeFactory,
ILogger<OpcUaServerService> logger) : BackgroundService
{
@@ -34,12 +36,19 @@ public sealed class OpcUaServerService(
// Skipped when no generation is Published yet — the fleet boots into a UNS-less
// address space until the first publish, then the registry fills on next restart.
if (result.GenerationId is { } gen)
{
await PopulateEquipmentContentAsync(gen, stoppingToken);
// PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver
// registration itself (RegisterAsync on DriverHost) happens during an earlier DI
// extension once the central config DB query + per-driver factory land; for now the
// server comes up with whatever drivers are in DriverHost at start time.
// Phase 7 follow-up #246 — load Script + VirtualTag + ScriptedAlarm rows,
// compose VirtualTagEngine + ScriptedAlarmEngine, start the driver-bridge
// feed. SetPhase7Sources MUST run before applicationHost.StartAsync because
// OtOpcUaServer + DriverNodeManager construction captures the field values
// — late binding after server start is rejected with InvalidOperationException.
// No-op when the generation has no virtual tags or scripted alarms.
var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken);
applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable);
}
await applicationHost.StartAsync(stoppingToken);
logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count);
@@ -57,6 +66,11 @@ public sealed class OpcUaServerService(
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
// Dispose Phase 7 first so the bridge stops feeding the cache + the engines
// stop firing alarm/historian events before the OPC UA server tears down its
// node managers. Otherwise an in-flight cascade could try to push through a
// disposed source and surface as a noisy shutdown warning.
await phase7Composer.DisposeAsync();
await applicationHost.DisposeAsync();
await driverHost.DisposeAsync();
}

Some files were not shown because too many files have changed in this diff Show More