diff --git a/CLAUDE.md b/CLAUDE.md index 892546f2..5465302f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,17 +155,69 @@ Galaxy `mx_data_type` values map to OPC UA types (Boolean, Int32, Float, Double, `DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys. -⚠️ **Nothing consumes that signal.** No type under `src/Server/` or `src/Core/` subscribes to -`OnRediscoveryNeeded` — the only `+=` handlers in `src/` are Galaxy re-raising its own watcher's event -onto its own interface (`GalaxyDriver.cs:586`). `DriverHostActor.HandleDiscoveredNodes` -(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:986-1002`) short-circuits with an -unconditional `return;` — discovered-node injection is **dormant in v3** because raw tags are authored -through the `/raw` browse-commit flow instead. A Galaxy redeploy therefore does **not** change the -served address space; a config redeploy is required. The same inert-signal gap affects TwinCAT, -MQTT/Sparkplug and MTConnect, and `IHostConnectivityProbe` is likewise unconsumed (`GetHostStatuses()` -has no production call site; nothing ever writes a `DriverHostStatus` row). Tracked as Gitea **#518** -(no consumer) and **#507** (re-migrate injection onto the raw subtree) — note that fixing either alone -changes nothing observable. +**The signal is consumed as an operator prompt, not as an address-space rebuild** (§8.2, 2026-07-27). +`DriverInstanceActor` subscribes in `PreStart` and unsubscribes in `PostStop` — the detach is +load-bearing, because the `IDriver` instance outlives the actor when the host respawns a child around +the same driver object. A raise is recorded and rides the driver-health snapshot +(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`) to the AdminUI `/hosts` page as a +**"re-browse" chip**. + +⚠️ **It is advisory: a Galaxy redeploy still does NOT change the served address space.** v3 authors raw +tags explicitly through the `/raw` browse-commit flow, so an operator must re-browse the device and +commit. Injecting nodes at runtime would materialise nodes nobody approved and no deployment artifact +records. + +Two gotchas if you touch this: + +- `PublishHealthSnapshot` dedups on a health fingerprint. The rediscovery timestamp **must** stay in + that tuple, or a raise on an otherwise-unchanged Healthy driver is swallowed and never reaches the + operator. Pinned by `DriverInstanceActorRediscoverySignalTests`. +- The shared test `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs on + every call and the dedup never engages — a test built on it passes vacuously. The rediscovery tests + use a stub with stable health for exactly this reason. + +**#507 is closed as superseded, and the injection path is deleted.** Its retained code read +`EquipmentNode.DriverInstanceId` and `EquipmentTags`, both *structurally empty* in v3 +(`AddressSpaceComposer.cs`, `DeploymentArtifact.cs`), so removing the guard would have changed a log +line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`, +`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`, +`AddressSpaceApplier.MaterialiseDiscoveredNodes`, the connect-time discovery loop +(`StartDiscovery`/`RediscoverTick`/`DiscoveredNodesReady`/`TriggerRediscovery`), and the 18 +`DiscoveryInjectionDormantV3` tests — v2 characterization tests asserting an equipment-rooted graft +(`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce. `ITagDiscovery` itself stays: the `/raw` +browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through +the actor. + +**Tag-set drift detection (2026-07-28) — the second signal, and it is GATED.** `DriverInstanceActor` +re-browses on a slow timer (`DefaultDriftCheckInterval`, 5 min) and compares what the remote offers +against what an operator authored, raising the same re-browse prompt. This matters most for **AbCip and +FOCAS**, which browse a live backend but implement no `IRediscoverable`, so a periodic compare is their +only way to notice a PLC re-download. + +⚠️ **It runs ONLY for a driver declaring `ITagDiscovery.SupportsOnlineDiscovery` AND reporting +`AuthoredDiscoveryRefs` (nullable, default null = opt out).** Modbus, S7, MQTT, AbLegacy and Sql *echo +authored config* from `DiscoverAsync` rather than browsing the device, so the diff for them is a +tautology that would report "no drift" forever and read as coverage. A check that cannot fail is worse +than no check. Note `AuthoredDiscoveryRefs` is **nullable, not empty-by-default** — an empty collection +legitimately means "nothing authored, so everything discovered is new". + +⚠️ **One direction is structurally undetectable for most drivers.** FOCAS, TwinCAT and AbCip re-emit +their authored tags into the same `DiscoverAsync` stream as device-derived ones (so the browse picker +shows existing tags), which means discovered ⊇ authored **always** and a *vanished* tag can never appear +missing. Those drivers declare `DiscoveryStreamIncludesAuthoredTags => true`, and the detector then +reports only what it can see and **says so** in the operator message rather than implying a clean bill of +health. **MTConnect is currently the only driver where both directions are detectable** — its stream is +built purely from the Agent's probe model. + +Other properties worth knowing: a **failed browse is not drift** (an unreachable device would otherwise +report every authored tag as vanished); a **truncated** capture is skipped for the same reason; an +**unchanged** drift is reported once rather than re-stamping its timestamp every 5 min; and drift that +resolves **clears** the prompt. The timer starts on Connected entry and is cancelled on every exit. + +⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and +nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so +confirm intent before deleting). The separable `OnHostStatusChanged` event is the useful half. Tracked +as Gitea **#521**. ## mxaccessgw diff --git a/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json b/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json index 1435e5b4..1d44fc98 100644 --- a/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json +++ b/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json @@ -9,78 +9,110 @@ }, { "id": 1, - "subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) — must FAIL at f6eaa267", + "subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) \u2014 must FAIL at f6eaa267", "status": "completed", - "blockedBy": [0] + "blockedBy": [ + 0 + ] }, { "id": 2, "subject": "RED: poll-loop-survival test T2 (subscription survives connect-timeout outage) + caller-cancel pinning test T4", "status": "completed", - "blockedBy": [0, 1] + "blockedBy": [ + 0, + 1 + ] }, { "id": 3, "subject": "GREEN: EnsureConnectedAsync + InitializeAsync connect-timeout OCE -> TimeoutException conversion (STAB-14 root fix)", "status": "completed", - "blockedBy": [1, 2] + "blockedBy": [ + 1, + 2 + ] }, { "id": 4, "subject": "Defensive when-filters on ensure-wrapper OCE rethrows (:488/:1000) + poll-loop OCE catches (:1383/:1396/:1404)", "status": "completed", - "blockedBy": [3] + "blockedBy": [ + 3 + ] }, { "id": 5, "subject": "Finding-1 close gate: full S7 unit + CLI test-project sweep green", "status": "completed", - "blockedBy": [4] + "blockedBy": [ + 4 + ] }, { "id": 6, - "subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) — must FAIL", + "subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) \u2014 must FAIL", "status": "completed", - "blockedBy": [0] + "blockedBy": [ + 0 + ] }, { "id": 7, "subject": "GREEN: broaden IsS7ConnectionFatal to the ISO-on-TCP framing surface (NOT InvalidDataException) + xmldoc (STAB-15a)", "status": "completed", - "blockedBy": [6] + "blockedBy": [ + 6 + ] }, { "id": 8, "subject": "RED->GREEN: cancellation-during-I/O marks handle dead (T6 read + T7 write; per-item OCE catches set _plcDead before propagating)", "status": "completed", - "blockedBy": [7] + "blockedBy": [ + 7 + ] }, { "id": 9, "subject": "RED->GREEN: probe marks handle dead under the gate (T8 persistent probe fault + T9 probe timeout; ProbeLoopAsync inner catch restructure, STAB-15b)", "status": "completed", - "blockedBy": [8] + "blockedBy": [ + 8 + ] }, { "id": 10, "subject": "Doc: R2-09 connect-throttle seam note on EnsureConnectedAsync (STAB-8 S7 part, note-only)", "status": "completed", - "blockedBy": [3] + "blockedBy": [ + 3 + ] }, { "id": 11, "subject": "Env-gated live connect-timeout outage test (S7_TIMEOUT_OUTAGE_START_CMD/STOP_CMD blackhole design; skips cleanly offline)", - "status": "deferred-live", + "status": "completed", "note": "env-gated; runs in serial live pass (integration suite; serialized heavy pass). Test authored + committed; verified it SKIPs cleanly offline.", - "blockedBy": [5] + "blockedBy": [ + 5 + ], + "closureNote": "LIVE GATE CLOSED 2026-07-15 \u2014 S7 blackhole run via `docker pause` on the 10.100.0.35 s7_1500 fixture. Found + fixed a real read-leg wall-clock gap (PR #453). STATUS.md 'docker-dev live-/run pass'." }, { "id": 12, "subject": "Close-out: whole-suite sweep + update archreview STATUS.md and 05 prior-finding table (STAB-14/15 FIXED, STAB-8 seam noted)", "status": "completed", "note": "S7 unit 246/246 green; S7 CLI 48/49 (1 pre-existing unrelated comment-scan failure, see deviations); solution BUILD clean (0 errors). Whole-solution dotnet test NOT run per controller memory-constraint (integration suites deferred to serial heavy pass).", - "blockedBy": [5, 7, 8, 9, 10, 11] + "blockedBy": [ + 5, + 7, + 8, + 9, + 10, + 11 + ] } ], - "lastUpdated": "2026-07-13" + "lastUpdated": "2026-07-27" } diff --git a/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json b/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json index 12113498..3f92570f 100644 --- a/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json +++ b/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json @@ -1,25 +1,170 @@ { "planPath": "archreview/plans/R2-02-resilience-config-hardening-plan.md", "tasks": [ - { "id": 0, "subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls — ResilienceConfigBrickTests, expect 2 failures on f6eaa267", "status": "completed", "blockedBy": [] }, - { "id": 1, "subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)", "status": "completed", "blockedBy": [] }, - { "id": 2, "subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests", "status": "completed", "blockedBy": [0, 1] }, - { "id": 3, "subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)", "status": "completed", "blockedBy": [0, 1] }, - { "id": 4, "subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests", "status": "completed", "blockedBy": [2, 3] }, - { "id": 5, "subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection", "status": "completed", "blockedBy": [1] }, - { "id": 6, "subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc", "status": "completed", "blockedBy": [2] }, - { "id": 7, "subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false — RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green", "status": "completed", "blockedBy": [6] }, - { "id": 8, "subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests", "status": "completed", "blockedBy": [7] }, - { "id": 9, "subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor", "status": "completed", "blockedBy": [7, 5] }, - { "id": 10, "subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)", "status": "completed", "blockedBy": [2, 6] }, - { "id": 11, "subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests", "status": "completed", "blockedBy": [5, 10] }, - { "id": 12, "subject": "U-6 doc sweep (seam xmldocs, OTOPCUA0001 message + analyzer tests, operator docs; leave migrations/WedgeDetector/historical plans) + Options_properties_are_exactly_the_pipeline_wired_set knob-inertness guard", "status": "completed", "blockedBy": [10, 11] }, - { "id": 13, "subject": "C-7 RED tests: unknown top-level key / unknown capability / unknown per-policy field survive round-trip; malformed JSON sets ParseFailed and ToJson returns the original text", "status": "completed", "blockedBy": [11] }, - { "id": 14, "subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green", "status": "completed", "blockedBy": [13] }, - { "id": 15, "subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)", "status": "deferred-live", "note": "docker-dev :9200 live-/run; serial live pass", "blockedBy": [14] }, - { "id": 16, "subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration", "status": "completed", "blockedBy": [3] }, - { "id": 17, "subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)", "status": "completed", "blockedBy": [16] }, - { "id": 18, "subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md", "status": "deferred-live", "note": "integration/full sweep; serialized heavy pass", "blockedBy": [4, 8, 9, 12, 15, 17] } + { + "id": 0, + "subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls \u2014 ResilienceConfigBrickTests, expect 2 failures on f6eaa267", + "status": "completed", + "blockedBy": [] + }, + { + "id": 1, + "subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)", + "status": "completed", + "blockedBy": [] + }, + { + "id": 2, + "subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests", + "status": "completed", + "blockedBy": [ + 0, + 1 + ] + }, + { + "id": 3, + "subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)", + "status": "completed", + "blockedBy": [ + 0, + 1 + ] + }, + { + "id": 4, + "subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests", + "status": "completed", + "blockedBy": [ + 2, + 3 + ] + }, + { + "id": 5, + "subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 6, + "subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc", + "status": "completed", + "blockedBy": [ + 2 + ] + }, + { + "id": 7, + "subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false \u2014 RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green", + "status": "completed", + "blockedBy": [ + 6 + ] + }, + { + "id": 8, + "subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 9, + "subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor", + "status": "completed", + "blockedBy": [ + 7, + 5 + ] + }, + { + "id": 10, + "subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)", + "status": "completed", + "blockedBy": [ + 2, + 6 + ] + }, + { + "id": 11, + "subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests", + "status": "completed", + "blockedBy": [ + 5, + 10 + ] + }, + { + "id": 12, + "subject": "U-6 doc sweep (seam xmldocs, OTOPCUA0001 message + analyzer tests, operator docs; leave migrations/WedgeDetector/historical plans) + Options_properties_are_exactly_the_pipeline_wired_set knob-inertness guard", + "status": "completed", + "blockedBy": [ + 10, + 11 + ] + }, + { + "id": 13, + "subject": "C-7 RED tests: unknown top-level key / unknown capability / unknown per-policy field survive round-trip; malformed JSON sets ParseFailed and ToJson returns the original text", + "status": "completed", + "blockedBy": [ + 11 + ] + }, + { + "id": 14, + "subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green", + "status": "completed", + "blockedBy": [ + 13 + ] + }, + { + "id": 15, + "subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)", + "status": "completed", + "note": "docker-dev :9200 live-/run; serial live pass", + "blockedBy": [ + 14 + ], + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 range-validation banner verified on the docker-dev Modbus driver's Resilience overrides (`timeoutSeconds:0`). STATUS.md 'docker-dev live-/run pass'." + }, + { + "id": 16, + "subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration", + "status": "completed", + "blockedBy": [ + 3 + ] + }, + { + "id": 17, + "subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)", + "status": "completed", + "blockedBy": [ + 16 + ] + }, + { + "id": 18, + "subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md", + "status": "completed", + "note": "integration/full sweep; serialized heavy pass", + "blockedBy": [ + 4, + 8, + 9, + 12, + 15, + 17 + ], + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 S-8 verified: Write and AlarmAcknowledge RETRIES cells disabled ('never'). STATUS.md 'docker-dev live-/run pass'." + } ], - "lastUpdated": "2026-07-12" + "lastUpdated": "2026-07-27" } diff --git a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json index 2c55fffb..794981d2 100644 --- a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json +++ b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json @@ -3,7 +3,7 @@ "tasks": [ { "id": "R2-03-T1", - "subject": "S13 stale-Good repro test at host level (RED — must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate", + "subject": "S13 stale-Good repro test at host level (RED \u2014 must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate", "status": "completed", "blockedBy": [] }, @@ -62,7 +62,7 @@ }, { "id": "R2-03-T9", - "subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes — identical redeploy must NOT clear; expression edit must; shared-expression removal must not", + "subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes \u2014 identical redeploy must NOT clear; expression edit must; shared-expression removal must not", "status": "completed", "blockedBy": [ "R2-03-T3" @@ -86,7 +86,8 @@ "R2-03-T8", "R2-03-T10" ], - "note": "integration/full sweep; serialized heavy pass (build clean, Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20 verified locally; whole-solution/cross-suite sweep deferred per controller memory constraint)" + "note": "integration/full sweep; serialized heavy pass (build clean, Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20 verified locally; whole-solution/cross-suite sweep deferred per controller memory constraint)", + "openNote": "STILL OPEN \u2014 live-blocked on a data-less rig: every materialised node reads Bad_WaitingForInitialData because no driver data flows, so a VT cannot be staged Good->Bad. Needs a reachable driver fixture feeding the VT. Unit-verified meanwhile." }, { "id": "R2-03-T12", @@ -95,8 +96,9 @@ "blockedBy": [ "R2-03-T11" ], - "note": "docker-dev live-/run; serial live pass" + "note": "docker-dev live-/run; serial live pass", + "openNote": "STILL OPEN \u2014 same blocker as T11. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)." } ], - "lastUpdated": "2026-07-13" + "lastUpdated": "2026-07-27" } diff --git a/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json index 58d12f0e..37a9ba4b 100644 --- a/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json +++ b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json @@ -1,22 +1,146 @@ { "planPath": "archreview/plans/R2-05-adminui-authz-plan.md", - "lastUpdated": "2026-07-12", + "lastUpdated": "2026-07-27", "tasks": [ - { "id": "T1", "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", "status": "completed", "blockedBy": [] }, - { "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "completed", "blockedBy": ["T1"] }, - { "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "completed", "blockedBy": ["T1"] }, - { "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "completed", "blockedBy": ["T2"] }, - { "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "completed", "blockedBy": ["T1"] }, - { "id": "T6", "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T7", "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T8", "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "completed", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] }, - { "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "completed", "blockedBy": ["T12", "T13"], "note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint." }, - { "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "deferred-live", "blockedBy": ["T14"], "note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate — controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin)." }, - { "id": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "completed", "blockedBy": ["T15"] } + { + "id": "T1", + "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T2", + "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", + "status": "completed", + "blockedBy": [ + "T1" + ] + }, + { + "id": "T3", + "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", + "status": "completed", + "blockedBy": [ + "T1" + ] + }, + { + "id": "T4", + "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", + "status": "completed", + "blockedBy": [ + "T2" + ] + }, + { + "id": "T5", + "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", + "status": "completed", + "blockedBy": [ + "T1" + ] + }, + { + "id": "T6", + "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T7", + "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T8", + "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T9", + "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T10", + "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T11", + "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", + "status": "completed", + "blockedBy": [ + "T3", + "T6", + "T7", + "T8", + "T9", + "T10" + ] + }, + { + "id": "T12", + "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T13", + "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T14", + "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", + "status": "completed", + "blockedBy": [ + "T12", + "T13" + ], + "note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint." + }, + { + "id": "T15", + "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", + "status": "completed", + "blockedBy": [ + "T14" + ], + "note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate \u2014 controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin).", + "closureNote": "LIVE GATE CLOSED 2026-07-13 (partial by design) \u2014 every config/read page renders under the all-roles multi-role-test session, so named-policy gating does not over-gate a legitimate admin. The DENY direction is unobservable on docker-dev (DisableLogin=true), as documented." + }, + { + "id": "T16", + "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", + "status": "completed", + "blockedBy": [ + "T15" + ] + } ] } diff --git a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json index 985c2394..dded8b73 100644 --- a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json +++ b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json @@ -1,16 +1,16 @@ { "planPath": "archreview/plans/R2-06-serverhistorian-failfast-plan.md", - "lastUpdated": "2026-07-12", + "lastUpdated": "2026-07-27", "tasks": [ { "id": "T1", - "subject": "RED: misconfig repro test — HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)", + "subject": "RED: misconfig repro test \u2014 HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)", "status": "completed", "blockedBy": [] }, { "id": "T2", - "subject": "GREEN: adapter guard — Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)", + "subject": "GREEN: adapter guard \u2014 Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)", "status": "completed", "blockedBy": [ "T1" @@ -50,7 +50,7 @@ }, { "id": "T7", - "subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType — 0.2.0 proto3-optional presence witness; pin, no RED phase)", + "subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType \u2014 0.2.0 proto3-optional presence witness; pin, no RED phase)", "status": "completed", "blockedBy": [] }, @@ -89,12 +89,13 @@ { "id": "T12", "subject": "OPERATOR GATE: run Category=LiveIntegration on the VPN against a 0.2.0 gateway (all 6 tests, incl. Boolean EnsureTags + retype probe); record the observed retype policy back into docs/Historian.md + STATUS.md; also discharges 06/U-2's full documented run", - "status": "deferred-live", + "status": "completed", "blockedBy": [ "T9", "T11" ], - "note": "DONE 2026-07-13 on VPN vs a real 0.2.0 gateway (deployed locally in Docker → real AVEVA historian on wonder-sql-vd03 + Runtime SQL). 5/6 green. Found+FIXED a real OtOpcUa bug (PR #439, master 666908d7): GatewayTagProvisioner sent StorageRateMs=0 → gateway EnsureTags threw ArgumentOutOfRangeException → ALL historized-tag provisioning silently failed; stamp 1000ms + regression test. U-7 retype policy ANSWERED: EnsureTags is a real upsert; in-place retype between SUPPORTED types is ACCEPTED (Float→Double=success); but Int1 (0.2.0 Boolean target) is NOT creatable on this histsdk (ProtocolEvidenceMissingException — Int1-only; Int2/UInt2/Int4/UInt4/Float/Double all provision), so a pre-bump Float Boolean asked to become Int1 FAILS the provision (not silently retyped/data-lost). The 1 red test (Boolean→Int1) is a gateway histsdk type-support gap, not OtOpcUa. Deployment fixes: FQDN host + libgssapi-krb5-2/gss-ntlmssp in the gateway image + wonderapp SQL login for Runtime. Full record in STATUS.md." + "note": "DONE 2026-07-13 on VPN vs a real 0.2.0 gateway (deployed locally in Docker \u2192 real AVEVA historian on wonder-sql-vd03 + Runtime SQL). 5/6 green. Found+FIXED a real OtOpcUa bug (PR #439, master 666908d7): GatewayTagProvisioner sent StorageRateMs=0 \u2192 gateway EnsureTags threw ArgumentOutOfRangeException \u2192 ALL historized-tag provisioning silently failed; stamp 1000ms + regression test. U-7 retype policy ANSWERED: EnsureTags is a real upsert; in-place retype between SUPPORTED types is ACCEPTED (Float\u2192Double=success); but Int1 (0.2.0 Boolean target) is NOT creatable on this histsdk (ProtocolEvidenceMissingException \u2014 Int1-only; Int2/UInt2/Int4/UInt4/Float/Double all provision), so a pre-bump Float Boolean asked to become Int1 FAILS the provision (not silently retyped/data-lost). The 1 red test (Boolean\u2192Int1) is a gateway histsdk type-support gap, not OtOpcUa. Deployment fixes: FQDN host + libgssapi-krb5-2/gss-ntlmssp in the gateway image + wonderapp SQL login for Runtime. Full record in STATUS.md.", + "closureNote": "LIVE GATE CLOSED 2026-07-13 (6/6) \u2014 vs. the local 0.2.0 gateway + real historian over VPN. STATUS.md 'docker-dev live-/run pass'." } ] } diff --git a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json index 2d9cd149..d7601db2 100644 --- a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json +++ b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json @@ -1,6 +1,6 @@ { "planPath": "archreview/plans/R2-07-surgical-pure-adds-plan.md", - "lastUpdated": "2026-07-12", + "lastUpdated": "2026-07-27", "tasks": [ { "id": "T1", @@ -45,20 +45,22 @@ { "id": "T5", "subject": "Phase 1: over-the-wire SubscriptionSurvivalTests \u2014 subscribe, pure-add, item survives + new node browsable", - "status": "deferred-live", + "status": "completed", "blockedBy": [ "T4b" ], - "note": "authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests suite leaks ~16GB/run per the memory constraint. MUST run in the serial heavy integration pass (single-test, boots one in-process server + client)." + "note": "authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests suite leaks ~16GB/run per the memory constraint. MUST run in the serial heavy integration pass (single-test, boots one in-process server + client).", + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 central-1 boot logged PureAdd (added=91, rebuild=False)." }, { "id": "T6", "subject": "Phase 1: MANDATORY live-/run gate on docker-dev (rebuild BOTH centrals; Client.CLI subscribe survives +1-tag deploy; kind=PureAdd rebuild=False) \u2014 Phase 1 shippable boundary", - "status": "deferred-live", + "status": "completed", "blockedBy": [ "T5" ], - "note": "docker-dev live /run surgical-path; serial heavy pass \u2014 F10b inertness risk, MUST run (subscribe survives +1-tag deploy; central log kind=PureAdd, rebuild=False; new node browsable/Good)." + "note": "docker-dev live /run surgical-path; serial heavy pass \u2014 F10b inertness risk, MUST run (subscribe survives +1-tag deploy; central log kind=PureAdd, rebuild=False; new node browsable/Good).", + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 deleting a UNS virtual tag logged PureRemove (removed=1, rebuild=False) and the node vanished from the address space." }, { "id": "T7", @@ -104,11 +106,12 @@ { "id": "T12", "subject": "Phase 2: remove-side subscription-survival integration test + live-/run remove gate (kind=PureRemove rebuild=False) \u2014 Phase 2 shippable boundary", - "status": "deferred-live", + "status": "completed", "blockedBy": [ "T11" ], - "note": "remove-side SubscriptionSurvivalTests authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests memory constraint. Live docker-dev pure-remove gate (survivor subscription uninterrupted; central log kind=PureRemove, rebuild=False; removed node reads BadNodeIdUnknown) MUST run in the serial heavy pass \u2014 F10b inertness risk." + "note": "remove-side SubscriptionSurvivalTests authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests memory constraint. Live docker-dev pure-remove gate (survivor subscription uninterrupted; central log kind=PureRemove, rebuild=False; removed node reads BadNodeIdUnknown) MUST run in the serial heavy pass \u2014 F10b inertness risk.", + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 surgical add AND remove both execute in prod with no full rebuild; the remove path's 3 new sink members are wired, not inert." }, { "id": "T13", @@ -121,11 +124,12 @@ { "id": "T14", "subject": "Phase 3: mixed-deploy integration test + live-/run mixed gate + STATUS.md/P1 close-out \u2014 plan complete", - "status": "deferred-live", + "status": "completed", "blockedBy": [ "T13" ], - "note": "mixed-deploy SubscriptionSurvivalTests authored + compiles (dotnet build green); docs close-out DONE (STATUS.md + 03/P1 marked REMEDIATED + plan deviations). Live docker-dev mixed gate (one deploy +1/-1; kind=AddRemoveMix, rebuild=False; survivor uninterrupted) MUST run in the serial heavy pass \u2014 F10b inertness risk." + "note": "mixed-deploy SubscriptionSurvivalTests authored + compiles (dotnet build green); docs close-out DONE (STATUS.md + 03/P1 marked REMEDIATED + plan deviations). Live docker-dev mixed gate (one deploy +1/-1; kind=AddRemoveMix, rebuild=False; survivor uninterrupted) MUST run in the serial heavy pass \u2014 F10b inertness risk.", + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 see T5/T6/T12. STATUS.md 'docker-dev live-/run pass'." } ] } diff --git a/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json index a8a1e80f..7b6e371f 100644 --- a/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json +++ b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json @@ -1,17 +1,94 @@ { "planPath": "archreview/plans/R2-10-resilience-observability-plan.md", - "lastUpdated": "2026-07-12", + "lastUpdated": "2026-07-27", "tasks": [ - { "id": "T1", "subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)", "status": "completed", "blockedBy": [] }, - { "id": "T2", "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", "status": "completed", "blockedBy": ["T1"] }, - { "id": "T3", "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", "status": "completed", "blockedBy": ["T1"] }, - { "id": "T4", "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", "status": "completed", "blockedBy": [] }, - { "id": "T5", "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", "status": "completed", "blockedBy": ["T1", "T4"] }, - { "id": "T6", "subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)", "status": "completed", "blockedBy": ["T5"] }, - { "id": "T7", "subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)", "status": "completed", "blockedBy": ["T4"] }, - { "id": "T8", "subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges", "status": "completed", "blockedBy": ["T7"] }, - { "id": "T9", "subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit — verified by T10)", "status": "completed", "blockedBy": ["T7"] }, - { "id": "T10", "subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim", "status": "deferred-live", "blockedBy": ["T6", "T8", "T9"] }, - { "id": "T11", "subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10", "status": "completed", "blockedBy": ["T10"] } + { + "id": "T1", + "subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T2", + "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", + "status": "completed", + "blockedBy": [ + "T1" + ] + }, + { + "id": "T3", + "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", + "status": "completed", + "blockedBy": [ + "T1" + ] + }, + { + "id": "T4", + "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T5", + "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", + "status": "completed", + "blockedBy": [ + "T1", + "T4" + ] + }, + { + "id": "T6", + "subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)", + "status": "completed", + "blockedBy": [ + "T5" + ] + }, + { + "id": "T7", + "subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)", + "status": "completed", + "blockedBy": [ + "T4" + ] + }, + { + "id": "T8", + "subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges", + "status": "completed", + "blockedBy": [ + "T7" + ] + }, + { + "id": "T9", + "subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit \u2014 verified by T10)", + "status": "completed", + "blockedBy": [ + "T7" + ] + }, + { + "id": "T10", + "subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim", + "status": "deferred-live", + "blockedBy": [ + "T6", + "T8", + "T9" + ], + "openNote": "PARTIAL \u2014 the resilience PIPELINE is live-verified (Polly CircuitBreaker -> Retry -> Timeout -> CapabilityInvoker in central-1 stack traces), but breaker-OPEN state was never forced: R2-09's ConnectionBackoff throttles retries so failures don't accumulate fast enough to open the Polly breaker. The reliable trigger is a rapid idempotent write to a dead endpoint. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)." + }, + { + "id": "T11", + "subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10", + "status": "completed", + "blockedBy": [ + "T10" + ] + } ] } diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index f37856f2..cd45d1ef 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -1,30 +1,213 @@ { "planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md", - "lastUpdated": "2026-07-12", + "lastUpdated": "2026-07-27", "tasks": [ - { "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] }, - { "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] }, - { "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] }, - { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] }, - { "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "completed", "blockedBy": ["T1", "T3"] }, - { "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "completed", "blockedBy": ["T5"] }, - { "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "completed", "blockedBy": ["T2", "T3"] }, - { "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "completed", "blockedBy": ["T2", "T3"] }, - { "id": "T10", "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", "status": "completed", "blockedBy": ["T3", "T5"] }, - { "id": "T11", "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", "status": "completed", "blockedBy": [] }, - { "id": "T12", "subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T13", "subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T14", "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T15", "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "completed", "blockedBy": ["T17"] }, - { "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "completed", "blockedBy": [] }, - { "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "completed", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] }, - { "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "completed", "blockedBy": ["T20"] }, - { "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "deferred-live", "blockedBy": [] }, - { "id": "T23", "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", "status": "completed", "blockedBy": ["T17", "T21"] }, - { "id": "T24", "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", "status": "deferred-live", "blockedBy": ["T5", "T6", "T7", "T8", "T9", "T10", "T12", "T13", "T14", "T15", "T16", "T17", "T18", "T19", "T21", "T22", "T23"] } + { + "id": "T1", + "subject": "Golden TagConfig corpus + compose\u2192encode\u2192decode parity characterization test (Runtime.Tests, green on unmodified tree)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T2", + "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T3", + "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T4", + "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T5", + "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", + "status": "completed", + "blockedBy": [ + "T1", + "T3" + ] + }, + { + "id": "T6", + "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost \u2192 DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T7", + "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", + "status": "completed", + "blockedBy": [ + "T5" + ] + }, + { + "id": "T8", + "subject": "DraftValidator swap: Configuration\u2192Commons ProjectReference; ExtractTagConfigFullName \u2192 TagConfigIntent.ExplicitFullName (null-on-absent preserved)", + "status": "completed", + "blockedBy": [ + "T2", + "T3" + ] + }, + { + "id": "T9", + "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", + "status": "completed", + "blockedBy": [ + "T2", + "T3" + ] + }, + { + "id": "T10", + "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", + "status": "completed", + "blockedBy": [ + "T3", + "T5" + ] + }, + { + "id": "T11", + "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T12", + "subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T13", + "subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T14", + "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T15", + "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T16", + "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T17", + "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T18", + "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", + "status": "completed", + "blockedBy": [ + "T17" + ] + }, + { + "id": "T19", + "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T20", + "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", + "status": "completed", + "blockedBy": [ + "T12", + "T13", + "T14", + "T15", + "T16", + "T17" + ] + }, + { + "id": "T21", + "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", + "status": "completed", + "blockedBy": [ + "T20" + ] + }, + { + "id": "T22", + "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", + "status": "completed", + "blockedBy": [], + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 the typed Modbus tag editor shows the Writable toggle and saving persists `writable: yes`." + }, + { + "id": "T23", + "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", + "status": "completed", + "blockedBy": [ + "T17", + "T21" + ] + }, + { + "id": "T24", + "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", + "status": "completed", + "blockedBy": [ + "T5", + "T6", + "T7", + "T8", + "T9", + "T10", + "T12", + "T13", + "T14", + "T15", + "T16", + "T17", + "T18", + "T19", + "T21", + "T22", + "T23" + ], + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 see T22. STATUS.md 'docker-dev live-/run pass'." + } ] } diff --git a/deferment.md b/deferment.md index b10efb62..c3e619af 100644 --- a/deferment.md +++ b/deferment.md @@ -77,7 +77,10 @@ Every gap below is in the **authoring/dispatch layer, not driver runtime code**. - `TagConfigEditorMap.cs:25-28` + `TagConfigValidator.cs:27-28` — "`DriverTypeNames.Sql` deliberately absent until Task 11"; the constant exists (`DriverTypeNames.cs:57`) and the values are identical, so behaviour is correct. - `RawDriverTypeDialog.razor:2-4, 59-60` — "its factory lands in Wave C". - `CsvColumnMap.cs:322-324` — hardcodes `CalculationDriverType = "Calculation"` "Not yet a `DriverTypeNames` constant"; it has been one since `DriverTypeNames.cs:54`. -- `SqlDriver.cs:218-220` — justifies not re-parsing config on the premise "the factory builds a fresh instance", **disproved** by `DriverInstanceActor.cs:316` + `DriverHostActor.cs:2565`. Should be corrected alongside #516. +- ~~`SqlDriver.cs:218-220` — justifies not re-parsing config on the premise "the factory builds a fresh + instance", **disproved** by `DriverInstanceActor.cs:316` + `DriverHostActor.cs:2565`.~~ ✅ **Fixed** — + the comment now says the premise was false when written and is true *now* because a config change + respawns. See §9. --- @@ -342,17 +345,320 @@ appear in §7.1 as well. ## 8. Recommended sequencing -1. **§3.1 ACL enforcement** — decide: wire `IPermissionEvaluator` into `OtOpcUaNodeManager`, or +1. ~~**§3.1 ACL enforcement** — decide: wire `IPermissionEvaluator` into `OtOpcUaNodeManager`, or remove the authoring UI and say plainly that ACLs are not enforced. Either way fix - `docs/ReadWriteOperations.md` first; it is the one that could mislead a security review. -2. **#518 + #507 together** — neither fix is observable alone. -3. **#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes. -4. **G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would - have caught G-1 and G-2 for free, and this class has now recurred twice. -5. **Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan - (§6.2) is visible as the real backlog it is. -6. **§3.3 tier truth** — either pass real tiers at factory registration or delete the Tier-C - machinery; today it is documented, authorable and dormant. + `docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~ + ✅ **Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9. + (`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.) +2. ~~**#518 + #507 together** — neither fix is observable alone.~~ ✅ **Done.** The framing was right + that they had to be handled together, but wrong about the resolution: **#507 was not revivable**, + so it was deleted rather than fixed. See §9. +3. ~~**#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.~~ + ✅ **Done.** See §9. #489 is still open and should now be re-scoped. +4. ~~**G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would + have caught G-1 and G-2 for free, and this class has now recurred twice.~~ ✅ **Done**, together with + G-1, G-2, G-3, G-5 and G-6. See §9. +5. ~~**Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan + (§6.2) is visible as the real backlog it is.~~ ✅ **Done.** See §9. +6. ~~**§3.3 tier truth** — either pass real tiers at factory registration or delete the Tier-C + machinery; today it is documented, authorable and dormant.~~ ✅ **Documented**; the keep-or-delete + code decision is Gitea **#522**. See §9. + +--- + +## 9. Execution log + +Remediation of §8, on branch `feat/deferment-remediation`. Status is updated in the **same commit** as +the work, so this register never disagrees with the tree. + +| §8 item | Status | Commit | +|---|---|---| +| 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` | +| 2. #518 + #507 | ✅ **Done** — `IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` | +| 3. #516 config discard | ✅ **Done** — seam respawn + re-parse on 3 drivers; Sql/FOCAS deliberately respawn-only | `2dc19f30` | +| 4. G-4 dispatch-map parity | ✅ **Done** — G-1…G-6 all closed, live-verified on docker-dev | `abacf4cf` | +| 5. Bookkeeping sweep | ✅ **Done** — 13 plan files closed, 11 archreview gates written back | `03658c2e` | +| 6. Tier truth | ✅ **Done** — docs reconciled; keep-or-delete is Gitea **#522** | `03658c2e` | + +### 2026-07-27 — §8.1 ACL non-enforcement made explicit + +Decision: **do not wire the evaluator, do not delete it** — state plainly that it does not run, and +track the wire-up as **Gitea #520**. + +Applied: + +- `docs/security.md` — the `NodeScope`/`PermissionTrie`/"Dispatch gate" block now leads with a + **What is enforced today** table (the three real checks) before the design material, which is + labelled *Designed but not wired*. +- `docs/ReadWriteOperations.md` — **rewritten**, not bannered. +- `docs/v2/acl-design.md` — ⚠️ NEVER WIRED banner. +- `docs/v2/v2-release-readiness.md` §"Security — Phase 6.2 dispatch wiring" — the CLOSED claim marked + as not holding. +- `ClusterAcls.razor` + `AclEdit.razor` — warning alerts stating rules are stored and deployed but + never evaluated. + +**Four things this pass found that §7 and the original audit missed** — all worse than what was +already recorded: + +1. **`docs/security.md` was the highest-risk doc, not `ReadWriteOperations.md`.** `:115` claimed an + anonymous session is "default-denie[d] any node a session has no ACL grant for". The opposite is + true: an anonymous session can Browse, Read, Subscribe and HistoryRead the **entire** address + space. It is refused only writes and alarm acks — and only because it carries no roles. +2. **Three of the five documented data-plane role strings do nothing.** `OpcUaDataPlaneRoles` declares + exactly `WriteOperate` and `AlarmAck`. `ReadOnly`, `WriteTune` and `WriteConfigure` are compared + against nowhere in `src/`, so mapping a group to `WriteTune` grants nothing and mapping one to + `ReadOnly` restricts nothing. The doc called all five "exact, case-insensitive, and **code-true**". +3. **`ReadWriteOperations.md` was fiction well beyond the ACL claims.** `OnReadValue` has **zero** + occurrences in `src/` — the whole documented read path did not exist. Reads never reach a driver at + all: the node manager is push-model and the SDK serves a client Read from the cached pushed value. + `WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef` and `IRoleBearer` are likewise + zero-hit, and `CapabilityInvoker` is not referenced by the `OpcUaServer` project at all. +4. **`v2-release-readiness.md` claimed a whole enforcement layer shipped.** Beyond the `:41` row §7.1 + already flagged, `:46-50` describe `FilterBrowseReferences`, `GateCallMethodRequests`, + `MapCallOperation`, `AuthorizationBootstrap` and `Node:Authorization:*` config keys as Closed or + Partial. None of them exist. Whatever landed on the v2 branch did not survive into the shipped tree. + +Also recorded in #520 and not previously known: every existing evaluator unit test runs in +`PermissionTrieBuilder`'s no-`scopePaths` *deterministic test mode*, so the production hierarchy path +is effectively untested; and `NodeAcl.ScopeId` has no FK or existence check, so scope ids dangle +silently. + +### 2026-07-27 — §8.2 rediscovery as a signal; injection deleted + +Decision: **signal, not mutation.** + +`DriverInstanceActor` now consumes `IRediscoverable.OnRediscoveryNeeded` (attach in `PreStart`, detach +in `PostStop`) and carries it on the driver-health snapshot to `/hosts` as a **re-browse chip**. It is +advisory — the served address space is unchanged, because v3 authors raw tags through `/raw` +browse-commit and a runtime graft would materialise nodes nobody approved. + +**#507's injection path was deleted, not fixed.** §2 recorded it as a "deferred (deliberate guard)". +That was too generous: its two inputs — `EquipmentNode.DriverInstanceId` and `EquipmentTags` — are +*structurally empty* in v3, so removing the guard would have changed a log line and injected nothing. +Deleted with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`, +`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`, +`AddressSpaceApplier.MaterialiseDiscoveredNodes`, `OpcUaPublishActor.MaterialiseDiscoveredNodes`, the +connect-time discovery loop, and 4 files' worth of tests. `ITagDiscovery` stays — the `/raw` browse +picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through the actor. + +**§4.4's "18 `DiscoveryInjectionDormantV3` — Real, blocked on #507" was wrong.** They were v2 +characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3 +cannot produce. They could never have unskipped as written; they are deleted. Skipped tests in +`Runtime.Tests` went 31 → 13. + +**The planned drift detector was deliberately NOT built.** The plan proposed diffing each connect-time +discovery pass against the authored raw tags. Reading the drivers killed it: **Modbus, S7, MQTT, +AbLegacy and Sql all echo authored config from `DiscoverAsync`** rather than browsing the device, so +the diff is permanently empty for them. It would have been another plausible-looking inert seam — +precisely the class this register exists to remove. That also removed the last consumer of the +connect-time loop, which is why the loop went too: it was browsing real devices up to ~15× per connect +and dropping the result. + +Two traps worth keeping: + +- `PublishHealthSnapshot` dedups on a health fingerprint, and a rediscovery raise changes none of the + four fields it hashed. The timestamp had to join the tuple or the dedup swallows the signal. + **Verified by reverting the one line — 3 of the 5 new tests go red.** +- The shared `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs every call + and the dedup never engages. A dedup test built on it would pass whether or not the fix is present; + the new tests use a stub with stable health. + +`IHostConnectivityProbe` was **not** resolved — it is a keep-or-delete decision, and the +`DriverHostStatus` table was re-created deliberately in the v3 initial migration, so deleting on +inference would be wrong. Split to Gitea **#521** with both options costed. + +### 2026-07-27 — §8.5 bookkeeping + §8.6 tier truth + +**13 plan files closed.** The 8 the register named, plus `mesh-phase4` (Task 9 landed `3a590a0c`), the +4th `stillpending` phase file, and `historian-tcp-transport` closed as **OBSOLETE** (it targets the +retired Wonderware sidecar; there is no Wonderware backend in the tree). Each carries a `closureNote` +naming the evidence, so the next reader can check rather than trust. + +**11 archreview live gates written back** from `STATUS.md`, which had recorded them passed since +2026-07-13/15 without ever updating the `.tasks.json` files: R2-01 #11, R2-02 #15/#18, R2-05 T15, +R2-06 T12, R2-07 T5/T6/T12/T14, R2-11 T22/T24. + +**R2-03 and R2-10 were NOT closed** — and reading `STATUS.md` rather than trusting its own headline +("Every Round-2 live gate is now GREEN") is what kept that honest. Its per-item detail says R2-03 is +"live-blocked on this data-less rig" and R2-10 verified the pipeline but "breaker-OPEN state not +forced". Both now carry an `openNote` recording the blocker: R2-03 needs a reachable driver fixture +feeding a VT; R2-10 needs a rapid idempotent write to a dead endpoint, because R2-09's +`ConnectionBackoff` throttles retries so failures never accumulate fast enough to open the Polly breaker. + +**Net effect (the point of the sweep):** ~140 phantom pending tasks are gone, so the genuinely +unexecuted **19-task AdminUI follow-ups plan** is now the largest open item in `docs/plans/` rather than +being buried. The remaining open plans are 8, and one of those (`mesh-phase6` Task 6) is a documented +`resolved-no-flip` decision rather than work. + +**Tier truth:** `docs/v2/driver-stability.md` now states plainly that its tier table is aspirational — +every driver runs Tier A, and `MemoryRecycle` / `ScheduledRecycleScheduler` have never engaged. It also +corrects a second stale claim the register did not flag: the **separate-Windows-service hosting** it +describes for Tier C no longer exists (Galaxy went to the mxaccessgw sidecar in PR 7.2; FOCAS went +in-process when its managed wire client landed). No runtime change — turning recycle on for two live +drivers deserves its own live gate, not a docs-pass side effect. Tracked as Gitea **#522**. + +### 2026-07-28 — the two things §8.3 deliberately did NOT build (1 of 2) + +**Sql and FOCAS now re-parse in place too.** The first pass excluded them for a real reason — each derives +more than options from config, so adopting new options alone would run a **new tag set against an old +connection/backend**, and a half-applied change is worse than a discarded one because it looks like it +worked. Rather than accept that, the blocker itself is fixed: each factory now exposes a **`ParseBinding`** +that returns *every* config-derived dependency as one value, and the driver adopts them **atomically**. + +| Driver | What one parse now yields | Adopted by | +|---|---|---| +| Sql | options + `ISqlDialect` + resolved connection string | `ApplyBinding`, which also rebuilds the provider factory, `Endpoint` and the `SqlPollReader` that captures all three | +| FOCAS | options + the `IFocasClientFactory` the `Backend` key selects | one assignment pair in `InitializeAsync` | + +Details worth keeping: + +- **Sql adopts BEFORE `BuildTagTable`**, so the tag table and the connection it will be polled over always + come from the same revision. +- **A test-injected `DbProviderFactory` survives a rebind** (`_explicitFactory`), so a re-derived dialect + cannot silently displace what a test passed in. +- **Re-resolving the connection string on reinit is a side benefit**: a rotated credential is picked up + without a process restart. +- A driver constructed **directly** gets no rebinder and keeps its constructor-supplied binding — every + existing `"{}"`-passing lifecycle test is unaffected by construction, not by luck. + +**The tests pin ATOMICITY, not merely "a re-parse happened"** — a test that only checked options would have +passed against the broken version. Verified by simulating the *half-fix* (adopt options, skip the backend): +2 of the 3 FOCAS tests go red. Reverting Sql's rebind turns its connection-string test red. Sql also asserts +that a reinit which **cannot resolve** its new connection leaves the previous binding whole, rather than +half-adopting. + +All 12 drivers now honour a changed config in place; `DriverSpawnPlanner`'s stop + respawn remains the outer +guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass. + +### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2) + +**The discovery-drift detector is built, and gated.** The original objection stands and is now *encoded* +rather than used as a reason not to build: Modbus, S7, MQTT, AbLegacy and Sql echo authored config from +`DiscoverAsync`, so a diff for them is a tautology. The missing piece was a discriminator — and the +codebase already had one. `ITagDiscovery.SupportsOnlineDiscovery` is documented as "enumerates the tag set +from the **live backend** rather than replaying pre-declared/authored tags", and it separates the fleet +cleanly: **FOCAS, TwinCAT, MTConnect, AbCip** true; the five config-echo drivers explicitly false. + +The check runs only for a driver that is `SupportsOnlineDiscovery` **and** reports the new +`AuthoredDiscoveryRefs` (**nullable**, default null = opt out — an *empty* collection legitimately means +"nothing authored, everything discovered is new", and conflating the two would report total drift for a +driver that simply never opted in). + +**Where the value is:** AbCip and FOCAS browse a live backend but implement **no `IRediscoverable`**, so +before this they had *no* change signal at all — a periodic compare is the only way to notice a PLC +re-download. TwinCAT and MTConnect already have native signals (symbol-version, agent instanceId); drift +detection is complementary there. + +**A finding that changed the design.** FOCAS, TwinCAT and AbCip all **re-emit their authored tags into the +same `DiscoverAsync` stream** as the device-derived ones, so the browse picker can show an operator their +existing tags. That means discovered ⊇ authored *always*, and a **vanished** tag can never appear missing +— one whole direction is structurally undetectable for three of the four. Shipping a `Vanished` list that +is permanently empty for them would have been exactly the half-inert seam this register exists to remove. +So the driver declares `DiscoveryStreamIncludesAuthoredTags`, the detector does not compute the +undetectable half, and the operator message **says** "missing-tag detection unavailable for this driver" +rather than letting the absence of a missing-tag clause read as reassurance. **MTConnect is the only +driver where both directions work** — its stream is purely probe-model-derived. + +Behavioural details: a **failed browse is not drift** (an unreachable device would otherwise report every +authored tag as vanished — the health surface already covers unreachability); a **truncated** capture is +skipped for the same reason; an **unchanged** drift is reported once rather than re-stamping its timestamp +every interval; drift that **resolves** clears the prompt so the next one is not deduped against a stale +signature. Interval defaults to 5 minutes — it browses a real device, and a tag set changing is an +engineering event, not a runtime one. + +The comparison is a pure, separately-tested type (`TagSetDriftDetector`) rather than actor-inline logic. +14 new tests. The gate was verified load-bearing by deleting the `SupportsOnlineDiscovery` half — the +tautology-driver test goes red, and it asserts discovery was **never invoked** rather than merely "no +prompt raised", which would have passed for the wrong reason if the timer simply never fired. + +### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6) + +**The maps had to become data before they could be guarded.** A Razor `@switch` compiles into +`BuildRenderTree`'s IL, so nothing can enumerate its cases — that is the whole reason G-1 and G-2 +survived review while the sibling picker guard stayed green (the picker test works only because +`RawDriverTypeDialog` keeps its data in a *field* the markup enumerates). Both modals now render from +`DriverConfigFormMap` / `DeviceFormMap` via ``, and being `public` those maps need +none of the picker test's `BindingFlags.NonPublic` fragility. + +| Gap | Resolution | +|---|---| +| G-1 | `CalculationDriverForm.razor` added — `RunTimeout` was unauthorable through the UI. | +| G-2 | Sql / MTConnect / Calculation are declared **single-connection** rather than given hollow device forms — each holds one connection at the driver level, so a per-device endpoint editor would be meaningless. | +| G-3 | `DriverConfigModal`'s hardcoded `Galaxy or Mqtt` replaced by `DeviceFormMap.IsSingleConnection`. | +| G-4 | `DriverFormMapParityTests` (5 cases, both directions) + `DriverDispatchMapParityTests` (3). | +| G-5 | `CsvColumnMap` entries for Sql, Mqtt, MTConnect. | +| G-6 | `RawBrowseCommitMapper` Sql branch — **and the browser end too**, which the register missed. | + +**G-6 was bigger than filed.** The register said the mapper lacked a Sql branch. It also turned out that +`SqlBrowseSession` emitted **no `AddressFields` at all**, so a browsed leaf carried only a column name — +and a column name alone cannot address a Sql tag, which needs its table. The browser now travels +schema/table/column and the mapper builds a `WideRow` `SqlTagConfigModel` from them. A branch alone +would have produced a half-built blob. + +**Two extra findings while writing the guards:** + +- The G-6 test found **Modbus and Calculation** also fall through to the generic `{"address": …}` key. + Both are correct — neither is browsable — so the test asserts the fall-through set **equals** a + documented non-browsable list, in both directions. A one-way check would let a newly-browsable driver + be quietly added to the exclusion list instead of getting a branch. +- `TagConfigDriverTypeNameGuardTests` was **hollow**: it enumerated a hand-written `TheoryData` that had + already drifted (omitting Galaxy, Sql and Mqtt), so it guarded against renames but not gaps — a new + `DriverTypeNames` constant would simply never appear. Now enumerated from the map, paired with a + coverage test in the other direction. + +**Live-verified on docker-dev** (`localhost:9200`, central pair rebuilt), because this repo has no bUnit +and no unit test can cover Blazor parameter binding: opened Calculation's config — the form renders where +it previously showed "No typed config form" — typed `3500`, saved, reopened, and the value **persisted**, +proving the `DynamicComponent` two-way binding round-trips. Sql's form renders fully (regression check on +the conversion) and now reads "This driver holds a single connection, authored above" (G-3). The rebuilt +image also showed `RediscoveryNeededUtc`/`RediscoveryReason` on the wire, confirming §8.2 end-to-end. + +### 2026-07-27 — §8.3 #516 driver config edits silently discarded + +Decision: **both** — per-driver re-parse *and* the seam respawn. Reading the drivers split the +"per-driver" half in two, which the register did not anticipate: + +- **Re-parse in place** (`Modbus`, `AbLegacy`, `OpcUaClient`) — `ParseOptions` extracted from each + factory, called from `InitializeAsync` behind a `HasConfigBody` guard so `"{}"` still keeps the + constructor options. +- **Respawn-only, deliberately NOT re-parsed** (`Sql`, `FOCAS`) — each builds **more than options** + from config: Sql's `ISqlDialect` + resolved connection string, FOCAS's client-factory backend, both + injected at construction. Adopting new options alone would run a **new tag set against an old + connection**. Half a re-parse is worse than none. Their doc-comments now say so. + +**The seam respawn is the load-bearing half.** `DriverSpawnPlanner` now routes a changed +`DriverConfig` to `ToStop` + `ToSpawn`, making the factory the single parse authority. +`ToApplyDelta` is consequently always empty from the reconcile path. This **reverses the deliberate +decision documented at `DriverSpawnPlan.cs:49-50`** ("a pure DriverConfig change stays an in-place +delta — no reconnect"). That reasoning was right about resilience and wrong about the driver; the +accepted price is a reconnect on every config edit. + +Two seals removed from `ApplyChildDelta`: it overwrote the cached `Spec` **synchronously, before the +child had dequeued the message**, so the host immediately believed the new config was live and the +next reconcile computed no delta — sealing the drift permanently; and it `Tell`d with no +`Receive` registered, so a failed reinit (including Galaxy's deliberate +`NotSupportedException`) dead-lettered. + +**A new visibility gap this change exposed, not created:** a factory throw is a *config* error +(`TryCreate` is pure parsing; device I/O happens later), and `SpawnChild` catches it and silently +substitutes a stub. Previously only a brand-new driver could hit it; now an ordinary config edit can. +Raised from `Warning` to `Error` with an actionable message. It still does **not** fail the +deployment — making it do so would let one malformed driver block a fleet deploy, so that is a +deliberate follow-up rather than a drive-by change. + +**Tests.** Every pre-existing reinit test in the five suites passes `"{}"` — precisely the input a +guarded re-parser treats as "keep the constructor options", so they were blind to this defect *by +construction*. The new tests pass **changed** JSON. The Modbus one was verified falsifiable by +deleting the re-parse line (goes red). Two existing tests asserted the old behaviour and were +rewritten: `DriverSpawnPlannerTests` (two cases), and +`DriverHostActorUnreadableArtifactTests.Dropping_a_drivers_last_tag_does_clear_its_subscription` — +a **positive control** for a sibling absence assertion, whose observable moved from `UnsubscribeAsync` +to `ShutdownAsync` because the teardown now happens by stopping the child rather than emptying its +desired set. Same event, different route; the control still calibrates the settle window. + +Full-solution run: only pre-existing environment failures remain — `Host.IntegrationTests` (3, +verified identical on the pre-change tree) and `Driver.AbLegacy.IntegrationTests` (4, docker +fixture-gated, and notably these **fail rather than skip**, unlike every other fixture-gated suite). --- diff --git a/docs/ReadWriteOperations.md b/docs/ReadWriteOperations.md index bf050fb0..2aea0c28 100644 --- a/docs/ReadWriteOperations.md +++ b/docs/ReadWriteOperations.md @@ -1,85 +1,120 @@ # Read/Write Operations -> ⚠️ **Accuracy warning (audited 2026-07-27).** Parts of this page describe v2-era machinery that no -> longer exists. Two corrections matter most: -> -> 1. **There is no per-node ACL gate on the read path — or anywhere else.** `WriteAuthzPolicy`, -> `AuthorizationGate`, `NodeScopeResolver` and `AuthorizationBootstrap` have **zero occurrences in -> `src/`**. The ACL evaluator that *does* exist (`IPermissionEvaluator` / `TriePermissionEvaluator` -> / `PermissionTrieCache`, `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/`) has **no production -> consumer** — `OtOpcUaNodeManager` never references it — even though `ClusterAcls.razor` lets -> operators author `NodeAcl` rows and `ConfigComposer.cs:51` ships them in every deployment -> artifact. Actual enforcement today is LDAP role mapping plus the realm-qualified `WriteOperate` -> check in `OtOpcUaNodeManager`, which is a **write** gate; **reads are ungated**. See -> `deferment.md` §3.1. -> 2. **`GenericDriverNodeManager` is not a production dispatch path** — it is Core test scaffolding -> with zero production references (`GenericDriverNodeManager.cs:71`). The live server materialises -> the address space through `AddressSpaceComposer` / `AddressSpaceApplier`. -> -> The `CapabilityInvoker` / Polly / `OnReadValue` / `OnWriteValue` mechanics below remain accurate. +> **Rewritten 2026-07-27** against `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`. +> The previous revision described a v2-era `DriverNodeManager` with per-variable `OnReadValue` hooks, +> an `AuthorizationGate`/`WriteAuthzPolicy` ACL pair and a `NodeSourceKind` dispatch switch. **None of +> those exist** — `OnReadValue`, `WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef` +> and `IRoleBearer` all have zero occurrences in `src/`. The read path in particular worked nothing +> like the way it was documented. See `deferment.md` §3.1 and §7. -The v2 server routes OPC UA Read and Write operations to each driver's `IReadable` and `IWritable` capabilities through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers. The per-variable `OnReadValue` and `OnWriteValue` hooks described in the sections below live in `DriverNodeManager` (the planned ADR-002 Phase 7 Stream G successor to the v1 `DriverNodeManager`); `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) handles address-space population and alarm routing during discovery. The current `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) is a push-model `CustomNodeManager2` that receives values from the Akka actor layer via `WriteValue`; OPC UA client reads return the cached pushed value. +## The shape of it: push for reads, pull for writes -## Driver vs virtual dispatch +The live server is `OtOpcUaNodeManager`, a **push-model** `CustomNodeManager2`. This asymmetry is the +single most important thing on this page: -Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), a single `DriverNodeManager` routes reads and writes across both driver-sourced and virtual (scripted) tags. At discovery time each variable registers a `NodeSourceKind` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`) in the manager's `_sourceByFullRef` lookup; the read/write hooks pattern-match on that value to pick the backend: +- **Reads never reach a driver.** There is no `Read` override and no `OnReadValue` / `OnSimpleReadValue` + handler anywhere in the node manager. Driver values are *pushed in* from the Akka actor layer + (`DriverInstanceActor` polls or subscribes, `DriverHostActor` fans the value to the raw NodeId and + every referencing UNS NodeId), and a client Read is served by the OPC UA SDK straight from the + cached node value. A client read therefore costs nothing on the wire to the device, and cannot fail + with a device error — it returns whatever quality was last pushed. +- **Writes are synchronous pull-through.** A client write runs `OnWriteValue` → role gate → driver. -- `NodeSourceKind.Driver` — dispatches to the driver's `IReadable` / `IWritable` through `CapabilityInvoker` (the rest of this doc). -- `NodeSourceKind.Virtual` — dispatches to `VirtualTagSource` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs`), which wraps `VirtualTagEngine`. Writes are rejected with `BadUserAccessDenied` before the branch per Phase 7 decision #6 — scripts are the only write path into virtual tags. -- `NodeSourceKind.ScriptedAlarm` — dispatches to the Phase 7 `ScriptedAlarmReadable` shim. +Everything the old page said about `CapabilityInvoker` wrapping OPC UA reads was misplaced: the +invoker is real, but it lives on the **driver-actor** side wrapping the poll/subscribe calls. The +`OpcUaServer` project does not reference it at all. -~~ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch, so the gates below apply uniformly to all three source kinds.~~ **Not true** — neither type exists; there is no per-node ACL gate. The only authorization applied before the source branch is the LDAP-role write gate (`WriteOperate` / `WriteTune` / `WriteConfigure`, realm-qualified, fail-closed). See the banner at the top of this page. +## Read path -## OnReadValue +1. The SDK resolves the NodeId in the Raw (`ns=2`) or UNS (`ns=3`) namespace. +2. It returns the cached `DataValue` — value, `StatusCode` and source timestamp as last pushed. +3. **No authorization check runs.** Not a per-node ACL, not a role check, nothing. Any admitted + session — including an Anonymous one — can read every node in the address space. The only gating is + the `AccessLevels` bitmask set at materialization, which is a per-node *capability* declaration, not + a per-user decision. -The hook is registered on every `BaseDataVariableState` created by the `IAddressSpaceBuilder.Variable(...)` call during discovery. When the stack dispatches a Read for a node in this namespace: +Authored `NodeAcl` deny rules have **no effect on reads** (or on anything else — see +[docs/security.md](security.md) § Data-Plane Authorization). -1. If the driver does not implement `IReadable`, the hook returns `BadNotReadable`. -2. The node's `NodeId.Identifier` is used directly as the driver-side full reference — it matches `DriverAttributeInfo.FullName` registered at discovery time. -3. ~~(Phase 6.2) If an `AuthorizationGate` + `NodeScopeResolver` are wired, the gate is consulted first via `IsAllowed(identity, OpcUaOperation.Read, scope)`. A denied read never hits the driver.~~ **Never shipped** — neither type exists in `src/`, and no ACL gate is consulted on the read path. Reads are authorized only by the `AccessLevels` bits set at materialization (and `HistoryRead` for history). Authored `NodeAcl` deny rules have **no effect on reads**. -4. The call is wrapped by `_invoker.ExecuteAsync(DriverCapability.Read, ResolveHostFor(fullRef), …)`. The resolved host is `IPerCallHostResolver.ResolveHost(fullRef)` for multi-host drivers; single-host drivers fall back to `DriverInstanceId` (decision #144). -5. The first `DataValueSnapshot` from the batch populates the outgoing `value` / `statusCode` / `timestamp`. An empty batch surfaces `BadNoData`; any exception surfaces `BadInternalError`. +## Write path — `OnEquipmentTagWrite` -The hook is synchronous — the async invoker call is bridged with `AsTask().GetAwaiter().GetResult()` because the OPC UA SDK's value-hook signature is sync. Idempotent-by-construction reads mean this bridge is safe to retry inside the Polly pipeline. +The handler is attached in `EnsureVariable` **only when the tag was materialized `writable`**, and +re-attached or cleared by `UpdateTagAttributes` on an in-place edit. A non-writable node has no +handler, so the SDK rejects the write before any of this runs. -## OnWriteValue +1. **Role gate.** `EvaluateEquipmentWriteGate(identity, gatewayWired)` requires the session identity to + be a `RoleCarryingUserIdentity` carrying the `WriteOperate` role. No identity or no role ⇒ + `BadUserAccessDenied`, fail-closed. Gate passed but no write gateway wired (admin-only node, + pre-boot) ⇒ `BadNotWritable`. + - This is a **server-wide** check. It takes no node and no realm: a session that may write one tag + may write every writable tag on that node. + - `WriteTune` and `WriteConfigure` are **never checked**. `OpcUaDataPlaneRoles` declares only + `WriteOperate` and `AlarmAck`; the classification tiers exist in the permission vocabulary but no + code path reads them. +2. **Optimistic local apply.** The new value is written to the node so subscribers see it immediately. +3. **Realm-qualified dispatch.** `RealmOf(node.NodeId)` selects Raw or UNS, and the value routes to the + backing driver ref through the node write gateway. Both NodeIds for a fanned value resolve to the + same driver ref, so a write via either one reaches the same device point. +4. **Write-outcome self-correction.** If the device write fails, the optimistic value is **reverted** + on the raw NodeId and every referencing UNS NodeId through the shared fan-out, so a failed write + cannot leave a phantom Good value behind. (Galaxy is the exception: its write is fire-and-forget, so + it can never surface a write failure.) -`OnWriteValue` follows the same shape with two additional concerns: authorization and idempotence. +`OnWriteValue` is invoked by the SDK **while holding the node-manager `Lock`**, so the handler must not +block. Anything asynchronous is dispatched fire-and-forget with the revert wired as a continuation. -### Authorization (two layers) +## Alarm method calls -1. **SecurityClassification gate.** Every variable stores its `SecurityClassification` in `_securityByFullRef` at registration time (populated from `DriverAttributeInfo.SecurityClass`). `WriteAuthzPolicy.IsAllowed(classification, userRoles)` runs first, consulting the session's roles via `context.UserIdentity is IRoleBearer`. `FreeAccess` passes anonymously, `ViewOnly` denies everyone, and `Operate / Tune / Configure / SecuredWrite / VerifiedWrite` require `WriteOperate / WriteTune / WriteConfigure` roles respectively. Denial returns `BadUserAccessDenied` without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (see `docs/security.md`). -2. **Phase 6.2 permission-trie gate.** When `AuthorizationGate` is wired, it re-runs with the operation derived from `WriteAuthzPolicy.ToOpcUaOperation(classification)`. The gate consults the per-cluster permission trie loaded from `NodeAcl` rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See `docs/v2/acl-design.md`. - -### Dispatch - -`_invoker.ExecuteWriteAsync(host, isIdempotent, callSite, …)` honors the `WriteIdempotentAttribute` semantics per decisions #44-45 and #143: - -- `isIdempotent = true` (tag flagged `WriteIdempotent` in the Config DB) → runs through the standard `DriverCapability.Write` pipeline; retry may apply per the tier configuration. -- `isIdempotent = false` (default) → the invoker builds a one-off pipeline with `RetryCount = 0`. A timeout may fire after the device already accepted the pulse / alarm-ack / counter-increment; replay is the caller's decision, not the server's. - -The `_writeIdempotentByFullRef` lookup is populated at discovery time from the `DriverAttributeInfo.WriteIdempotent` field. - -### Per-write status - -`IWritable.WriteAsync` returns `IReadOnlyList` — one numeric `StatusCode` per requested write. A non-zero code is surfaced directly to the client; exceptions become `BadInternalError`. The OPC UA stack's pattern of batching per-service is preserved through the full chain. - -## Array element writes - -Array-element writes via OPC UA `IndexRange` are driver-specific. The OPC UA stack hands the dispatch an unwrapped `NumericRange` on the `indexRange` parameter of `OnWriteValue`; `DriverNodeManager` passes the full `value` object to `IWritable.WriteAsync` and the driver decides whether to support partial writes. Galaxy performs a read-modify-write inside the Galaxy driver (MXAccess has no element-level writes); other drivers generally accept only full-array writes today. +Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) +are gated by a second role check requiring `AlarmAck` — one role covering all five methods; the +`operation` argument is passed to the router but the gate does not consult it. Scripted alarms go +through `HandleAlarmCommand`, driver-fed native alarms through `HandleNativeAlarmAck`; both fail closed +with `BadUserAccessDenied`. ## HistoryRead -`DriverNodeManager.HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`, and `HistoryReadEvents` route through the driver's `IHistoryProvider` capability with `DriverCapability.HistoryRead`. Drivers without `IHistoryProvider` surface `BadHistoryOperationUnsupported` per node. See `docs/v1/HistoricalDataAccess.md`. +Four overrides — `HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`, +`HistoryReadEvents` — route to the **server-wide** `IHistorianDataSource` (the HistorianGateway-backed +reader, or `NullHistorianDataSource` when `ServerHistorian:Enabled=false`, which returns `GoodNoData`). +This is not a per-driver `IHistoryProvider` dispatch. + +**No authorization check runs on any of the four.** Unlike `OnWriteValue`, the SDK does *not* hold the +node-manager `Lock` while invoking them, so these paths may await. + +See [docs/Historian.md](Historian.md). ## Failure isolation -Per decision #12, exceptions in the driver's capability call are logged and converted to a per-node `BadInternalError` — they never unwind into the master node manager. This keeps one driver's outage from disrupting sibling drivers in the same server process. +Exceptions in a driver capability call are logged and converted to a per-node bad status rather than +unwinding into the master node manager, so one driver's outage cannot disrupt sibling drivers in the +same process. + +## What is *not* enforced anywhere + +Collected here because the previous revision of this page claimed several of these: + +| Surface | Check | +|---|---| +| Read, Browse, TranslateBrowsePaths | none | +| CreateMonitoredItems / Subscribe / TransferSubscriptions | none | +| HistoryRead (all four variants) | none | +| Non-Value attribute writes | none (the handler is on `OnWriteValue`) | +| Value write | `WriteOperate` role, server-wide | +| Alarm methods | `AlarmAck` role, server-wide | +| Per-node ACLs (`NodeAcl` / `PermissionTrie`) | **authored and deployed, never evaluated** | ## Key source files -- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — address-space population and alarm routing during discovery -- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — push-model `CustomNodeManager2`; `EnsureVariable` / `WriteValue` are the v2 read/write path -- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — permission trie + evaluator (`PermissionTrie`, `PermissionTrieCache`, `TriePermissionEvaluator`) that gates Read/Write/Subscribe per the session's resolved LDAP groups -- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — `ExecuteAsync` / `ExecuteWriteAsync` -- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs`, `IWritable.cs`, `WriteIdempotentAttribute.cs` +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — the live node manager; + `EnsureVariable` / `WriteValue` / `OnEquipmentTagWrite` / `EvaluateEquipmentWriteGate` and the four + HistoryRead overrides +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`, + `Security/OpcUaDataPlaneRoles.cs` — how roles reach the gates +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — materialization; where the write + handler is attached per realm +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — the push side: value fan-out to + raw + UNS NodeIds +- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — driver-side resilience pipeline + (**not** on the OPC UA read path) +- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — the permission trie + evaluator, built and + unit-tested but with **zero production consumers** diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md index 14def81a..4807d4fb 100644 --- a/docs/drivers/Galaxy.md +++ b/docs/drivers/Galaxy.md @@ -70,7 +70,7 @@ Project root files: | Capability | Implementation entry point | |------------|---------------------------| | `ITagDiscovery` | `Browse/GalaxyDiscoverer.cs` | -| `IRediscoverable` | `Browse/DeployWatcher.cs` — ⚠️ raised but **unconsumed**; a Galaxy redeploy does not rebuild the address space ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518), [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)) | +| `IRediscoverable` | `Browse/DeployWatcher.cs` — consumed as an **operator prompt**: a Galaxy redeploy raises a "re-browse" chip on `/hosts`. ⚠️ It does **not** rebuild the address space — re-browse the device under `/raw` and commit ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)) | | `IReadable` | `Runtime/GalaxyMxSession.cs` | | `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` | | `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (driven by `EventPump`) | diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index f1f15662..e694b5eb 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -220,8 +220,9 @@ what the driver's cursor expects) is detected **two ways**, both triggering a **before** the sequence-gap check (a restart also usually trips the gap, so the two need disambiguating, not just OR-ing together). On a changed `instanceId` the driver clears its cached probe model and raises -`OnRediscoveryNeeded` — see [Known limitations](#known-limitations) for why -that signal currently has no consumer. +`OnRediscoveryNeeded`, which surfaces a **re-browse prompt** on the AdminUI +`/hosts` page — see [Known limitations](#known-limitations) for what that does +and does not do. ## Browse — free via the universal browser @@ -249,16 +250,20 @@ need re-authoring. See [Deferred](#deferred-not-in-this-build). These are real, not placeholders — read them before relying on the driver for anything beyond values-and-conditions. -1. **`IRediscoverable` and `IHostConnectivityProbe` have no consumer in the - server.** `DriverInstanceActor` wires only `ISubscribable.OnDataChange` and - `IAlarmSource.OnAlarmEvent`; nothing in the server subscribes to - `OnRediscoveryNeeded` or `OnHostStatusChanged` except `GalaxyDriver` - wiring its own internal `DeployWatcher` sub-component. So a restarted - Agent (a changed `instanceId`) leaves a stale address space behind an - otherwise-Healthy driver. **This is a pre-existing fleet-wide gap - affecting every driver that implements either interface** (eight drivers - besides Galaxy), not something specific to MTConnect — this build simply - surfaced it again. +1. **A restarted Agent prompts an operator; it does not re-shape the address + space.** `DriverInstanceActor` now consumes `OnRediscoveryNeeded` + (2026-07-27), so a changed `instanceId` raises a "re-browse" chip against + this driver on `/hosts` carrying the reason. It is **advisory**: v3 authors + raw tags through the `/raw` browse-commit flow, so until an operator + re-browses the Agent and commits, any DataItem that appeared or vanished is + not reflected in the served tree. A runtime graft was deliberately rejected + — it would materialise nodes nobody approved and no deployment artifact + records. + + **`IHostConnectivityProbe` is still unconsumed** — `GetHostStatuses()` has + no production call site and nothing writes a `DriverHostStatus` row. That + half remains a fleet-wide gap affecting every driver that implements it + (Gitea #521). 2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for routing.** One driver instance owns exactly one Agent client scoped by `MTConnectDriverOptions.DeviceName` (the top-level config key); the diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md index 252157fd..081f8752 100644 --- a/docs/drivers/TwinCAT.md +++ b/docs/drivers/TwinCAT.md @@ -155,7 +155,7 @@ fixture is down during normal dev. **Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router). **Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional -arrays, per-element historization. `IRediscoverable` still fires (but is unconsumed — #518) +arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518) on PLC re-download (unchanged semantics from scalar nodes). See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix diff --git a/docs/plans/2026-06-12-historian-tcp-transport.md.tasks.json b/docs/plans/2026-06-12-historian-tcp-transport.md.tasks.json index 54e08321..84dc0d91 100644 --- a/docs/plans/2026-06-12-historian-tcp-transport.md.tasks.json +++ b/docs/plans/2026-06-12-historian-tcp-transport.md.tasks.json @@ -1,18 +1,116 @@ { "planPath": "docs/plans/2026-06-12-historian-tcp-transport.md", "tasks": [ - {"id": 0, "nativeTaskId": 296, "subject": "Task 0: Create feature branch", "status": "pending"}, - {"id": 1, "nativeTaskId": 297, "subject": "Task 1: Add TCP/TLS fields to client options", "status": "pending", "blockedBy": [0]}, - {"id": 2, "nativeTaskId": 298, "subject": "Task 2: Client TCP connect factory + FrameChannel rename", "status": "pending", "blockedBy": [1]}, - {"id": 3, "nativeTaskId": 299, "subject": "Task 3: Switch client default ctor to TCP", "status": "pending", "blockedBy": [2]}, - {"id": 4, "nativeTaskId": 300, "subject": "Task 4: Host config binding (Host/Port/TLS)", "status": "pending", "blockedBy": [1]}, - {"id": 5, "nativeTaskId": 301, "subject": "Task 5: Sidecar TcpFrameServer", "status": "pending", "blockedBy": [0]}, - {"id": 6, "nativeTaskId": 302, "subject": "Task 6: Sidecar Program.cs — TCP bootstrap + env", "status": "pending", "blockedBy": [5]}, - {"id": 7, "nativeTaskId": 303, "subject": "Task 7: Remove dead pipe code + finalize options shape", "status": "pending", "blockedBy": [3, 4, 6]}, - {"id": 8, "nativeTaskId": 304, "subject": "Task 8: Deploy scripts — env block + firewall + cert", "status": "pending", "blockedBy": [7]}, - {"id": 9, "nativeTaskId": 305, "subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS", "status": "pending", "blockedBy": [7]}, - {"id": 10, "nativeTaskId": 306, "subject": "Task 10: Docs — TCP transport", "status": "pending", "blockedBy": [7]}, - {"id": 11, "nativeTaskId": 307, "subject": "Task 11: Verification (build + test + live)", "status": "pending", "blockedBy": [8, 9, 10]} + { + "id": 0, + "nativeTaskId": 296, + "subject": "Task 0: Create feature branch", + "status": "completed" + }, + { + "id": 1, + "nativeTaskId": 297, + "subject": "Task 1: Add TCP/TLS fields to client options", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "nativeTaskId": 298, + "subject": "Task 2: Client TCP connect factory + FrameChannel rename", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "nativeTaskId": 299, + "subject": "Task 3: Switch client default ctor to TCP", + "status": "completed", + "blockedBy": [ + 2 + ] + }, + { + "id": 4, + "nativeTaskId": 300, + "subject": "Task 4: Host config binding (Host/Port/TLS)", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 5, + "nativeTaskId": 301, + "subject": "Task 5: Sidecar TcpFrameServer", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 6, + "nativeTaskId": 302, + "subject": "Task 6: Sidecar Program.cs \u2014 TCP bootstrap + env", + "status": "completed", + "blockedBy": [ + 5 + ] + }, + { + "id": 7, + "nativeTaskId": 303, + "subject": "Task 7: Remove dead pipe code + finalize options shape", + "status": "completed", + "blockedBy": [ + 3, + 4, + 6 + ] + }, + { + "id": 8, + "nativeTaskId": 304, + "subject": "Task 8: Deploy scripts \u2014 env block + firewall + cert", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 9, + "nativeTaskId": 305, + "subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 10, + "nativeTaskId": 306, + "subject": "Task 10: Docs \u2014 TCP transport", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 11, + "nativeTaskId": 307, + "subject": "Task 11: Verification (build + test + live)", + "status": "completed", + "blockedBy": [ + 8, + 9, + 10 + ] + } ], - "lastUpdated": "2026-06-12" + "lastUpdated": "2026-07-27", + "closureNote": "OBSOLETE \u2014 targets the bespoke Wonderware TCP/ArchestrA historian sidecar, retired when HistorianGateway became the sole historian backend. There is no Wonderware backend in the tree. Closed as obsolete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json b/docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json index 229b845a..9da566b6 100644 --- a/docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json +++ b/docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json @@ -1,18 +1,111 @@ { "planPath": "docs/plans/2026-06-15-stillpending-phase-0-1.md", "tasks": [ - {"id": 402, "subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1", "status": "pending"}, - {"id": 403, "subject": "SP Task 1: Phase 0 — correct 7 stale code comments", "status": "pending", "blockedBy": [402]}, - {"id": 404, "subject": "SP Task 2: Phase 0 — fix docs/security.md + benign-residue comments", "status": "pending", "blockedBy": [402]}, - {"id": 405, "subject": "SP Task 3: Phase 0 — mark shipped .tasks.json completed", "status": "pending", "blockedBy": [402]}, - {"id": 406, "subject": "SP Task 4: Phase 1 H1a — Phase7Applier rebuilds on Changed*", "status": "pending", "blockedBy": [402]}, - {"id": 407, "subject": "SP Task 5: Phase 1 H1b — VirtualTagHostActor respawns changed child", "status": "pending", "blockedBy": [402]}, - {"id": 408, "subject": "SP Task 6: Phase 1 H5a — EquipmentVirtualTagPlan.Historize + composer", "status": "pending", "blockedBy": [402]}, - {"id": 409, "subject": "SP Task 7: Phase 1 H5b — DeploymentArtifact decodes Historize (byte-parity)", "status": "pending", "blockedBy": [408]}, - {"id": 410, "subject": "SP Task 8: Phase 1 H5c — VirtualTagHostActor invokes IHistoryWriter", "status": "pending", "blockedBy": [407, 409]}, - {"id": 411, "subject": "SP Task 9: Phase 1 H5d — thread IHistoryWriter through DriverHostActor + DI", "status": "pending", "blockedBy": [410, 403]}, - {"id": 412, "subject": "SP Task 10: Phase 1 — docs + follow-up bookkeeping", "status": "pending", "blockedBy": [411]}, - {"id": 413, "subject": "SP Task 11: Phase 0+1 — full build + test + integration review", "status": "pending", "blockedBy": [403, 404, 405, 406, 407, 408, 409, 410, 411, 412]} + { + "id": 402, + "subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1", + "status": "completed" + }, + { + "id": 403, + "subject": "SP Task 1: Phase 0 \u2014 correct 7 stale code comments", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 404, + "subject": "SP Task 2: Phase 0 \u2014 fix docs/security.md + benign-residue comments", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 405, + "subject": "SP Task 3: Phase 0 \u2014 mark shipped .tasks.json completed", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 406, + "subject": "SP Task 4: Phase 1 H1a \u2014 Phase7Applier rebuilds on Changed*", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 407, + "subject": "SP Task 5: Phase 1 H1b \u2014 VirtualTagHostActor respawns changed child", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 408, + "subject": "SP Task 6: Phase 1 H5a \u2014 EquipmentVirtualTagPlan.Historize + composer", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 409, + "subject": "SP Task 7: Phase 1 H5b \u2014 DeploymentArtifact decodes Historize (byte-parity)", + "status": "completed", + "blockedBy": [ + 408 + ] + }, + { + "id": 410, + "subject": "SP Task 8: Phase 1 H5c \u2014 VirtualTagHostActor invokes IHistoryWriter", + "status": "completed", + "blockedBy": [ + 407, + 409 + ] + }, + { + "id": 411, + "subject": "SP Task 9: Phase 1 H5d \u2014 thread IHistoryWriter through DriverHostActor + DI", + "status": "completed", + "blockedBy": [ + 410, + 403 + ] + }, + { + "id": 412, + "subject": "SP Task 10: Phase 1 \u2014 docs + follow-up bookkeeping", + "status": "completed", + "blockedBy": [ + 411 + ] + }, + { + "id": 413, + "subject": "SP Task 11: Phase 0+1 \u2014 full build + test + integration review", + "status": "completed", + "blockedBy": [ + 403, + 404, + 405, + 406, + 407, + 408, + 409, + 410, + 411, + 412 + ] + } ], - "lastUpdated": "2026-06-15" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md.tasks.json b/docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md.tasks.json index e7774c5e..c8c51972 100644 --- a/docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md.tasks.json +++ b/docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md.tasks.json @@ -2,15 +2,72 @@ "planPath": "docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md", "branch": "feat/stillpending-phase-2-servicelevel", "tasks": [ - {"id": 414, "subject": "P2 Task 1: Move ServiceLevelCalculator to Core.Cluster", "status": "pending"}, - {"id": 415, "subject": "P2 Task 2a: OpcUaPublishActor calculator path (DB+stale+leader+Detached guard, legacy seam)", "status": "pending", "blockedBy": [414]}, - {"id": 416, "subject": "P2 Task 2b: OpcUaProbeOk from peer-probes-me (freshness + debounce)", "status": "pending", "blockedBy": [415]}, - {"id": 417, "subject": "P2 Task 3: HealthTick — periodic DB Ask/PipeTo + PreStart immediate refresh", "status": "pending", "blockedBy": [416]}, - {"id": 418, "subject": "P2 Task 4: PeerProbeSupervisor — one peer probe per driver peer", "status": "pending"}, - {"id": 419, "subject": "P2 Task 5: Wire WithOtOpcUaRuntimeActors (dbHealth ref + spawn supervisor)", "status": "pending", "blockedBy": [417, 418]}, - {"id": 420, "subject": "P2 Task 6: docs/Redundancy.md — calculator is WIRED", "status": "pending"}, - {"id": 421, "subject": "P2 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [419, 420]}, - {"id": 422, "subject": "P2 Task 8: Live /run on the 2-node rig (acceptance gate)", "status": "pending", "blockedBy": [421]} + { + "id": 414, + "subject": "P2 Task 1: Move ServiceLevelCalculator to Core.Cluster", + "status": "completed" + }, + { + "id": 415, + "subject": "P2 Task 2a: OpcUaPublishActor calculator path (DB+stale+leader+Detached guard, legacy seam)", + "status": "completed", + "blockedBy": [ + 414 + ] + }, + { + "id": 416, + "subject": "P2 Task 2b: OpcUaProbeOk from peer-probes-me (freshness + debounce)", + "status": "completed", + "blockedBy": [ + 415 + ] + }, + { + "id": 417, + "subject": "P2 Task 3: HealthTick \u2014 periodic DB Ask/PipeTo + PreStart immediate refresh", + "status": "completed", + "blockedBy": [ + 416 + ] + }, + { + "id": 418, + "subject": "P2 Task 4: PeerProbeSupervisor \u2014 one peer probe per driver peer", + "status": "completed" + }, + { + "id": 419, + "subject": "P2 Task 5: Wire WithOtOpcUaRuntimeActors (dbHealth ref + spawn supervisor)", + "status": "completed", + "blockedBy": [ + 417, + 418 + ] + }, + { + "id": 420, + "subject": "P2 Task 6: docs/Redundancy.md \u2014 calculator is WIRED", + "status": "completed" + }, + { + "id": 421, + "subject": "P2 Task 7: Full build + test + final integration review", + "status": "completed", + "blockedBy": [ + 419, + 420 + ] + }, + { + "id": 422, + "subject": "P2 Task 8: Live /run on the 2-node rig (acceptance gate)", + "status": "completed", + "blockedBy": [ + 421 + ] + } ], - "lastUpdated": "2026-06-15" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md.tasks.json b/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md.tasks.json index 60bf2d05..3c96da0d 100644 --- a/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md.tasks.json +++ b/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md.tasks.json @@ -2,16 +2,80 @@ "planPath": "docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md", "branch": "feat/stillpending-phase-3-opcua-standards", "tasks": [ - {"id": 423, "subject": "P3 Task 1: H2-bit — NodePermissions.HistoryUpdate + evaluator mapping", "status": "pending"}, - {"id": 424, "subject": "P3 Task 2: H6a — mark native conditions (isNative through the sink)", "status": "pending"}, - {"id": 425, "subject": "P3 Task 3: H4 — wire OnEnableDisable over OPC UA", "status": "pending", "blockedBy": [424]}, - {"id": 426, "subject": "P3 Task 4: H6b — AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource", "status": "pending"}, - {"id": 427, "subject": "P3 Task 5: H6c — NativeAlarmAckRouter seam + native OnAcknowledge routing", "status": "pending", "blockedBy": [424]}, - {"id": 428, "subject": "P3 Task 6: H6d — DriverHostActor inverse map + native-ack route to driver", "status": "pending", "blockedBy": [426, 427]}, - {"id": 429, "subject": "P3 Task 7: H6e — wire NativeAlarmAckRouter in host DI", "status": "pending", "blockedBy": [427, 428]}, - {"id": 430, "subject": "P3 Task 8: Docs — Enable/Disable + native-ack→AVEVA + HistoryUpdate bit", "status": "pending"}, - {"id": 431, "subject": "P3 Task 9: Full build + test + final integration review", "status": "pending", "blockedBy": [423, 425, 429, 430]}, - {"id": 432, "subject": "P3 Task 10: Live /run — H4 Enable/Disable + H6 native-ack route", "status": "pending", "blockedBy": [431]} + { + "id": 423, + "subject": "P3 Task 1: H2-bit \u2014 NodePermissions.HistoryUpdate + evaluator mapping", + "status": "completed" + }, + { + "id": 424, + "subject": "P3 Task 2: H6a \u2014 mark native conditions (isNative through the sink)", + "status": "completed" + }, + { + "id": 425, + "subject": "P3 Task 3: H4 \u2014 wire OnEnableDisable over OPC UA", + "status": "completed", + "blockedBy": [ + 424 + ] + }, + { + "id": 426, + "subject": "P3 Task 4: H6b \u2014 AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource", + "status": "completed" + }, + { + "id": 427, + "subject": "P3 Task 5: H6c \u2014 NativeAlarmAckRouter seam + native OnAcknowledge routing", + "status": "completed", + "blockedBy": [ + 424 + ] + }, + { + "id": 428, + "subject": "P3 Task 6: H6d \u2014 DriverHostActor inverse map + native-ack route to driver", + "status": "completed", + "blockedBy": [ + 426, + 427 + ] + }, + { + "id": 429, + "subject": "P3 Task 7: H6e \u2014 wire NativeAlarmAckRouter in host DI", + "status": "completed", + "blockedBy": [ + 427, + 428 + ] + }, + { + "id": 430, + "subject": "P3 Task 8: Docs \u2014 Enable/Disable + native-ack\u2192AVEVA + HistoryUpdate bit", + "status": "completed" + }, + { + "id": 431, + "subject": "P3 Task 9: Full build + test + final integration review", + "status": "completed", + "blockedBy": [ + 423, + 425, + 429, + 430 + ] + }, + { + "id": 432, + "subject": "P3 Task 10: Live /run \u2014 H4 Enable/Disable + H6 native-ack route", + "status": "completed", + "blockedBy": [ + 431 + ] + } ], - "lastUpdated": "2026-06-15" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md.tasks.json b/docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md.tasks.json index bc15b3cd..249f63ff 100644 --- a/docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md.tasks.json +++ b/docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md.tasks.json @@ -2,14 +2,68 @@ "planPath": "docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md", "branch": "feat/stillpending-phase-4-driver-datatypes", "tasks": [ - {"id": 433, "subject": "P4 Task 1: Modbus Int64/UInt64 node DataType (MapDataType split)", "status": "pending"}, - {"id": 434, "subject": "P4 Task 2: FOCAS fail-fast factory (EnsureUsable at init)", "status": "pending"}, - {"id": 435, "subject": "P4 Task 3: FOCAS position scaling (PositionDecimalPlaces)", "status": "pending", "blockedBy": [434]}, - {"id": 436, "subject": "P4 Task 4: Historian Total aggregate (client-side Average x interval-seconds)", "status": "pending"}, - {"id": 437, "subject": "P4 Task 5: Historian poison-event dead-letter cap (maxAttempts)", "status": "pending"}, - {"id": 438, "subject": "P4 Task 6: Docs + bookkeeping", "status": "pending", "blockedBy": [433, 434, 435, 436, 437]}, - {"id": 439, "subject": "P4 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [433, 434, 435, 436, 437, 438]}, - {"id": 440, "subject": "P4 Task 8: Live /run — Modbus Int64 (acceptance gate)", "status": "pending", "blockedBy": [439]} + { + "id": 433, + "subject": "P4 Task 1: Modbus Int64/UInt64 node DataType (MapDataType split)", + "status": "completed" + }, + { + "id": 434, + "subject": "P4 Task 2: FOCAS fail-fast factory (EnsureUsable at init)", + "status": "completed" + }, + { + "id": 435, + "subject": "P4 Task 3: FOCAS position scaling (PositionDecimalPlaces)", + "status": "completed", + "blockedBy": [ + 434 + ] + }, + { + "id": 436, + "subject": "P4 Task 4: Historian Total aggregate (client-side Average x interval-seconds)", + "status": "completed" + }, + { + "id": 437, + "subject": "P4 Task 5: Historian poison-event dead-letter cap (maxAttempts)", + "status": "completed" + }, + { + "id": 438, + "subject": "P4 Task 6: Docs + bookkeeping", + "status": "completed", + "blockedBy": [ + 433, + 434, + 435, + 436, + 437 + ] + }, + { + "id": 439, + "subject": "P4 Task 7: Full build + test + final integration review", + "status": "completed", + "blockedBy": [ + 433, + 434, + 435, + 436, + 437, + 438 + ] + }, + { + "id": 440, + "subject": "P4 Task 8: Live /run \u2014 Modbus Int64 (acceptance gate)", + "status": "completed", + "blockedBy": [ + 439 + ] + } ], - "lastUpdated": "2026-06-16" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps.md.tasks.json b/docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps.md.tasks.json index c1ba5a15..5b99ede0 100644 --- a/docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps.md.tasks.json +++ b/docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps.md.tasks.json @@ -4,16 +4,61 @@ "designCommit": "f90017bc", "baseMaster": "c081917a", "branch": "feat/stillpending-phase-4b-driver-gaps (merged to master 08a65513, deleted)", - "executionState": "COMPLETE — shipped + pushed to master 08a65513 (ff). Final integration review = SHIP. Build clean; AdminUI 450, Galaxy 279(+3 skip), FOCAS 200, Modbus 277, DriverProbeRegistration 2 — all green. Live: Modbus typed editor + Build-address proven on the rig's seeded MAIN-modbus-eq (Modbus reconcile, conclusive); Galaxy browse rendered a nested hierarchical root via the real driver path (corroborated, unit-proven 8 ways); FOCAS unit-proven (no CNC).", - "nativeTaskIds": {"1": 482, "2": 483, "3a": 484, "3b": 485, "4": 486, "5": 487, "6": 488}, + "executionState": "COMPLETE \u2014 shipped + pushed to master 08a65513 (ff). Final integration review = SHIP. Build clean; AdminUI 450, Galaxy 279(+3 skip), FOCAS 200, Modbus 277, DriverProbeRegistration 2 \u2014 all green. Live: Modbus typed editor + Build-address proven on the rig's seeded MAIN-modbus-eq (Modbus reconcile, conclusive); Galaxy browse rendered a nested hierarchical root via the real driver path (corroborated, unit-proven 8 ways); FOCAS unit-proven (no CNC).", + "nativeTaskIds": { + "1": 482, + "2": 483, + "3a": 484, + "3b": 485, + "4": 486, + "5": 487, + "6": 488 + }, "tasks": [ - {"id": 1, "subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")", "status": "completed", "commit": "8b4675b1", "reviewFixCommit": "a40c77de"}, - {"id": 2, "subject": "Task 2: Galaxy nested gobject hierarchy", "status": "completed", "commit": "21c7645d", "reviewFixCommit": "bec37848"}, - {"id": "3a", "subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)", "status": "completed", "commit": "3fcbc70c"}, - {"id": "3b", "subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)", "status": "completed", "commit": "6855be28"}, - {"id": 4, "subject": "Task 4: Docs + bookkeeping", "status": "completed", "commit": "08a65513"}, - {"id": 5, "subject": "Task 5: Full build + test + final integration review", "status": "completed", "note": "final integration review = SHIP (no Critical/Important); 3 Minor non-blocking notes recorded"}, - {"id": 6, "subject": "Task 6: Live /run verification", "status": "completed", "note": "Modbus typed editor live-proven on rig; Galaxy nested root corroborated; FOCAS unit-proven"} + { + "id": 1, + "subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")", + "status": "completed", + "commit": "8b4675b1", + "reviewFixCommit": "a40c77de" + }, + { + "id": 2, + "subject": "Task 2: Galaxy nested gobject hierarchy", + "status": "completed", + "commit": "21c7645d", + "reviewFixCommit": "bec37848" + }, + { + "id": "3a", + "subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)", + "status": "completed", + "commit": "3fcbc70c" + }, + { + "id": "3b", + "subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)", + "status": "completed", + "commit": "6855be28" + }, + { + "id": 4, + "subject": "Task 4: Docs + bookkeeping", + "status": "completed", + "commit": "08a65513" + }, + { + "id": 5, + "subject": "Task 5: Full build + test + final integration review", + "status": "completed", + "note": "final integration review = SHIP (no Critical/Important); 3 Minor non-blocking notes recorded" + }, + { + "id": 6, + "subject": "Task 6: Live /run verification", + "status": "completed", + "note": "Modbus typed editor live-proven on rig; Galaxy nested root corroborated; FOCAS unit-proven" + } ], "reviewFollowUps": [ "FOCAS minor: no upper-bound clamp on a misbehaving cnc_getfigure value (Math.Pow overflow; same latent risk the manual path had)", @@ -21,5 +66,6 @@ "FOCAS: live auto-fetch on the real backend needs a cnc_getfigure command added to the managed FocasWireClient wire protocol (WireFocasClient returns empty today)", "Galaxy minor: 3-node chain cycle provably-correct but not directly tested (2-node mutual cycle is tested)" ], - "lastUpdated": "2026-06-16" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-16-stillpending-phase-4c-array-support.md.tasks.json b/docs/plans/2026-06-16-stillpending-phase-4c-array-support.md.tasks.json index 89ce9ad1..24a808cc 100644 --- a/docs/plans/2026-06-16-stillpending-phase-4c-array-support.md.tasks.json +++ b/docs/plans/2026-06-16-stillpending-phase-4c-array-support.md.tasks.json @@ -4,28 +4,121 @@ "designCommit": "efccd8d1", "baseMaster": "050164b2", "branch": "feat/stillpending-phase-4c-array-support (merged to master 0f92e9e2, deleted)", - "executionState": "COMPLETE — shipped + pushed to master 0f92e9e2 (ff, 23 commits). Big-bang all 5 drivers + full AbLegacy. Build clean; OpcUaServer 230, Runtime 272, AdminUI 472, Modbus 289, AbCip 292, TwinCAT 153, S7 143, AbLegacy 188 — all green. Bundle integration review caught the cross-driver isArray-gating divergence (C-1/C-2 Modbus+AbLegacy ignored isArray; I-1/I-2/I-3 the arrayLength=1 edge); final integration review = SHIP WITH MINOR FOLLOW-UPS (I-1 S7 clarity, I-2 AbCip degenerate-scalar, I-3 AbLegacy >256 author-validation, M-1 array nodes read-only) — all closed. LIVE /run PASS: a Modbus array equipment tag (HR[0..3], UInt16[4]) materialises as ValueRank=OneDimension/ArrayDimensions=[4] and a Good array value flows end-to-end from the live sim (read over the wire, Status 0x00000000, Value System.UInt16[]); the address-100 first attempt correctly surfaced the sim's Illegal-Data-Address as Bad (faithful device status). S7/AbCip/TwinCAT/AbLegacy unit-proven (fixtures down).", + "executionState": "COMPLETE \u2014 shipped + pushed to master 0f92e9e2 (ff, 23 commits). Big-bang all 5 drivers + full AbLegacy. Build clean; OpcUaServer 230, Runtime 272, AdminUI 472, Modbus 289, AbCip 292, TwinCAT 153, S7 143, AbLegacy 188 \u2014 all green. Bundle integration review caught the cross-driver isArray-gating divergence (C-1/C-2 Modbus+AbLegacy ignored isArray; I-1/I-2/I-3 the arrayLength=1 edge); final integration review = SHIP WITH MINOR FOLLOW-UPS (I-1 S7 clarity, I-2 AbCip degenerate-scalar, I-3 AbLegacy >256 author-validation, M-1 array nodes read-only) \u2014 all closed. LIVE /run PASS: a Modbus array equipment tag (HR[0..3], UInt16[4]) materialises as ValueRank=OneDimension/ArrayDimensions=[4] and a Good array value flows end-to-end from the live sim (read over the wire, Status 0x00000000, Value System.UInt16[]); the address-100 first attempt correctly surfaced the sim's Illegal-Data-Address as Bad (faithful device status). S7/AbCip/TwinCAT/AbLegacy unit-proven (fixtures down).", "scope": "Big-bang all 5 drivers (AskUserQuestion) + full AbLegacy array read (AskUserQuestion). 1-D read-surface only; array writes / multi-dim / array historization out of scope (array nodes forced read-only at the applier per review M-1).", - "nativeTaskIds": {"1": 495, "2": 496, "3": 497, "4": 498, "5": 499, "6": 500, "7": 501, "8": 502, "9": 503, "10": 504, "11": 505, "12": 506}, + "nativeTaskIds": { + "1": 495, + "2": 496, + "3": 497, + "4": 498, + "5": 499, + "6": 500, + "7": 501, + "8": 502, + "9": 503, + "10": 504, + "11": 505, + "12": 506 + }, "tasks": [ - {"id": 1, "subject": "Sink contract — EnsureVariable array params", "classification": "high-risk", "status": "completed", "commit": "a7928202", "reviewFixCommit": "3172b7bd"}, - {"id": 2, "subject": "EquipmentTagPlan IsArray/ArrayLength + composer + applier", "classification": "high-risk", "status": "completed", "commit": "71cc4171", "reviewFixCommit": "584e9f2a"}, - {"id": 3, "subject": "DeploymentArtifact decode byte-parity", "classification": "high-risk", "status": "completed", "commit": "0a747c34", "reviewFixCommit": "eb8a8dc1"}, - {"id": 4, "subject": "AdminUI driver-agnostic isArray/arrayLength control", "classification": "standard", "status": "completed", "commit": "c2006dfb"}, - {"id": 5, "subject": "Modbus String/BitInRegister array decode + resolver", "classification": "small", "status": "completed", "commit": "8d3dc321", "reviewFixCommit": "49ac1392"}, - {"id": 6, "subject": "AbCip libplctag array read + IsArray", "classification": "standard", "status": "completed", "commit": "f4d5a5ee", "reviewFixCommit": "94e8c55b+5f7a2acd"}, - {"id": 7, "subject": "TwinCAT ADS array read + IsArray (reference impl, no fix needed)", "classification": "standard", "status": "completed", "commit": "3e742395"}, - {"id": 8, "subject": "S7 block-read array + decode loop + IsArray", "classification": "high-risk", "status": "completed", "commit": "a82c22c6", "reviewFixCommit": "3bbe39c1+d30fb77e"}, - {"id": 9, "subject": "AbLegacy PCCC multi-element array read + IsArray", "classification": "high-risk", "status": "completed", "commit": "95006939", "reviewFixCommit": "ce5d46be+0f92e9e2"}, - {"id": 10, "subject": "Docs + bookkeeping", "classification": "small", "status": "completed", "commit": "05c7e86f"}, - {"id": 11, "subject": "Full build + test + final integration review", "classification": "standard", "status": "completed", "note": "final integration review = SHIP WITH MINOR FOLLOW-UPS; 4 review fixes applied (d30fb77e/5f7a2acd/0f92e9e2/3bb2031d)"}, - {"id": 12, "subject": "Live /run acceptance + finish branch", "classification": "standard", "status": "completed", "note": "Modbus array tag live-proven (Good UInt16[4] from sim HR[0..3], over the wire); others unit-proven; root-caused the address-100 Bad as a sim Illegal-Data-Address (not a code bug)"} + { + "id": 1, + "subject": "Sink contract \u2014 EnsureVariable array params", + "classification": "high-risk", + "status": "completed", + "commit": "a7928202", + "reviewFixCommit": "3172b7bd" + }, + { + "id": 2, + "subject": "EquipmentTagPlan IsArray/ArrayLength + composer + applier", + "classification": "high-risk", + "status": "completed", + "commit": "71cc4171", + "reviewFixCommit": "584e9f2a" + }, + { + "id": 3, + "subject": "DeploymentArtifact decode byte-parity", + "classification": "high-risk", + "status": "completed", + "commit": "0a747c34", + "reviewFixCommit": "eb8a8dc1" + }, + { + "id": 4, + "subject": "AdminUI driver-agnostic isArray/arrayLength control", + "classification": "standard", + "status": "completed", + "commit": "c2006dfb" + }, + { + "id": 5, + "subject": "Modbus String/BitInRegister array decode + resolver", + "classification": "small", + "status": "completed", + "commit": "8d3dc321", + "reviewFixCommit": "49ac1392" + }, + { + "id": 6, + "subject": "AbCip libplctag array read + IsArray", + "classification": "standard", + "status": "completed", + "commit": "f4d5a5ee", + "reviewFixCommit": "94e8c55b+5f7a2acd" + }, + { + "id": 7, + "subject": "TwinCAT ADS array read + IsArray (reference impl, no fix needed)", + "classification": "standard", + "status": "completed", + "commit": "3e742395" + }, + { + "id": 8, + "subject": "S7 block-read array + decode loop + IsArray", + "classification": "high-risk", + "status": "completed", + "commit": "a82c22c6", + "reviewFixCommit": "3bbe39c1+d30fb77e" + }, + { + "id": 9, + "subject": "AbLegacy PCCC multi-element array read + IsArray", + "classification": "high-risk", + "status": "completed", + "commit": "95006939", + "reviewFixCommit": "ce5d46be+0f92e9e2" + }, + { + "id": 10, + "subject": "Docs + bookkeeping", + "classification": "small", + "status": "completed", + "commit": "05c7e86f" + }, + { + "id": 11, + "subject": "Full build + test + final integration review", + "classification": "standard", + "status": "completed", + "note": "final integration review = SHIP WITH MINOR FOLLOW-UPS; 4 review fixes applied (d30fb77e/5f7a2acd/0f92e9e2/3bb2031d)" + }, + { + "id": 12, + "subject": "Live /run acceptance + finish branch", + "classification": "standard", + "status": "completed", + "note": "Modbus array tag live-proven (Good UInt16[4] from sim HR[0..3], over the wire); others unit-proven; root-caused the address-100 Bad as a sim Illegal-Data-Address (not a code bug)" + } ], "reviewFollowUps": [ - "Foundation array test asserts in-process variable.Value only — add a wire-level Session.ReadValueAsync assertion over an array node (the live /run proved it works, but it's not unit-covered)", - "Client.CLI read/subscribe prints an array value's type name (System.UInt16[]) not its elements — add array element formatting", + "Foundation array test asserts in-process variable.Value only \u2014 add a wire-level Session.ReadValueAsync assertion over an array node (the live /run proved it works, but it's not unit-covered)", + "Client.CLI read/subscribe prints an array value's type name (System.UInt16[]) not its elements \u2014 add array element formatting", "Array WRITES (inbound client->device), multi-dimensional arrays, array historization remain out of scope (named deferrals)", "Live array read for S7/AbCip/TwinCAT/AbLegacy is fixture-gated/operator-gated (unit-proven only); the 5 AbLegacy libplctag PCCC array-read assumptions need a real SLC/MicroLogix to confirm" ], - "lastUpdated": "2026-06-16" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-26-otopcua-historian-gateway-integration.md.tasks.json b/docs/plans/2026-06-26-otopcua-historian-gateway-integration.md.tasks.json index 753a5a51..169b5632 100644 --- a/docs/plans/2026-06-26-otopcua-historian-gateway-integration.md.tasks.json +++ b/docs/plans/2026-06-26-otopcua-historian-gateway-integration.md.tasks.json @@ -1,27 +1,185 @@ { "planPath": "docs/plans/2026-06-26-otopcua-historian-gateway-integration.md", "tasks": [ - { "id": 0, "subject": "Task 1: Consume gateway packages + scaffold Gateway driver project", "status": "pending", "blockedBy": [] }, - { "id": 1, "subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)", "status": "pending", "blockedBy": [0] }, - { "id": 2, "subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)", "status": "pending", "blockedBy": [0] }, - { "id": 3, "subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper", "status": "pending", "blockedBy": [0] }, - { "id": 4, "subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)", "status": "pending", "blockedBy": [0] }, - { "id": 5, "subject": "Task 6: AlarmHistorianEvent->HistorianEvent mapper (SendEvent)", "status": "pending", "blockedBy": [0] }, - { "id": 6, "subject": "Task 7: GatewayHistorianDataSource read paths (raw/processed/at-time)", "status": "pending", "blockedBy": [1, 3] }, - { "id": 7, "subject": "Task 8: GetHealthSnapshot via Probe/GetConnectionStatus", "status": "pending", "blockedBy": [6] }, - { "id": 8, "subject": "Task 9: Reshape ServerHistorianOptions to gateway form", "status": "pending", "blockedBy": [0] }, - { "id": 9, "subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)", "status": "pending", "blockedBy": [6, 8] }, - { "id": 10, "subject": "Task 11: ReadEventsAsync alarm-history on the data source", "status": "pending", "blockedBy": [6, 4] }, - { "id": 11, "subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)", "status": "pending", "blockedBy": [9, 5] }, - { "id": 12, "subject": "Task 13: Swap AddAlarmHistorian factory in Program.cs", "status": "pending", "blockedBy": [11] }, - { "id": 13, "subject": "Task 14: IHistorianProvisioning + GatewayTagProvisioner (EnsureTags)", "status": "pending", "blockedBy": [9, 2] }, - { "id": 14, "subject": "Task 15: Hook provisioning into AddressSpaceApplier.Apply()", "status": "pending", "blockedBy": [13] }, - { "id": 15, "subject": "Task 16: FasterLog historization outbox store", "status": "pending", "blockedBy": [9] }, - { "id": 16, "subject": "Task 17: ContinuousHistorizationRecorder actor", "status": "pending", "blockedBy": [15, 9] }, - { "id": 17, "subject": "Task 18: Wire recorder into DI + hosted lifecycle", "status": "pending", "blockedBy": [16] }, - { "id": 18, "subject": "Task 19: Retire Wonderware historian projects", "status": "pending", "blockedBy": [9, 12, 17, 19] }, - { "id": 19, "subject": "Task 20: Env-gated live validation vs wonder-sql-vd03", "status": "pending", "blockedBy": [9, 10, 12, 17] }, - { "id": 20, "subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)", "status": "pending", "blockedBy": [18] } + { + "id": 0, + "subject": "Task 1: Consume gateway packages + scaffold Gateway driver project", + "status": "completed", + "blockedBy": [] + }, + { + "id": 1, + "subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 3, + "subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 4, + "subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 5, + "subject": "Task 6: AlarmHistorianEvent->HistorianEvent mapper (SendEvent)", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 6, + "subject": "Task 7: GatewayHistorianDataSource read paths (raw/processed/at-time)", + "status": "completed", + "blockedBy": [ + 1, + 3 + ] + }, + { + "id": 7, + "subject": "Task 8: GetHealthSnapshot via Probe/GetConnectionStatus", + "status": "completed", + "blockedBy": [ + 6 + ] + }, + { + "id": 8, + "subject": "Task 9: Reshape ServerHistorianOptions to gateway form", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 9, + "subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)", + "status": "completed", + "blockedBy": [ + 6, + 8 + ] + }, + { + "id": 10, + "subject": "Task 11: ReadEventsAsync alarm-history on the data source", + "status": "completed", + "blockedBy": [ + 6, + 4 + ] + }, + { + "id": 11, + "subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)", + "status": "completed", + "blockedBy": [ + 9, + 5 + ] + }, + { + "id": 12, + "subject": "Task 13: Swap AddAlarmHistorian factory in Program.cs", + "status": "completed", + "blockedBy": [ + 11 + ] + }, + { + "id": 13, + "subject": "Task 14: IHistorianProvisioning + GatewayTagProvisioner (EnsureTags)", + "status": "completed", + "blockedBy": [ + 9, + 2 + ] + }, + { + "id": 14, + "subject": "Task 15: Hook provisioning into AddressSpaceApplier.Apply()", + "status": "completed", + "blockedBy": [ + 13 + ] + }, + { + "id": 15, + "subject": "Task 16: FasterLog historization outbox store", + "status": "completed", + "blockedBy": [ + 9 + ] + }, + { + "id": 16, + "subject": "Task 17: ContinuousHistorizationRecorder actor", + "status": "completed", + "blockedBy": [ + 15, + 9 + ] + }, + { + "id": 17, + "subject": "Task 18: Wire recorder into DI + hosted lifecycle", + "status": "completed", + "blockedBy": [ + 16 + ] + }, + { + "id": 18, + "subject": "Task 19: Retire Wonderware historian projects", + "status": "completed", + "blockedBy": [ + 9, + 12, + 17, + 19 + ] + }, + { + "id": 19, + "subject": "Task 20: Env-gated live validation vs wonder-sql-vd03", + "status": "completed", + "blockedBy": [ + 9, + 10, + 12, + 17 + ] + }, + { + "id": 20, + "subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)", + "status": "completed", + "blockedBy": [ + 18 + ] + } ], - "lastUpdated": "2026-06-26" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped as PR #423 and live-validated against the gateway on wonder-sql-vd03. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-22-per-cluster-mesh-program.md.tasks.json b/docs/plans/2026-07-22-per-cluster-mesh-program.md.tasks.json index b78ab816..0c788a89 100644 --- a/docs/plans/2026-07-22-per-cluster-mesh-program.md.tasks.json +++ b/docs/plans/2026-07-22-per-cluster-mesh-program.md.tasks.json @@ -2,14 +2,69 @@ "planPath": "docs/plans/2026-07-22-per-cluster-mesh-program.md", "note": "PROGRAM plan: each task = write that phase's detailed plan (writing-plans), execute it (executing-plans), run its exit gate, update the tracking tables. Prereq task 0 is the separate selfform-fallback plan in this repo.", "tasks": [ - {"id": 0, "subject": "Prereq: execute 2026-07-22-selfform-fallback-and-manual-failover.md (7 tasks)", "status": "completed", "note": "Shipped as self-first seed ordering + AkkaClusterOptionsValidator, NOT the planned SelfFormAfter watchdog (live gate caught it islanding a failed-over node). Merged a78425ea."}, - {"id": 1, "subject": "Phase 1: ClusterNode Akka+gRPC address columns; ConfigPublishCoordinator ack set from DB", "status": "completed", "note": "Merged 7654f24d. Escape hatch is ClusterNode.MaintenanceMode, not Enabled=0."}, - {"id": 2, "subject": "Phase 2: comm actors + receptionist + ClusterClient transport (deploy notify/acks, driver-control)", "status": "in_progress", "blockedBy": [1]}, - {"id": 3, "subject": "Phase 3: config fetch-and-cache from central; LocalDb steady-state config store (live gate)", "status": "pending", "blockedBy": [2]}, - {"id": 4, "subject": "Phase 4: cut driver-side ConfigDb — EfAlarmConditionStateStore to LocalDb, DbHealthProbeActor ServiceLevel input, OpcUaPublishActor audit (live gate)", "status": "pending", "blockedBy": [3]}, - {"id": 5, "subject": "Phase 5: gRPC oneof stream contract; migrate 7 observability topics; AdminUI reconnect story", "status": "pending", "blockedBy": [2]}, - {"id": 6, "subject": "Phase 6: mesh partition — per-pair seeds, cluster-scoped roles/singletons, co-location rig rewrite, secrets replication re-scope", "status": "pending", "blockedBy": [0, 4, 5]}, - {"id": 7, "subject": "Phase 7: failover drill per pair (incl. shared-VM failure) + close auto-down 1v1 and self-first cold-start-alone live gates + operator runbook", "status": "pending", "blockedBy": [6]} + { + "id": 0, + "subject": "Prereq: execute 2026-07-22-selfform-fallback-and-manual-failover.md (7 tasks)", + "status": "completed", + "note": "Shipped as self-first seed ordering + AkkaClusterOptionsValidator, NOT the planned SelfFormAfter watchdog (live gate caught it islanding a failed-over node). Merged a78425ea." + }, + { + "id": 1, + "subject": "Phase 1: ClusterNode Akka+gRPC address columns; ConfigPublishCoordinator ack set from DB", + "status": "completed", + "note": "Merged 7654f24d. Escape hatch is ClusterNode.MaintenanceMode, not Enabled=0." + }, + { + "id": 2, + "subject": "Phase 2: comm actors + receptionist + ClusterClient transport (deploy notify/acks, driver-control)", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Phase 3: config fetch-and-cache from central; LocalDb steady-state config store (live gate)", + "status": "completed", + "blockedBy": [ + 2 + ] + }, + { + "id": 4, + "subject": "Phase 4: cut driver-side ConfigDb \u2014 EfAlarmConditionStateStore to LocalDb, DbHealthProbeActor ServiceLevel input, OpcUaPublishActor audit (live gate)", + "status": "completed", + "blockedBy": [ + 3 + ] + }, + { + "id": 5, + "subject": "Phase 5: gRPC oneof stream contract; migrate 7 observability topics; AdminUI reconnect story", + "status": "completed", + "blockedBy": [ + 2 + ] + }, + { + "id": 6, + "subject": "Phase 6: mesh partition \u2014 per-pair seeds, cluster-scoped roles/singletons, co-location rig rewrite, secrets replication re-scope", + "status": "completed", + "blockedBy": [ + 0, + 4, + 5 + ] + }, + { + "id": 7, + "subject": "Phase 7: failover drill per pair (incl. shared-VM failure) + close auto-down 1v1 and self-first cold-start-alone live gates + operator runbook", + "status": "completed", + "blockedBy": [ + 6 + ] + } ], - "lastUpdated": "2026-07-22T00:00:00Z" + "lastUpdated": "2026-07-27", + "closureNote": "Program COMPLETE (Phases 0a-7 + bootstrap guard), origin/master d1dac87f. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json b/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json index 20e1ce31..29114768 100644 --- a/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json +++ b/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json @@ -100,7 +100,7 @@ { "id": 9, "subject": "Task 9: Drop dead ConfigDb ScriptedAlarmState table + retire EfAlarmConditionStateStore", - "status": "deferred", + "status": "completed", "blockedBy": [ 6 ], @@ -128,5 +128,6 @@ "result": "LIVE GATE PASSED \u2014 deploy sealed green w/ 4 DB-less site nodes acking; ServiceLevel 240 w/ central SQL down; restart booted last-known-good from LocalDb pointer; grep proof; alarm_condition_state table live+replicated. Doc: 2026-07-23-mesh-phase4-live-gate.md" } ], - "lastUpdated": "2026-07-23" + "lastUpdated": "2026-07-27", + "closureNote": "Task 9 landed 3a590a0c; live gate PASSED 1281aebf. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-24-modbus-rtu-driver.md.tasks.json b/docs/plans/2026-07-24-modbus-rtu-driver.md.tasks.json index dc74d3de..a17f041c 100644 --- a/docs/plans/2026-07-24-modbus-rtu-driver.md.tasks.json +++ b/docs/plans/2026-07-24-modbus-rtu-driver.md.tasks.json @@ -1,17 +1,91 @@ { "planPath": "docs/plans/2026-07-24-modbus-rtu-driver.md", "tasks": [ - {"id": 0, "subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)", "status": "pending"}, - {"id": 1, "subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors", "status": "pending"}, - {"id": 2, "subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe", "status": "pending", "blockedBy": [1]}, - {"id": 3, "subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)", "status": "pending"}, - {"id": 4, "subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)", "status": "pending", "blockedBy": [2, 3]}, - {"id": 5, "subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode", "status": "pending", "blockedBy": [0, 4]}, - {"id": 6, "subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)", "status": "pending", "blockedBy": [0]}, - {"id": 7, "subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory", "status": "pending", "blockedBy": [5, 6]}, - {"id": 8, "subject": "Task 8: AdminUI Transport selector on ModbusDriverForm", "status": "pending", "blockedBy": [6]}, - {"id": 9, "subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test", "status": "pending", "blockedBy": [4, 7]}, - {"id": 10, "subject": "Task 10: Live /run verification on docker-dev", "status": "pending", "blockedBy": [8, 9]} + { + "id": 0, + "subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)", + "status": "completed" + }, + { + "id": 1, + "subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors", + "status": "completed" + }, + { + "id": 2, + "subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)", + "status": "completed" + }, + { + "id": 4, + "subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)", + "status": "completed", + "blockedBy": [ + 2, + 3 + ] + }, + { + "id": 5, + "subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode", + "status": "completed", + "blockedBy": [ + 0, + 4 + ] + }, + { + "id": 6, + "subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 7, + "subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory", + "status": "completed", + "blockedBy": [ + 5, + 6 + ] + }, + { + "id": 8, + "subject": "Task 8: AdminUI Transport selector on ModbusDriverForm", + "status": "completed", + "blockedBy": [ + 6 + ] + }, + { + "id": 9, + "subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test", + "status": "completed", + "blockedBy": [ + 4, + 7 + ] + }, + { + "id": 10, + "subject": "Task 10: Live /run verification on docker-dev", + "status": "completed", + "blockedBy": [ + 8, + 9 + ] + } ], - "lastUpdated": "2026-07-24" + "lastUpdated": "2026-07-27", + "closureNote": "Merged 0f38f486 (#495); live gate PASSED. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-24-mqtt-sparkplug-driver.md.tasks.json b/docs/plans/2026-07-24-mqtt-sparkplug-driver.md.tasks.json index 2abe8b56..3c0e831a 100644 --- a/docs/plans/2026-07-24-mqtt-sparkplug-driver.md.tasks.json +++ b/docs/plans/2026-07-24-mqtt-sparkplug-driver.md.tasks.json @@ -1,34 +1,315 @@ { "planPath": "docs/plans/2026-07-24-mqtt-sparkplug-driver.md", - "note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet — its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).", + "note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet \u2014 its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).", "tasks": [ - {"id": 0, "subject": "Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build under central pinning", "status": "pending", "classification": "standard", "parallelizableWith": []}, - {"id": 1, "subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [0]}, - {"id": 2, "subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]}, - {"id": 3, "subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]}, - {"id": 4, "subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [3]}, - {"id": 5, "subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)", "status": "pending", "classification": "small", "parallelizableWith": [6], "blockedBy": [3]}, - {"id": 6, "subject": "Task 6 (P1): ISubscribable plain topic subscribe→OnDataChange + retained seed", "status": "pending", "classification": "standard", "parallelizableWith": [5], "blockedBy": [2, 4]}, - {"id": 7, "subject": "Task 7 (P1): MqttDriver shell — IDriver + authored-only ITagDiscovery (Once) + probe interface", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [5, 6]}, - {"id": 8, "subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake", "status": "pending", "classification": "small", "parallelizableWith": [10], "blockedBy": [3]}, - {"id": 9, "subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [7, 8]}, - {"id": 10, "subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)", "status": "pending", "classification": "standard", "parallelizableWith": [8], "blockedBy": [2]}, - {"id": 11, "subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)", "status": "pending", "classification": "trivial", "parallelizableWith": [12], "blockedBy": [10]}, - {"id": 12, "subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)", "status": "pending", "classification": "standard", "parallelizableWith": [11], "blockedBy": [2]}, - {"id": 13, "subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [9]}, - {"id": 14, "subject": "Task 14 (P1): Live /run verify on docker-dev — P1 MILESTONE COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [9, 11, 12, 13]}, - {"id": 15, "subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [14]}, - {"id": 16, "subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors", "status": "pending", "classification": "standard", "parallelizableWith": [17], "blockedBy": [15]}, - {"id": 17, "subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map", "status": "pending", "classification": "small", "parallelizableWith": [16], "blockedBy": [15]}, - {"id": 18, "subject": "Task 18 (P2): BirthCache + AliasTable — bind-by-name, rebuild-per-birth", "status": "pending", "classification": "high-risk", "parallelizableWith": [19], "blockedBy": [16, 17]}, - {"id": 19, "subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie", "status": "pending", "classification": "high-risk", "parallelizableWith": [18], "blockedBy": [16, 17]}, - {"id": 20, "subject": "Task 20 (P2): RebirthRequester NCMD encode", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [16]}, - {"id": 21, "subject": "Task 21 (P2): Sparkplug ingest state machine — the 3.6 matrix", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [18, 19, 20]}, - {"id": 22, "subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21]}, - {"id": 23, "subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync", "status": "pending", "classification": "standard", "parallelizableWith": [24], "blockedBy": [20, 21]}, - {"id": 24, "subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator", "status": "pending", "classification": "small", "parallelizableWith": [23], "blockedBy": [12, 17]}, - {"id": 25, "subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21, 22, 23]}, - {"id": 26, "subject": "Task 26 (P2): Live /run verify Sparkplug — P2 COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [23, 24, 25]} + { + "id": 0, + "subject": "Task 0 (P1): Dependency-validation spike \u2014 MQTTnet-5 pin + net10 restore/build under central pinning", + "status": "completed", + "classification": "standard", + "parallelizableWith": [] + }, + { + "id": 1, + "subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)", + "status": "completed", + "classification": "small", + "parallelizableWith": [], + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 1 + ] + }, + { + "id": 4, + "subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)", + "status": "completed", + "classification": "high-risk", + "parallelizableWith": [], + "blockedBy": [ + 3 + ] + }, + { + "id": 5, + "subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)", + "status": "completed", + "classification": "small", + "parallelizableWith": [ + 6 + ], + "blockedBy": [ + 3 + ] + }, + { + "id": 6, + "subject": "Task 6 (P1): ISubscribable plain topic subscribe\u2192OnDataChange + retained seed", + "status": "completed", + "classification": "standard", + "parallelizableWith": [ + 5 + ], + "blockedBy": [ + 2, + 4 + ] + }, + { + "id": 7, + "subject": "Task 7 (P1): MqttDriver shell \u2014 IDriver + authored-only ITagDiscovery (Once) + probe interface", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 5, + 6 + ] + }, + { + "id": 8, + "subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake", + "status": "completed", + "classification": "small", + "parallelizableWith": [ + 10 + ], + "blockedBy": [ + 3 + ] + }, + { + "id": 9, + "subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 7, + 8 + ] + }, + { + "id": 10, + "subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)", + "status": "completed", + "classification": "standard", + "parallelizableWith": [ + 8 + ], + "blockedBy": [ + 2 + ] + }, + { + "id": 11, + "subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)", + "status": "completed", + "classification": "trivial", + "parallelizableWith": [ + 12 + ], + "blockedBy": [ + 10 + ] + }, + { + "id": 12, + "subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)", + "status": "completed", + "classification": "standard", + "parallelizableWith": [ + 11 + ], + "blockedBy": [ + 2 + ] + }, + { + "id": 13, + "subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 9 + ] + }, + { + "id": 14, + "subject": "Task 14 (P1): Live /run verify on docker-dev \u2014 P1 MILESTONE COMPLETE", + "status": "completed", + "classification": "small", + "parallelizableWith": [], + "blockedBy": [ + 9, + 11, + 12, + 13 + ] + }, + { + "id": 15, + "subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 14 + ] + }, + { + "id": 16, + "subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors", + "status": "completed", + "classification": "standard", + "parallelizableWith": [ + 17 + ], + "blockedBy": [ + 15 + ] + }, + { + "id": 17, + "subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map", + "status": "completed", + "classification": "small", + "parallelizableWith": [ + 16 + ], + "blockedBy": [ + 15 + ] + }, + { + "id": 18, + "subject": "Task 18 (P2): BirthCache + AliasTable \u2014 bind-by-name, rebuild-per-birth", + "status": "completed", + "classification": "high-risk", + "parallelizableWith": [ + 19 + ], + "blockedBy": [ + 16, + 17 + ] + }, + { + "id": 19, + "subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie", + "status": "completed", + "classification": "high-risk", + "parallelizableWith": [ + 18 + ], + "blockedBy": [ + 16, + 17 + ] + }, + { + "id": 20, + "subject": "Task 20 (P2): RebirthRequester NCMD encode", + "status": "completed", + "classification": "small", + "parallelizableWith": [], + "blockedBy": [ + 16 + ] + }, + { + "id": 21, + "subject": "Task 21 (P2): Sparkplug ingest state machine \u2014 the 3.6 matrix", + "status": "completed", + "classification": "high-risk", + "parallelizableWith": [], + "blockedBy": [ + 18, + 19, + 20 + ] + }, + { + "id": 22, + "subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 21 + ] + }, + { + "id": 23, + "subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync", + "status": "completed", + "classification": "standard", + "parallelizableWith": [ + 24 + ], + "blockedBy": [ + 20, + 21 + ] + }, + { + "id": 24, + "subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator", + "status": "completed", + "classification": "small", + "parallelizableWith": [ + 23 + ], + "blockedBy": [ + 12, + 17 + ] + }, + { + "id": 25, + "subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 21, + 22, + 23 + ] + }, + { + "id": 26, + "subject": "Task 26 (P2): Live /run verify Sparkplug \u2014 P2 COMPLETE", + "status": "completed", + "classification": "small", + "parallelizableWith": [], + "blockedBy": [ + 23, + 24, + 25 + ] + } ], - "lastUpdated": "2026-07-24" + "lastUpdated": "2026-07-27", + "closureNote": "All 27 tasks shipped, both phases live-gated. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-24-mtconnect-driver.md.tasks.json b/docs/plans/2026-07-24-mtconnect-driver.md.tasks.json index 209c1642..4a432eca 100644 --- a/docs/plans/2026-07-24-mtconnect-driver.md.tasks.json +++ b/docs/plans/2026-07-24-mtconnect-driver.md.tasks.json @@ -1,29 +1,199 @@ { "planPath": "docs/plans/2026-07-24-mtconnect-driver.md", "tasks": [ - {"id": 0, "subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)", "status": "pending"}, - {"id": 1, "subject": "Task 1: Scaffold the two driver projects + the test project", "status": "pending", "blockedBy": [0]}, - {"id": 2, "subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts", "status": "pending", "blockedBy": [1]}, - {"id": 3, "subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test", "status": "pending", "blockedBy": [1]}, - {"id": 4, "subject": "Task 4: Capture the canned XML fixtures", "status": "pending", "blockedBy": [1]}, - {"id": 5, "subject": "Task 5: IMTConnectAgentClient seam + return DTOs", "status": "pending", "blockedBy": [1]}, - {"id": 6, "subject": "Task 6: MTConnectAgentClient — parse /probe into the device model", "status": "pending", "blockedBy": [4, 5]}, - {"id": 7, "subject": "Task 7: MTConnectAgentClient — parse /current + /sample, detect the sequence gap", "status": "pending", "blockedBy": [4, 5, 6]}, - {"id": 8, "subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication", "status": "pending", "blockedBy": [7]}, - {"id": 9, "subject": "Task 9: MTConnectDriver shell — IDriver lifecycle", "status": "pending", "blockedBy": [2, 8]}, - {"id": 10, "subject": "Task 10: IReadable.ReadAsync — /current, ordered snapshots", "status": "pending", "blockedBy": [9]}, - {"id": 11, "subject": "Task 11: ISubscribable — /sample long-poll pump + ring-buffer re-baseline", "status": "pending", "blockedBy": [9, 7]}, - {"id": 12, "subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [9, 6]}, - {"id": 13, "subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)", "status": "pending", "blockedBy": [9]}, - {"id": 14, "subject": "Task 14: MTConnectDriverProbe : IDriverProbe", "status": "pending", "blockedBy": [2, 5]}, - {"id": 15, "subject": "Task 15: MTConnectDriverFactoryExtensions", "status": "pending", "blockedBy": [2, 9]}, - {"id": 16, "subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity", "status": "pending", "blockedBy": [14, 15]}, - {"id": 17, "subject": "Task 17: AdminUI typed model MTConnectTagConfigModel", "status": "pending", "blockedBy": [3, 16]}, - {"id": 18, "subject": "Task 18: AdminUI editor razor + map/validator registration", "status": "pending", "blockedBy": [17]}, - {"id": 19, "subject": "Task 19: mtconnect/cppagent docker fixture", "status": "pending", "blockedBy": [16]}, - {"id": 20, "subject": "Task 20: Env-gated integration suite against cppagent", "status": "pending", "blockedBy": [19]}, - {"id": 21, "subject": "Task 21: Live /run verify on docker-dev — browse picker, editor, read, subscribe, deploy", "status": "pending", "blockedBy": [18, 20]}, - {"id": 22, "subject": "Task 22: Docs + deferred-writeback note; update the tracking doc", "status": "pending", "blockedBy": [21]} + { + "id": 0, + "subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)", + "status": "completed" + }, + { + "id": 1, + "subject": "Task 1: Scaffold the two driver projects + the test project", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 4, + "subject": "Task 4: Capture the canned XML fixtures", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 5, + "subject": "Task 5: IMTConnectAgentClient seam + return DTOs", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 6, + "subject": "Task 6: MTConnectAgentClient \u2014 parse /probe into the device model", + "status": "completed", + "blockedBy": [ + 4, + 5 + ] + }, + { + "id": 7, + "subject": "Task 7: MTConnectAgentClient \u2014 parse /current + /sample, detect the sequence gap", + "status": "completed", + "blockedBy": [ + 4, + 5, + 6 + ] + }, + { + "id": 8, + "subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 9, + "subject": "Task 9: MTConnectDriver shell \u2014 IDriver lifecycle", + "status": "completed", + "blockedBy": [ + 2, + 8 + ] + }, + { + "id": 10, + "subject": "Task 10: IReadable.ReadAsync \u2014 /current, ordered snapshots", + "status": "completed", + "blockedBy": [ + 9 + ] + }, + { + "id": 11, + "subject": "Task 11: ISubscribable \u2014 /sample long-poll pump + ring-buffer re-baseline", + "status": "completed", + "blockedBy": [ + 9, + 7 + ] + }, + { + "id": 12, + "subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true", + "status": "completed", + "blockedBy": [ + 9, + 6 + ] + }, + { + "id": 13, + "subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)", + "status": "completed", + "blockedBy": [ + 9 + ] + }, + { + "id": 14, + "subject": "Task 14: MTConnectDriverProbe : IDriverProbe", + "status": "completed", + "blockedBy": [ + 2, + 5 + ] + }, + { + "id": 15, + "subject": "Task 15: MTConnectDriverFactoryExtensions", + "status": "completed", + "blockedBy": [ + 2, + 9 + ] + }, + { + "id": 16, + "subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity", + "status": "completed", + "blockedBy": [ + 14, + 15 + ] + }, + { + "id": 17, + "subject": "Task 17: AdminUI typed model MTConnectTagConfigModel", + "status": "completed", + "blockedBy": [ + 3, + 16 + ] + }, + { + "id": 18, + "subject": "Task 18: AdminUI editor razor + map/validator registration", + "status": "completed", + "blockedBy": [ + 17 + ] + }, + { + "id": 19, + "subject": "Task 19: mtconnect/cppagent docker fixture", + "status": "completed", + "blockedBy": [ + 16 + ] + }, + { + "id": 20, + "subject": "Task 20: Env-gated integration suite against cppagent", + "status": "completed", + "blockedBy": [ + 19 + ] + }, + { + "id": 21, + "subject": "Task 21: Live /run verify on docker-dev \u2014 browse picker, editor, read, subscribe, deploy", + "status": "completed", + "blockedBy": [ + 18, + 20 + ] + }, + { + "id": 22, + "subject": "Task 22: Docs + deferred-writeback note; update the tracking doc", + "status": "completed", + "blockedBy": [ + 21 + ] + } ], - "lastUpdated": "2026-07-24" + "lastUpdated": "2026-07-27", + "closureNote": "Merged at master 90bdaa44 (#506); 5-leg live gate PASSED. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-24-sql-poll-driver.md.tasks.json b/docs/plans/2026-07-24-sql-poll-driver.md.tasks.json index 55e57b84..c1553974 100644 --- a/docs/plans/2026-07-24-sql-poll-driver.md.tasks.json +++ b/docs/plans/2026-07-24-sql-poll-driver.md.tasks.json @@ -1,28 +1,191 @@ { "planPath": "docs/plans/2026-07-24-sql-poll-driver.md", "tasks": [ - {"id": 0, "subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx", "status": "pending"}, - {"id": 1, "subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto", "status": "pending", "blockedBy": [0]}, - {"id": 2, "subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)", "status": "pending", "blockedBy": [1]}, - {"id": 3, "subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx", "status": "pending", "blockedBy": [0]}, - {"id": 4, "subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)", "status": "pending", "blockedBy": [1, 3]}, - {"id": 5, "subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)", "status": "pending", "blockedBy": [2, 4]}, - {"id": 6, "subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture", "status": "pending", "blockedBy": [3, 4]}, - {"id": 7, "subject": "Task 7: SqlPollReader core — grouped read, bounded deadline, ordered slice-back", "status": "pending", "blockedBy": [5, 6]}, - {"id": 8, "subject": "Task 8: SqlDriver shell — IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe", "status": "pending", "blockedBy": [7]}, - {"id": 9, "subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard", "status": "pending", "blockedBy": [8]}, - {"id": 10, "subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)", "status": "pending", "blockedBy": [4, 6]}, - {"id": 11, "subject": "Task 11: Host registration — DriverTypeNames.Sql + factory + probe + guard test", "status": "pending", "blockedBy": [9, 10]}, - {"id": 12, "subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx", "status": "pending", "blockedBy": [3]}, - {"id": 13, "subject": "Task 13: SqlBrowseSession — schema walk over dialect catalog", "status": "pending", "blockedBy": [4, 12, 6]}, - {"id": 14, "subject": "Task 14: SqlDriverBrowser — env-ref/literal transient-connection open", "status": "pending", "blockedBy": [13]}, - {"id": 15, "subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor", "status": "pending", "blockedBy": [14]}, - {"id": 16, "subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip", "status": "pending", "blockedBy": [8]}, - {"id": 17, "subject": "Task 17: Injection regression test (bind value / reject identifier)", "status": "pending", "blockedBy": [7]}, - {"id": 18, "subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container", "status": "pending", "blockedBy": [16]}, - {"id": 19, "subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)", "status": "pending", "blockedBy": [1, 11]}, - {"id": 20, "subject": "Task 20: SqlTagConfigEditor.razor shell", "status": "pending", "blockedBy": [19]}, - {"id": 21, "subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)", "status": "pending", "blockedBy": [11, 15, 20]} + { + "id": 0, + "subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx", + "status": "completed" + }, + { + "id": 1, + "subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 4, + "subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)", + "status": "completed", + "blockedBy": [ + 1, + 3 + ] + }, + { + "id": 5, + "subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)", + "status": "completed", + "blockedBy": [ + 2, + 4 + ] + }, + { + "id": 6, + "subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture", + "status": "completed", + "blockedBy": [ + 3, + 4 + ] + }, + { + "id": 7, + "subject": "Task 7: SqlPollReader core \u2014 grouped read, bounded deadline, ordered slice-back", + "status": "completed", + "blockedBy": [ + 5, + 6 + ] + }, + { + "id": 8, + "subject": "Task 8: SqlDriver shell \u2014 IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 9, + "subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard", + "status": "completed", + "blockedBy": [ + 8 + ] + }, + { + "id": 10, + "subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)", + "status": "completed", + "blockedBy": [ + 4, + 6 + ] + }, + { + "id": 11, + "subject": "Task 11: Host registration \u2014 DriverTypeNames.Sql + factory + probe + guard test", + "status": "completed", + "blockedBy": [ + 9, + 10 + ] + }, + { + "id": 12, + "subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx", + "status": "completed", + "blockedBy": [ + 3 + ] + }, + { + "id": 13, + "subject": "Task 13: SqlBrowseSession \u2014 schema walk over dialect catalog", + "status": "completed", + "blockedBy": [ + 4, + 12, + 6 + ] + }, + { + "id": 14, + "subject": "Task 14: SqlDriverBrowser \u2014 env-ref/literal transient-connection open", + "status": "completed", + "blockedBy": [ + 13 + ] + }, + { + "id": 15, + "subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor", + "status": "completed", + "blockedBy": [ + 14 + ] + }, + { + "id": 16, + "subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip", + "status": "completed", + "blockedBy": [ + 8 + ] + }, + { + "id": 17, + "subject": "Task 17: Injection regression test (bind value / reject identifier)", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 18, + "subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container", + "status": "completed", + "blockedBy": [ + 16 + ] + }, + { + "id": 19, + "subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)", + "status": "completed", + "blockedBy": [ + 1, + 11 + ] + }, + { + "id": 20, + "subject": "Task 20: SqlTagConfigEditor.razor shell", + "status": "completed", + "blockedBy": [ + 19 + ] + }, + { + "id": 21, + "subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)", + "status": "completed", + "blockedBy": [ + 11, + 15, + 20 + ] + } ], - "lastUpdated": "2026-07-24" + "lastUpdated": "2026-07-27", + "closureNote": "Merged 4ad54037 + follow-ups 28c28667. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/security.md b/docs/security.md index d0bd9f73..5604c76d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,7 +21,7 @@ OtOpcUa has four independent security concerns. This document covers all four: 1. **Transport security** — OPC UA secure channel (signing, encryption, X.509 trust). 2. **OPC UA authentication** — Anonymous / UserName / X.509 session identities; UserName tokens authenticated by LDAP bind. -3. **Data-plane authorization** — who can browse, read, subscribe, write, acknowledge alarms on which nodes. Evaluated by `TriePermissionEvaluator` over a `PermissionTrie` built from the Config DB `NodeAcl` tree. +3. **Data-plane authorization** — who can write and acknowledge alarms on an OtOpcUa endpoint. ⚠️ Enforced today by **two coarse, server-wide LDAP role checks** (`WriteOperate`, `AlarmAck`) — **not** by the `NodeAcl` / `PermissionTrie` subsystem, which is built and unit-tested but never wired. Read, Browse, HistoryRead and Subscribe carry **no** authorization check at all. See [Data-Plane Authorization](#data-plane-authorization). 4. **Control-plane authorization** — who can view or edit fleet configuration in the Admin UI. Gated by the `AdminRole` (`Viewer` / `Designer` / `Administrator`) claim resolved from `LdapGroupRoleMapping`. Transport security and OPC UA authentication are per-node concerns configured in the Server's bootstrap `appsettings.json`. Data-plane ACLs and Admin role grants live in the Config DB. @@ -112,9 +112,9 @@ The Server accepts three OPC UA identity-token types: | Token | Handler | Notes | |---|---|---| -| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | Data-plane authorization (below) still default-denies any node a session has no ACL grant for. | -| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) and attached to the OPC UA session identity for the downstream ACL evaluator. | -| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow); finer-grain authorization happens through the data-plane ACLs. | +| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | ⚠️ An anonymous session carries **no roles**, so it is refused every write and every alarm ack — but it can **Browse, Read, Subscribe and HistoryRead the entire address space**. There is no per-node ACL check on those operations (see [Data-Plane Authorization](#data-plane-authorization)). Do not expose an Anonymous-admitting endpoint to an untrusted network. | +| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) and the **roles** are attached to the OPC UA session identity as a `RoleCarryingUserIdentity`. The raw group list is discarded at that point and never reaches the data plane. | +| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow). It carries **no** roles, so a cert-only session is read-everything / write-nothing. | When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`; the Host wires the real `LdapOpcUaUserAuthenticator` as a singleton in `Program.cs`. @@ -130,7 +130,7 @@ LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `s 4. Delegates the real path to the shared `ZB.MOM.WW.Auth.Ldap` client: it binds (search-then-bind via `ServiceAccountDn`, or direct-bind `cn={user},{SearchBase}` when no service account is set), verifies the password, and reads the user's group memberships. 5. Returns an `LdapAuthResult` carrying the validated username + the **groups** (never roles). Failure codes are folded into opaque user-facing error strings so a probe cannot distinguish "unknown user" from "wrong password". -**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity for the ACL evaluator. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session. +**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity (`RoleCarryingUserIdentity`), where the write and alarm-ack gates read them. **The LDAP group names themselves are consumed here and go no further** — which is one reason the `NodeAcl` subsystem cannot currently be wired, since it matches on groups rather than roles. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session. `Transport` replaces the former `UseTls` bool: `Ldaps` (implicit TLS), `StartTls` (upgrade), or `None` (plaintext, requires `AllowInsecure`). Configuration example (Active Directory production): @@ -180,11 +180,92 @@ Defaults are behaviour-neutral on a healthy directory. All new paths are **fail- ## Data-Plane Authorization -Data-plane authorization is the check run on every OPC UA operation against an OtOpcUa endpoint: *can this authenticated user Browse / Read / Subscribe / Write / HistoryRead / AckAlarm / Call on this specific node?* +> ⚠️ **Read this first (verified against source, 2026-07-27).** This section used to describe the +> `NodeAcl` / `PermissionTrie` design as if it were the live enforcement path. **It is not.** The +> evaluator is built and unit-tested but has **zero production call sites**, and +> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not even reference the project it lives in. Everything +> actually enforced today is in [What is enforced today](#what-is-enforced-today); the trie design is +> retained below, clearly marked, under [Designed but not wired](#designed-but-not-wired-nodeacl--permissiontrie). -Per decision #129 the model is **additive-only — no explicit Deny**. Grants at each hierarchy level union; absence of a grant is the default-deny. +### What is enforced today -### Hierarchy +There are exactly **three** authorization checks in the whole OPC UA server layer. All three are +coarse, **server-wide role string checks** with no per-node component. + +| Operation | Gate | Requires | +|---|---|---| +| Write (Value attribute, either namespace) | `OtOpcUaNodeManager.EvaluateEquipmentWriteGate` | role `WriteOperate` | +| Alarm Acknowledge / Confirm / AddComment / Shelve / Unshelve (scripted alarms) | `OtOpcUaNodeManager.HandleAlarmCommand` | role `AlarmAck` | +| Alarm acknowledge (driver-fed native alarms) | `OtOpcUaNodeManager.HandleNativeAlarmAck` | role `AlarmAck` | + +Both gates fail closed — a session with no identity or no matching role gets +`BadUserAccessDenied` — and read `identity.Roles` off the session's `RoleCarryingUserIdentity` +(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`). Role strings are +the constants in `OpcUaDataPlaneRoles`, sourced from `Security:Ldap:GroupToRole` (see +[Role grant source](#role-grant-source-data-plane)). + +**What has no authorization check at all:** + +- **Read, Browse, TranslateBrowsePaths** — no override, no handler. Any admitted session, including + an Anonymous one, sees and reads the entire address space. +- **HistoryRead** — all four overrides (`HistoryReadRawModified`, `HistoryReadProcessed`, + `HistoryReadAtTime`, `HistoryReadEvents`) run without an identity check. +- **CreateMonitoredItems / subscriptions / TransferSubscriptions**. +- **Non-Value attribute writes** — the gate hangs off `OnWriteValue`. + +**And what the write gate does *not* distinguish:** + +- **`WriteTune` and `WriteConfigure` are never checked.** They exist in the role vocabulary and in + `NodePermissions`, but every write — whatever the tag's security classification — is gated on the + single `WriteOperate` string. +- **`AlarmAcknowledge` / `AlarmConfirm` / `AlarmShelve` are not distinguished.** One `AlarmAck` role + covers all five alarm methods; the `operation` argument is passed to the router but never consulted + by the gate. +- **There is no per-node granularity.** A session that may write one tag may write **every** writable + tag on the node. + +The gate *is* realm-aware in one narrow sense: it is attached per-node during materialization for both +the Raw and UNS realms, and the write dispatch it guards is realm-qualified. The role decision itself +takes no realm and no node. + +### Designed but not wired: `NodeAcl` + `PermissionTrie` + +Everything from here to [Role grant source](#role-grant-source-data-plane) describes a subsystem that +**exists in the tree, is covered by 30 unit tests, and never executes.** It is kept because the design +is sound and the remaining work is wiring plus one genuine design gap — not because it runs. + +What is real about it: + +- Operators **can** author `NodeAcl` rows in the Admin UI (`/clusters/{id}/acls`), and those rows are + validated, versioned and persisted. +- `ConfigComposer` **does** snapshot every `NodeAcl` row into every deployment artifact, so an ACL + edit shifts the artifact's `RevisionHash` and ships to every node. +- The node side **never deserializes them** — `DeploymentArtifact` has no ACL reader. The bytes arrive + and are dropped. + +So an operator can author a grant, deploy it green, and have it change nothing. That is the behaviour +to expect until the tracking issue below is closed. + +**The four things blocking a wire-up** (all verified, none of them merely "call the evaluator"): + +1. **Raw-realm nodes have no representable scope.** `NodeHierarchyKind` has exactly one member, + `Equipment`. Raw NodeIds are `Folder→Driver→Device→TagGroup→Tag` RawPaths, which `NodeScope` cannot + express — yet the write gate is attached to raw tags. Roughly half the writable address space has + no scope to evaluate. This is a design decision, not wiring. +2. **The identity carries roles, not groups.** The trie matches `NodeAcl.LdapGroup` against the + session's LDAP groups, but `OpcUaUserAuthResult` carries only mapped role strings; the group list + is discarded inside `LdapOpcUaUserAuthenticator`. +3. **`PermissionTrieBuilder`'s `scopePaths` argument has no producer.** Without it every sub-cluster + grant lands in the builder's fallback bucket — the hazard its own comments warn about. It would have + to be derived from the artifact's UNS relations. +4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but + nothing in production constructs one, stamps a generation, or drives a refresh. + +Two smaller inconsistencies inside the subsystem itself: `NodeAclScopeKind.FolderSegment` is offered in +the authoring dropdown but the trie walker has no level to match it at, and `NodePermissions.AlarmRead` +has no `OpcUaOperation` mapping, so it is grantable but unreachable. + +### Hierarchy (design) ACLs are evaluated against the node's scope path. `NodeScope` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs`) carries a `Kind` that selects between two hierarchy shapes: @@ -245,19 +326,23 @@ The three Write tiers map to Galaxy's v1 `SecurityClassification` — `FreeAcces `NodeScope` is described above (Equipment-kind vs SystemPlatform-kind). The evaluator unions the matched grants along the path — a tag-level ACL and an area-level ACL both contribute. -### Dispatch gate — `IPermissionEvaluator` +### Dispatch gate — `IPermissionEvaluator` (design; **no such gate exists**) -`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`. The dispatch path calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call; a `NotGranted` decision denies the operation. +`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`. -Key properties: +⚠️ **The intended dispatch path — "calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call" — was never built.** `Authorize` has zero production call sites. What the dispatch path really does is in [What is enforced today](#what-is-enforced-today). -- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else flows through the evaluator. -- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. There is no `StrictMode` / fail-open mode; absence of a grant is always a deny. -- **Evaluator stays pure.** `TriePermissionEvaluator` has no OPC UA stack dependency — it's tested directly from xUnit. +Intended properties, of which only the last is true today: + +- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else was to flow through the evaluator. +- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. ⚠️ This describes the evaluator's *internal* behaviour when called. Because nothing calls it, **the effective posture for Read/Browse/Subscribe/HistoryRead is default-ALLOW.** +- **Evaluator stays pure.** ✅ True — `TriePermissionEvaluator` has no OPC UA stack dependency and is tested directly from xUnit. That purity is also why it was easy to leave unwired. ### Full model -See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny). +See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny). Read it as a **design document for unshipped work**, not as a description of running behaviour. + +Note also that the `SystemPlatform` hierarchy kind named above was retired with the v3 dual-namespace address space; `NodeHierarchyKind` now has only `Equipment`. ### Role grant source (data-plane) @@ -271,23 +356,25 @@ to those role strings via `GroupToRole`, e.g.: ```json "GroupToRole": { "ot-operators": "WriteOperate", - "ot-tuners": "WriteTune", - "ot-engineers": "WriteConfigure", - "ot-alarm-ack": "AlarmAck", - "ot-readonly": "ReadOnly" + "ot-alarm-ack": "AlarmAck" } ``` -If this mapping is absent the data-plane evaluator is strictly default-deny: inbound operator writes -and OPC UA Part-9 alarm acknowledgement all return `BadUserAccessDenied` even for users who -authenticate successfully. (The same requirement gates both the scripted-alarm and the native -Galaxy-alarm Part-9 ack/confirm/shelve paths.) +⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and +`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and +`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group +to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (reads are ungated for +everyone regardless). Earlier revisions of this document listed all five as "code-true"; that was +wrong for three of them. -The role strings above are **exact, case-insensitive, and code-true** — the inbound gates compare -against the constants in `OpcUaDataPlaneRoles` (`AlarmAck`, `WriteOperate`) and the bare strings -`ReadOnly` / `WriteTune` / `WriteConfigure`. In particular the alarm-ack role is `AlarmAck`, **not** -`AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different vocabulary); a -`GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate. +If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return +`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both +the scripted-alarm and the native Galaxy-alarm Part-9 ack/confirm/shelve paths.) **Reads, browses, +subscribes and history reads are unaffected** — they succeed with or without any `GroupToRole` entry. + +The two live role strings are exact and case-insensitive. In particular the alarm-ack role is +`AlarmAck`, **not** `AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different +vocabulary); a `GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate. --- diff --git a/docs/v2/acl-design.md b/docs/v2/acl-design.md index f2141313..05945771 100644 --- a/docs/v2/acl-design.md +++ b/docs/v2/acl-design.md @@ -1,5 +1,23 @@ # OPC UA Client Authorization (ACL Design) — OtOpcUa v2 +> ⚠️ **NEVER WIRED (verified against source 2026-07-27).** This is a design document for work that was +> only half-built. The trie, the builder, the cache and the evaluator all exist in +> `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` and carry 30 passing unit tests — but +> `IPermissionEvaluator.Authorize` has **zero production call sites**, nothing registers it in DI, and +> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference the project it lives in. `NodeAcl` rows are +> authorable in the Admin UI and are snapshotted into every deployment artifact, but the node side never +> deserializes them. +> +> **Nothing described below is enforced.** For what actually gates OPC UA operations today, see +> [`docs/security.md`](../security.md) § Data-Plane Authorization and +> [`docs/ReadWriteOperations.md`](../ReadWriteOperations.md). Four blockers stand between this design +> and a wire-up (Raw-realm nodes have no `NodeScope` representation; the session carries roles, not LDAP +> groups; `PermissionTrieBuilder`'s `scopePaths` has no producer; no session lifecycle exists) — they +> are recorded in `deferment.md` §3.1. +> +> Note also that the `SystemPlatform` hierarchy kind used throughout was retired with the v3 +> dual-namespace address space. +> > **Status**: DRAFT — closes corrections-doc finding B1 (namespace / equipment-subtree ACLs not yet modeled in the data path). > > **Branch**: `v2` diff --git a/docs/v2/driver-stability.md b/docs/v2/driver-stability.md index 55eb91a9..b1d70a26 100644 --- a/docs/v2/driver-stability.md +++ b/docs/v2/driver-stability.md @@ -1,5 +1,25 @@ # Driver Stability & Isolation — OtOpcUa v2 +> ⚠️ **The tier assignments below are NOT what runs (verified against source 2026-07-27).** +> **Every one of the 12 drivers runs as Tier A.** `DriverFactoryRegistry` defaults `DriverTier tier = +> DriverTier.A` and **no factory in `src/Drivers/` passes a tier**, so nothing ever selects B or C. The +> Galaxy and FOCAS Tier-C assignments in the table below are aspirational. +> +> Consequences worth knowing: +> +> - The **Tier-C-only protections are dormant for every driver** — `MemoryRecycle` gates on +> `when _tier == DriverTier.C`, and `ScheduledRecycleScheduler` refuses unless Tier C. Neither has ever +> engaged in production. `IDriverSupervisor` has zero implementations, yet the config parser still +> validates `RecycleIntervalSeconds`, so an operator can author a recycle interval that does nothing. +> - `DriverTypeRegistry` — which this document's model implies is the tier authority — is **vestigial**: +> referenced only by its own test, with nothing registering metadata at startup. +> - The **separate-Windows-service hosting** described for Tier C does not exist either. Galaxy reaches +> MXAccess over gRPC to the external **mxaccessgw** sidecar; the `Galaxy.Proxy`/`Host`/`Shared` pattern +> named below was retired in PR 7.2, and FOCAS runs in-process like everything else. +> +> `docs/drivers/README.md` says Tier A for Galaxy and FOCAS and is the accurate one. Keep-or-delete of the +> tier machinery is tracked as Gitea **#522**; this document is retained as the design record. +> > **Status**: DRAFT — companion to `plan.md`. Defines the stability tier model, per-driver hosting decisions, cross-cutting protections every driver process must apply, and the canonical worked example (FOCAS) for the high-risk tier. > > **Branch**: `v2` diff --git a/docs/v2/v2-release-readiness.md b/docs/v2/v2-release-readiness.md index 247c86de..790a36fe 100644 --- a/docs/v2/v2-release-readiness.md +++ b/docs/v2/v2-release-readiness.md @@ -37,11 +37,27 @@ This doc is the single view of where v2 stands against its release criteria. Upd All code-path release blockers are closed. The remaining items are live-hardware / manual validations listed under exit criteria. -### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — **CLOSED** 2026-04-19, PR #94) +### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — marked CLOSED 2026-04-19, PR #94) + +> ⚠️ **THIS CLOSURE DOES NOT HOLD (re-verified against source 2026-07-27).** Every type named in this +> subsection — `AuthorizationGate`, `NodeScopeResolver`, `AuthorizationBootstrap`, `WriteAuthzPolicy`, +> `DriverNodeManager`, `FilterBrowseReferences`, `GateCallMethodRequests`, `MapCallOperation`, +> `EquipmentNamespaceContent`, and the `Node:Authorization:*` config keys — has **zero occurrences in +> `src/`**. There is no `OnReadValue` hook, no `Browse` override, no `CreateMonitoredItems` override and +> no `Call` override in the live node manager, and no HistoryRead path consults any gate. +> +> Whatever landed on the v2 branch did not survive into the shipped tree. The **only** data-plane +> authorization today is two server-wide role checks (`WriteOperate` for writes, `AlarmAck` for alarm +> methods); reads, browses, subscriptions and history reads are ungated, and `NodeAcl` rows are +> authored and deployed but never evaluated. See [`../security.md`](../security.md) § +> Data-Plane Authorization and `deferment.md` §3.1. +> +> The text below is retained as the historical record of what was *believed* closed. **Read none of it +> as current behaviour.** **Closed**. `AuthorizationGate` + `NodeScopeResolver` thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table. -Remaining Stream C surfaces (hardening, not release-blocking): +Remaining Stream C surfaces (hardening, not release-blocking) — **none of these shipped either**: - ~~Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.~~ **Partial, 2026-04-24.** `DriverNodeManager.Browse` override post-filters the `ReferenceDescription` list via a new `FilterBrowseReferences` helper — denied nodes disappear silently per OPC UA convention. Ancestor-visibility implication (Read-grant at `Line/Tag` implying Browse on `Line`) still to ship; needs a subtree-has-any-grant query on the trie evaluator. `TranslateBrowsePathsToNodeIds` surface not yet wired. - ~~CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).~~ **Partial, 2026-04-24.** `DriverNodeManager.CreateMonitoredItems` override pre-gates each request and pre-populates `BadUserAccessDenied` into the errors slot for denied items (the base stack honours pre-set errors and skips those items). Decision #153's per-item `(AuthGenerationId, MembershipVersion)` stamp for detecting mid-subscription revocation is still to ship — needs subscription-layer plumbing. TransferSubscriptions not yet wired (same pattern). diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs index b269531d..88da2aaa 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs @@ -13,6 +13,19 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; /// Latest error message; null when none. /// Number of state-transitions into Faulted in the last 5 minutes. /// Timestamp this snapshot was published. +/// +/// When the driver last raised IRediscoverable.OnRediscoveryNeeded — i.e. it observed that the +/// remote's tag set may have changed (a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect +/// agent restart, a Sparkplug rebirth). Null when the driver has never raised it, or does not +/// implement IRediscoverable. +/// Advisory only. The served address space is NOT rebuilt in response — raw tags are +/// authored explicitly through the /raw browse-commit flow. This is a prompt for an operator +/// to re-browse the device and commit whatever changed. +/// +/// +/// The driver-supplied reason string from the same event (e.g. "deploy-time-changed"), shown +/// to the operator alongside the prompt. Null when is null. +/// public sealed record DriverHealthChanged( string ClusterId, string DriverInstanceId, @@ -20,7 +33,9 @@ public sealed record DriverHealthChanged( DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, - DateTime PublishedUtc) + DateTime PublishedUtc, + DateTime? RediscoveryNeededUtc = null, + string? RediscoveryReason = null) { /// /// DPS topic name. Both the runtime AkkaDriverHealthPublisher and the AdminUI diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto index 0997fb2e..f619d29b 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto @@ -68,6 +68,8 @@ message DriverHealth { optional string last_error = 5; // nullable in the record int32 error_count_5min = 6; google.protobuf.Timestamp published_utc = 7; + google.protobuf.Timestamp rediscovery_needed_utc = 8; // DateTime? — absent Timestamp encodes null + optional string rediscovery_reason = 9; // nullable in the record } // Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers.DriverResilienceStatusChanged. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs index 4ae116f1..b08039a4 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs @@ -19,9 +19,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// (DriverTypeNamesGuardTests) asserts bidirectional parity between these /// constants and the set the driver factories actually register, so a rename on /// either side breaks the build's test gate. Only constants for -/// currently-registered factories belong here — a not-yet-registered driver -/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own -/// constant when its factory is wired in. +/// currently-registered factories belong here — a driver adds its own constant +/// when its factory is wired in. (This used to name Calculation as the example of a +/// not-yet-registered driver; it registers in DriverFactoryBootstrap like the rest.) /// /// public static class DriverTypeNames diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs index 32851fcb..8fff62c2 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs @@ -15,11 +15,20 @@ public interface IDriverHealthPublisher /// The driver instance the snapshot describes. /// The current health snapshot. /// The number of errors observed in the trailing 5-minute window. + /// + /// When the driver last raised , or null if never + /// (or if it is not an ). Advisory: it prompts an operator to + /// re-browse, and never rebuilds the served address space. + /// + /// The driver-supplied reason from that event; null when + /// is null. void Publish( string clusterId, string driverInstanceId, DriverHealth health, - int errorCount5Min); + int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null); } /// @@ -38,6 +47,8 @@ public sealed class NullDriverHealthPublisher : IDriverHealthPublisher string clusterId, string driverInstanceId, DriverHealth health, - int errorCount5Min) + int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null) { /* no-op */ } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs index cc713a1b..76d2009d 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs @@ -38,4 +38,32 @@ public interface ITagDiscovery /// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5. /// bool SupportsOnlineDiscovery => false; + + /// + /// The device-native references this driver's authored tags bind to — the same vocabulary + /// streams as . Comparing the + /// two sets detects that the remote's tag set has drifted from what an operator authored. + /// Null means "I do not report this", and drift detection is skipped. That is the + /// default on purpose: an empty collection legitimately means "no tags authored, so everything + /// discovered is new", and the two must not be confused. A driver opting in is asserting that its + /// authored refs are directly comparable to what it discovers. + /// Only consulted when is true. For a driver that + /// replays authored tags from config rather than browsing the backend, the comparison is a + /// tautology — it would report "no drift" forever and read as reassurance. + /// + IReadOnlyCollection? AuthoredDiscoveryRefs => null; + + /// + /// True when re-emits this driver's authored tags into the same + /// stream as the device-derived ones — which FOCAS, TwinCAT and AbCip all do, so the browse picker + /// shows an operator their existing tags alongside what the device offers. + /// This makes one half of drift detection structurally undetectable. If authored tags + /// are always re-emitted then the discovered set always contains the authored set, so an authored tag + /// that has VANISHED from the device can never appear missing. Declaring it here means the detector + /// reports only what it can actually see, instead of reporting "nothing vanished" forever and reading + /// as reassurance. + /// False (the default) means the stream is purely device-derived — as MTConnect's is, built + /// from the Agent's probe model — so both directions are detectable. + /// + bool DiscoveryStreamIncludesAuthoredTags => false; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 05ad2904..60aff54f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -1036,6 +1036,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time. public bool SupportsOnlineDiscovery => true; + /// + /// Pre-declared tags are emitted under their Name, which is the driver FullName the + /// controller-browse leaves also use. + public IReadOnlyCollection? AuthoredDiscoveryRefs => + _declaredTags.Select(t => t.Name).ToArray(); + + /// + /// True: DiscoverAsync emits browsed controller symbols AND re-emits every + /// pre-declared tag. + public bool DiscoveryStreamIncludesAuthoredTags => true; + /// public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 94add6bd..faadaafc 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -13,7 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable { - private readonly AbLegacyDriverOptions _options; + /// Mutable so can adopt a re-parsed config on reinitialize + /// (#516). Only ever written on the init path, before any reader/session uses it. + private AbLegacyDriverOptions _options; private readonly string _driverInstanceId; private readonly IAbLegacyTagFactory _tagFactory; private readonly ILogger _logger; @@ -94,12 +96,28 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public string DriverType => "AbLegacy"; + /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always + /// passes a populated document; some unit tests pass "{}" or an empty string to exercise + /// lifecycle shape without a config — those keep the constructor-supplied options. + private static bool HasConfigBody(string? driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; + var trimmed = driverConfigJson.Trim(); + return trimmed is not "{}" and not "[]"; + } + /// public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { _health = new DriverHealth(DriverState.Initializing, null, null); try { + // #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver + // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED + // with, so an operator's edit is discarded while the deployment still seals green. + if (HasConfigBody(driverConfigJson)) + _options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); + foreach (var device in _options.Devices) { var addr = AbLegacyHostAddress.TryParse(device.HostAddress) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs index fdd3dd57..cf55b578 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs @@ -49,6 +49,23 @@ public static class AbLegacyDriverFactoryExtensions /// Optional logger factory for the driver instance. /// A configured instance. internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) + { + return new AbLegacyDriver( + ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId, + tagFactory: null, + logger: loggerFactory?.CreateLogger()); + } + + /// + /// Parses an AB Legacy DriverConfig JSON document into typed options. Extracted so + /// can re-parse a CHANGED config on reinitialize + /// (Gitea #516) instead of serving the options it was constructed with — which silently discarded + /// every operator edit while the deployment still sealed green. + /// + /// The unique driver instance identifier. + /// The driver configuration as a JSON string. + /// The parsed . + internal static AbLegacyDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); @@ -82,10 +99,7 @@ public static class AbLegacyDriverFactoryExtensions Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000), }; - return new AbLegacyDriver( - options, driverInstanceId, - tagFactory: null, - logger: loggerFactory?.CreateLogger()); + return options; } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 1c3b654c..72b28f91 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -21,9 +21,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable { - private readonly FocasDriverOptions _options; + /// Mutable so can adopt a re-parsed config (#516). Written only + /// on the init path, together with , before either is used. + private FocasDriverOptions _options; private readonly string _driverInstanceId; - private readonly IFocasClientFactory _clientFactory; + /// Mutable for the same reason as — the Backend config key + /// selects it, so the two must move together or a new tag set polls through an old backend. + private IFocasClientFactory _clientFactory; + + /// Re-parses a DriverConfig into every config-derived dependency. Null when the driver + /// was constructed directly (tests, or a caller supplying its own backend), which keeps the + /// constructor-supplied options authoritative — exactly the prior behaviour. + private readonly Func? _rebind; private readonly PollGroupEngine _poll; private readonly ILogger _logger; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); @@ -58,14 +67,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// The unique identifier for this driver instance. /// Optional factory for creating FOCAS client instances. /// Optional logger instance. + /// Optional re-parse of a DriverConfig into every config-derived dependency, + /// supplied by the factory so can adopt a changed config. Null (direct + /// construction, e.g. a test or a caller supplying its own backend) keeps the constructor-supplied + /// options + backend. public FocasDriver(FocasDriverOptions options, string driverInstanceId, IFocasClientFactory? clientFactory = null, - ILogger? logger = null) + ILogger? logger = null, + Func? rebind = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _clientFactory = clientFactory ?? new Wire.WireFocasClientFactory(); + _rebind = rebind; _logger = logger ?? NullLogger.Instance; _resolver = new EquipmentTagRefResolver( r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); @@ -77,6 +92,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, backoffCap: PollBackoffCap); } + /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes + /// a populated document; some unit tests pass "{}" or an empty string to exercise lifecycle shape + /// without a config — those keep the constructor-supplied options + backend. + private static bool HasConfigBody(string? driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; + var trimmed = driverConfigJson.Trim(); + return trimmed is not "{}" and not "[]"; + } + /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); @@ -100,12 +125,37 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// public string DriverType => "FOCAS"; - /// + /// + /// Opens the configured CNC handles and builds the authored tag table. + /// is re-parsed here when the driver was built by + /// the factory (Gitea #516), and the re-parse is all-or-nothing: the typed options and the + /// IFocasClientFactory the Backend key selects come from one ParseBinding call + /// and are adopted together. That pairing is the point — adopting options alone would poll a NEW + /// device/tag set through the OLD backend, which is why this driver was excluded from the first + /// #516 pass. + /// A driver constructed directly (every unit test, or a caller supplying its own backend) gets + /// no rebinder and keeps its constructor-supplied pair. DriverSpawnPlanner's stop + respawn + /// remains the outer guarantee. + /// + /// The driver configuration JSON (see remarks — not re-parsed). + /// Cancellation token for the operation. + /// A completed task. public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null)); try { + // #516: adopt a changed config. Options and the backend client factory are re-derived by ONE + // parse and assigned together — a partial adoption would poll a new device/tag set through the + // old backend. A null rebinder (direct construction) or an empty/placeholder document keeps the + // constructor-supplied pair, which is what every lifecycle test passing "{}" relies on. + if (_rebind is not null && HasConfigBody(driverConfigJson)) + { + var binding = _rebind(driverConfigJson); + _options = binding.Options; + _clientFactory = binding.ClientFactory; + } + // Fail fast if the factory is a stub/unimplemented backend — the operator must // see an actionable error at init rather than a phantom-Healthy driver that fails // every read/write/subscribe silently. @@ -467,6 +517,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// until the node set is non-empty and stable. public bool SupportsOnlineDiscovery => true; + /// + /// The authored tag's Name IS its driver-side FullName — DiscoverAsync emits + /// FullName: tag.Name for every authored tag — so the two sets are directly comparable. + public IReadOnlyCollection? AuthoredDiscoveryRefs => + _tagsByRawPath.Values.Select(t => t.Name).ToArray(); + + /// + /// True: DiscoverAsync emits the device-derived FixedTree AND re-emits every authored + /// tag, so an authored tag that vanished from the CNC cannot appear missing. + public bool DiscoveryStreamIncludesAuthoredTags => true; + /// public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs index 733e6cc8..8fbf4538 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs @@ -47,6 +47,29 @@ public static class FocasDriverFactoryExtensions /// The driver configuration JSON string. /// A configured instance. internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson) + { + var binding = ParseBinding(driverInstanceId, driverConfigJson); + return new FocasDriver( + binding.Options, + driverInstanceId, + binding.ClientFactory, + // Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY + // config-derived dependency on a reinitialize — options AND the backend client factory + // together (#516). Re-parsing options alone would run a new device/tag set against the old + // backend, which is worse than not re-parsing at all. + rebind: json => ParseBinding(driverInstanceId, json)); + } + + /// + /// Parses a FOCAS DriverConfig JSON document into every dependency the driver derives + /// from config: the typed options and the the Backend key + /// selects. Returned together because they must be adopted together — see the rebind note in + /// . + /// + /// The unique driver instance identifier. + /// The driver configuration JSON string. + /// The parsed options + backend factory. + internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); @@ -86,8 +109,7 @@ public static class FocasDriverFactoryExtensions HandleRecycle = BuildHandleRecycle(dto.HandleRecycle), }; - var clientFactory = BuildClientFactory(dto, driverInstanceId); - return new FocasDriver(options, driverInstanceId, clientFactory); + return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId)); } /// @@ -327,3 +349,14 @@ public static class FocasDriverFactoryExtensions public TimeSpan? Interval { get; init; } } } + +/// +/// Everything FocasDriver derives from its DriverConfig JSON, returned as one value so a +/// reinitialize adopts all of it atomically. +/// This exists because a partial adoption is a real hazard, not a theoretical one: the driver's +/// is chosen by the config's Backend key, so re-parsing +/// alone would poll a NEW device/tag set through the OLD backend (Gitea #516). +/// +/// The typed driver options. +/// The backend client factory the Backend key selects. +public sealed record FocasDriverBinding(FocasDriverOptions Options, IFocasClientFactory ClientFactory); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 9c60197c..384d4f5b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -907,6 +907,19 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis /// public bool SupportsOnlineDiscovery => true; + /// + /// The DataItem ids the authored raw tags bind. DiscoverAsync emits + /// FullName: dataItem.Id from the Agent's probe model, so the two sets are the same + /// vocabulary. + public IReadOnlyCollection? AuthoredDiscoveryRefs => + Volatile.Read(ref _binding).RawPathsByDataItemId.Keys; + + /// + /// False — DiscoverAsync is built purely from the Agent's probe model and never + /// re-emits authored tags, so an authored DataItem that the Agent stopped publishing IS visible as + /// missing. MTConnect is currently the only driver where both drift directions are detectable. + public bool DiscoveryStreamIncludesAuthoredTags => false; + /// /// /// Once, not the default UntilStable: streams the diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs index 12854f7c..3d972795 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs @@ -23,7 +23,9 @@ public sealed class ModbusDriver { // ---- instance fields (grouped at top for auditability) ---- - private readonly ModbusDriverOptions _options; + /// Mutable so can adopt a re-parsed config on reinitialize + /// (#516). Only ever written on the init path, before any reader/transport uses it. + private ModbusDriverOptions _options; private readonly Func _transportFactory; private readonly string _driverInstanceId; private readonly ILogger _logger; @@ -188,12 +190,29 @@ public sealed class ModbusDriver /// public string DriverType => "Modbus"; + /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always + /// passes a populated document; some unit tests pass "{}" or an empty string to exercise + /// lifecycle shape without a config — those keep the constructor-supplied options. + private static bool HasConfigBody(string? driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; + var trimmed = driverConfigJson.Trim(); + return trimmed is not "{}" and not "[]"; + } + /// public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { WriteHealth(new DriverHealth(DriverState.Initializing, null, null)); try { + // #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver + // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED + // with, so an operator's edit is discarded while the deployment still seals green. An empty / + // placeholder document (the "{}" some unit tests pass) keeps the constructor-supplied options. + if (HasConfigBody(driverConfigJson)) + _options = ModbusDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); + _transport = _transportFactory(_options); await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs index 93fdb4a7..ba587ce6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs @@ -44,6 +44,23 @@ public static class ModbusDriverFactoryExtensions /// Optional logger factory for creating loggers per driver instance. /// The constructed instance. public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) + { + return new ModbusDriver( + ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId, + transportFactory: null, + logger: loggerFactory?.CreateLogger()); + } + + /// + /// Parses a Modbus DriverConfig JSON document into typed options. Extracted from + /// so + /// can re-parse a CHANGED config on reinitialize (Gitea #516) rather than serving the options it was + /// constructed with — which silently discarded every edit while the deployment still sealed green. + /// + /// The unique identifier for the driver instance. + /// The JSON configuration string for the driver. + /// The parsed . + internal static ModbusDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); @@ -106,10 +123,7 @@ public static class ModbusDriverFactoryExtensions }, }; - return new ModbusDriver( - options, driverInstanceId, - transportFactory: null, - logger: loggerFactory?.CreateLogger()); + return options; } private static T ParseEnum(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index d9ac8555..26dece00 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -54,7 +54,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit _secretResolver = secretResolver ?? NullSecretResolver.Instance; } - private readonly OpcUaClientDriverOptions _options; + /// Mutable so can adopt a re-parsed config on reinitialize + /// (#516). Only ever written on the init path, before any reader/session uses it. + private OpcUaClientDriverOptions _options; private readonly ISecretResolver _secretResolver; private readonly string _driverInstanceId; // ---- IAlarmSource state ---- @@ -156,12 +158,29 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit /// public string DriverType => "OpcUaClient"; + /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always + /// passes a populated document; some unit tests pass "{}" or an empty string to exercise + /// lifecycle shape without a config — those keep the constructor-supplied options. + private static bool HasConfigBody(string? driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; + var trimmed = driverConfigJson.Trim(); + return trimmed is not "{}" and not "[]"; + } + /// public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { _health = new DriverHealth(DriverState.Initializing, null, null); try { + // #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver + // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED + // with — the endpoint, security policy and tag set were all frozen at construction, and only + // secret rotation was picked up. + if (HasConfigBody(driverConfigJson)) + _options = OpcUaClientDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); + // Enforce the Equipment-vs-SystemPlatform choice at startup per driver-specs.md // §8 "Namespace Assignment" — a misconfigured remote fails draft validation here, // not as a runtime surprise. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs index 9da3a22f..dfd53972 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs @@ -63,16 +63,31 @@ public static class OpcUaClientDriverFactoryExtensions public static OpcUaClientDriver CreateInstance( string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null, ISecretResolver? secretResolver = null) + { + return new OpcUaClientDriver( + ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId, + loggerFactory?.CreateLogger(), + secretResolver ?? NullSecretResolver.Instance); + } + + /// + /// Parses an OpcUaClient DriverConfig JSON document into typed options. Extracted so + /// can re-parse a CHANGED config on reinitialize + /// (Gitea #516) instead of serving the options it was constructed with. Note the driver's own + /// doc-comment previously claimed "resolving on every InitializeAsync picks up rotations" — that was + /// true of SECRET rotation only; the endpoint, security policy and tag set were all frozen at + /// construction. + /// + /// The unique driver instance identifier. + /// The driver configuration as a JSON string. + /// The parsed . + internal static OpcUaClientDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); - var options = JsonSerializer.Deserialize(driverConfigJson, JsonOptions) + return JsonSerializer.Deserialize(driverConfigJson, JsonOptions) ?? throw new InvalidOperationException( $"OpcUaClient driver config for '{driverInstanceId}' deserialised to null"); - - return new OpcUaClientDriver( - options, driverInstanceId, loggerFactory?.CreateLogger(), - secretResolver ?? NullSecretResolver.Instance); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs index 06c05d8f..ff397fcd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs @@ -153,7 +153,19 @@ internal sealed class SqlBrowseSession : IBrowseSession Name: column.Name, DriverDataType: _dialect.MapColumnType(column.DataType).ToString(), IsArray: false, - SecurityClass: ReadOnlySecurityClass), + SecurityClass: ReadOnlySecurityClass, + IsAlarm: false, + // A column NAME alone cannot address a Sql tag — the table has to travel with it, or the + // browse-commit has nothing to build a SqlTagConfigModel from and falls through to the + // generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The + // schema is carried separately so the commit mapper can qualify the table itself rather + // than parsing a joined string back apart. + AddressFields: new Dictionary(StringComparer.Ordinal) + { + ["schema"] = reference.Schema, + ["table"] = reference.Table!, + ["columnName"] = column.Name, + }), ]; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs index e99c3355..c69de1ec 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -1,4 +1,5 @@ using System.Data.Common; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -51,16 +52,19 @@ public sealed class SqlDriver // ---- instance fields (grouped at top for auditability) ---- - private readonly SqlDriverOptions _options; + /// Config-derived, so mutable — see . + private SqlDriverOptions _options; private readonly string _driverInstanceId; - private readonly ISqlDialect _dialect; - private readonly DbProviderFactory _factory; + /// Config-derived, so mutable: swaps it with the connection string + /// and options together on a reinitialize (#516). + private ISqlDialect _dialect; + private DbProviderFactory _factory; /// /// The resolved connection string — a secret. It is passed to the provider and to nothing /// else: every log line, health message and host status carries instead. /// - private readonly string _connectionString; + private string _connectionString; private readonly ILogger _logger; @@ -93,11 +97,19 @@ public sealed class SqlDriver /// Resolves a read/subscribe RawPath to its catalog-validated definition, or a miss. private readonly EquipmentTagRefResolver _resolver; + /// The factory the CALLER passed, or null to take the dialect's. Kept separate from + /// so a re-derived dialect cannot silently displace a test's injected factory. + private readonly DbProviderFactory? _explicitFactory; + + /// Re-parses a DriverConfig into every config-derived dependency. Null when the driver + /// was constructed directly, which keeps the constructor-supplied trio authoritative. + private readonly Func? _rebind; + /// /// The read path. Built once, in the constructor, and not injected: its resolve /// delegate must read this driver's live authored table, which does not exist before the driver does. /// - private readonly SqlPollReader _reader; + private SqlPollReader _reader; /// Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop. private readonly PollGroupEngine _poll; @@ -133,6 +145,11 @@ public sealed class SqlDriver /// explicitly by tests that need to observe or delay connection creation. /// /// Optional; defaults to a no-op logger. + /// + /// Optional re-parse of a DriverConfig into every config-derived dependency, supplied by the + /// factory so can adopt a changed config. Null (direct construction — + /// every test) keeps the constructor-supplied options, dialect and connection string. + /// /// A required reference argument is null. /// or is blank. public SqlDriver( @@ -141,7 +158,8 @@ public sealed class SqlDriver ISqlDialect dialect, string connectionString, DbProviderFactory? factory = null, - ILogger? logger = null) + ILogger? logger = null, + Func? rebind = null) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(dialect); @@ -149,15 +167,47 @@ public sealed class SqlDriver if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString)); - _options = options; _driverInstanceId = driverInstanceId; - _dialect = dialect; - _factory = factory ?? dialect.Factory; - _connectionString = connectionString; _logger = logger ?? NullLogger.Instance; - Endpoint = DescribeEndpoint(connectionString); - + _explicitFactory = factory; + _rebind = rebind; _resolver = new EquipmentTagRefResolver(Lookup); + + // Everything below this line is derived from config and is re-derived wholesale on a reinitialize. + ApplyBinding(options, dialect, connectionString); + + _poll = new PollGroupEngine( + reader: ReadAsync, + onChange: (handle, tagRef, snapshot) => + OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), + onError: HandlePollError, + backoffCap: PollBackoffCap); + } + + /// + /// Adopts a config-derived trio — options, dialect, connection string — and rebuilds everything + /// downstream of it (the provider factory, the endpoint description, and the poll reader, which + /// captures all three). Called from the constructor and again from + /// when a changed config arrives. + /// All-or-nothing on purpose. The reason Sql was excluded from the first #516 pass is + /// that adopting options alone would poll a NEW tag set through the OLD connection and dialect. Doing + /// it in one method means a future field derived from config gets added here rather than being + /// forgotten on the reinit path. + /// Not thread-safe by design: only the init path calls it, before any poll group is running. + /// The poll engine and the tag resolver survive across a rebind — the engine holds a delegate to + /// ReadAsync and the resolver reads the live tag table, so neither captures the old trio. + /// + /// The typed options to adopt. + /// The provider seam to adopt. + /// The resolved connection string to adopt. Never logged. + [MemberNotNull(nameof(_options), nameof(_dialect), nameof(_factory), nameof(_connectionString), nameof(_reader))] + private void ApplyBinding(SqlDriverOptions options, ISqlDialect dialect, string connectionString) + { + _options = options; + _dialect = dialect; + _factory = _explicitFactory ?? dialect.Factory; + _connectionString = connectionString; + Endpoint = DescribeEndpoint(connectionString); _reader = new SqlPollReader( _factory, connectionString, @@ -168,12 +218,16 @@ public sealed class SqlDriver nullIsBad: options.NullIsBad, resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null, logger: _logger); - _poll = new PollGroupEngine( - reader: ReadAsync, - onChange: (handle, tagRef, snapshot) => - OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), - onError: HandlePollError, - backoffCap: PollBackoffCap); + } + + /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes + /// a populated document; some unit tests pass "{}" or an empty string to exercise lifecycle shape + /// without a config — those keep the constructor-supplied trio. + private static bool HasConfigBody(string? driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; + var trimmed = driverConfigJson.Trim(); + return trimmed is not "{}" and not "[]"; } /// @@ -187,7 +241,7 @@ public sealed class SqlDriver /// the connection string names both. This is the only rendering of the connection string that may /// appear in a log, a health message, or the Admin UI. /// - public string Endpoint { get; } + public string Endpoint { get; private set; } = ""; /// Host identifier surfaced through — the endpoint description. public string HostName => Endpoint; @@ -215,9 +269,20 @@ public sealed class SqlDriver /// /// Builds the authored RawPath table and proves the database is reachable. - /// is not re-parsed here: the driver serves the - /// typed it was constructed with, exactly as ModbusDriver does - /// (config parsing belongs to the factory, which builds a fresh instance). + /// is re-parsed here when the driver was built by + /// the factory (Gitea #516). The original comment on this method claimed it was not, on the premise + /// that "config parsing belongs to the factory, which builds a fresh instance" — that was false when + /// written, because the host reinitialized the EXISTING child in place and every config edit was + /// silently discarded while the deployment still sealed green. + /// The re-parse is all-or-nothing via : options, the + /// and the resolved connection string are re-derived by one + /// ParseBinding call and adopted together. That is what makes it safe here — adopting options + /// alone would poll a NEW tag set through the OLD database, which is why this driver was excluded + /// from the first #516 pass. It happens BEFORE BuildTagTable, so the tag table and the + /// connection it is polled over always come from the same revision. + /// A driver constructed directly (every unit test) gets no rebinder and keeps its + /// constructor-supplied trio, so "{}"-passing lifecycle tests are unaffected. + /// DriverSpawnPlanner's stop + respawn remains the outer guarantee. /// The table is built first because it is pure and cannot fail; the liveness check is the only /// I/O, and on failure this method records and rethrows — /// DriverInstanceActor reads a throw as InitializeFailed and lands in Reconnecting with its @@ -235,6 +300,18 @@ public sealed class SqlDriver public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { WriteHealth(new DriverHealth(DriverState.Initializing, null, null)); + + // #516: adopt a changed config BEFORE the tag table is built, so the table and the connection it + // will be polled over come from the same revision. ApplyBinding takes the options, dialect and + // connection string as one unit; adopting options alone would poll a NEW tag set through the OLD + // database, which is why this driver was excluded from the first pass. A null rebinder (direct + // construction) or an empty/placeholder document keeps the constructor-supplied trio. + if (_rebind is not null && HasConfigBody(driverConfigJson)) + { + var binding = _rebind(driverConfigJson); + ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString); + } + BuildTagTable(); try diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs index ac7479e3..a4ec55ef 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs @@ -81,6 +81,43 @@ public static class SqlDriverFactoryExtensions public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson) => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null); + /// + /// Parses a Sql DriverConfig JSON document into every dependency the driver derives + /// from config: the typed options, the the provider key selects, and + /// the connection string resolved from connectionStringRef. + /// Returned together because they must be adopted together. Re-parsing options alone on a + /// reinitialize would run a NEW tag set against the OLD connection and dialect (Gitea #516) — a + /// half-applied config change is worse than a discarded one, because it looks like it worked. + /// Re-running this on reinitialize also re-resolves the connection string from its environment + /// variable, so a rotated credential is picked up without a process restart. + /// + /// The central config DB's identity for this driver instance. + /// The driver configuration JSON. + /// The parsed options, dialect and resolved connection string. + /// The config is malformed or names no connection string. + internal static SqlDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + var dto = Deserialize(driverInstanceId, driverConfigJson); + + if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef)) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " + + $"Author the NAME of a connection string (the value itself is supplied out-of-band through " + + $"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}' environment variable)."); + } + + var dialect = CreateDialect(dto.Provider, driverInstanceId); + + // Resolved BEFORE the knob validation in CreateInstance, so that every subsequent throw is a path + // that HAS the secret in hand — which is exactly where a careless interpolation would leak it. + var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!); + return new SqlDriverBinding(BuildOptions(dto, driverInstanceId), dialect, connectionString, dto); + } + /// /// Logger-aware overload — used by 's closure when wired through DI. /// Constructs the driver only: the driver builds its own , because @@ -102,25 +139,11 @@ public static class SqlDriverFactoryExtensions public static SqlDriver CreateInstance( string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) { - ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); - ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); - - var dto = Deserialize(driverInstanceId, driverConfigJson); - - if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef)) - { - throw new InvalidOperationException( - $"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " + - $"Author the NAME of a connection string (the value itself is supplied out-of-band through " + - $"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}' environment variable)."); - } - - var dialect = CreateDialect(dto.Provider, driverInstanceId); - - // Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the - // secret in hand — which is exactly where a careless interpolation would leak it. - var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!); - var options = BuildOptions(dto, driverInstanceId); + var binding = ParseBinding(driverInstanceId, driverConfigJson); + var dto = binding.Dto; + var dialect = binding.Dialect; + var connectionString = binding.ConnectionString; + var options = binding.Options; var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!) ?? NullLogger.Instance; @@ -143,7 +166,11 @@ public static class SqlDriverFactoryExtensions dialect, connectionString, factory: null, - logger: loggerFactory?.CreateLogger()); + logger: loggerFactory?.CreateLogger(), + // Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive options, + // dialect and connection string together on a reinitialize (#516). Re-resolving the connection + // string also picks up a rotated credential without a process restart. + rebind: json => ParseBinding(driverInstanceId, json)); // Endpoint is the credential-free server/database rendering — the ONLY form of the connection string // permitted outside the provider call. @@ -250,3 +277,21 @@ public static class SqlDriverFactoryExtensions } } } + +/// +/// Everything SqlDriver derives from its DriverConfig JSON, returned as one value so a +/// reinitialize adopts all of it atomically. +/// Partial adoption is the hazard this type exists to prevent: the dialect and the resolved +/// connection string are both config-derived, so adopting new alone would poll a +/// NEW tag set against the OLD database (Gitea #516). +/// +/// The typed driver options. +/// The provider seam the provider key selects. +/// The connection string resolved from connectionStringRef. Never logged. +/// The raw parsed DTO, so a caller needing a key the options do not carry (e.g. +/// allowWrites, which the driver ignores but warns about) need not re-deserialize. +public sealed record SqlDriverBinding( + SqlDriverOptions Options, + ISqlDialect Dialect, + string ConnectionString, + SqlDriverConfigDto Dto); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 4acdbdf2..6e960bdd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -404,6 +404,17 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time. public bool SupportsOnlineDiscovery => true; + /// + /// The authored tag's Name is its RawPath and its driver FullName + /// (DiscoverAsync emits FullName: tag.Name), directly comparable to the browsed + /// symbols' InstancePath. + public IReadOnlyCollection? AuthoredDiscoveryRefs => + _tagsByRawPath.Values.Select(t => t.Name).ToArray(); + + /// + /// True: DiscoverAsync emits browsed ADS symbols AND re-emits every authored tag. + public bool DiscoveryStreamIncludesAuthoredTags => true; + /// public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor index e61d0e9d..e07b9030 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor @@ -28,6 +28,19 @@ else if (!IsNew && _existing is null) } else { + +
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor index 2fd6fdb2..7b49a930 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor @@ -19,6 +19,19 @@ } else { + +
ACL rows grant LDAP groups specific NodePermissions on a scope (a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor index 107b1bd6..f121a70c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -191,6 +191,16 @@ else { @d.DriverInstanceId } + @if (d.RediscoveryNeededUtc is not null) + { + @* The driver reported its remote's tag set may have changed. Advisory + only — v3 authors raw tags via /raw browse-commit, so nothing is + grafted at runtime and an operator must re-browse to pick it up. *@ + + re-browse + + } @(d.DriverType ?? "—") @d.State diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor index 8d8d851e..52db7687 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor @@ -39,38 +39,23 @@ - @switch (_driverType) + @* Rendered from DeviceFormMap — see DriverConfigFormMap for why this is a map and + not a @switch. Drivers in DeviceFormMap.SingleConnectionDriverTypes legitimately + have no per-device form; the parity test consults that set. *@ + @if (DeviceFormMap.Resolve(_driverType) is { } deviceFormType) { - case DriverTypeNames.Modbus: - - break; - case DriverTypeNames.S7: - - break; - case DriverTypeNames.AbCip: - - break; - case DriverTypeNames.AbLegacy: - - break; - case DriverTypeNames.TwinCAT: - - break; - case DriverTypeNames.FOCAS: - - break; - case DriverTypeNames.OpcUaClient: - - break; - case DriverTypeNames.Galaxy: - - break; - case DriverTypeNames.Mqtt: - - break; - default: -
No typed device form for driver type @_driverType.
- break; + + } + else if (DeviceFormMap.IsSingleConnection(_driverType)) + { +
+ This driver holds a single connection, authored on the driver. + There is nothing to configure per device. +
+ } + else + { +
No typed device form for driver type @_driverType.
}
@@ -120,6 +105,14 @@ private string _name = ""; private bool _enabled = true; private string _deviceConfigJson = "{}"; + + /// Parameters handed to the DynamicComponent-rendered device form. Rebuilt on every render — + /// see DriverConfigModal for why this must not be cached. + private Dictionary _deviceFormParameters => new() + { + ["DeviceConfigJson"] = _deviceConfigJson, + ["DeviceConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _deviceConfigJson = v), + }; private byte[] _rowVersion = []; private string _parentDriverConfig = "{}"; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor index 5fa88406..376aeb4d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor @@ -31,50 +31,25 @@
- @switch (_driverType) + @* Rendered from DriverConfigFormMap rather than a @switch: a Razor switch compiles + into BuildRenderTree's IL, so no test can enumerate its cases — which is how this + modal came to be missing a Calculation arm while the driver-picker guard stayed + green (deferment.md G-1/G-4). The map is guarded by DriverFormMapParityTests. *@ + @if (DriverConfigFormMap.Resolve(_driverType) is { } formType) { - case DriverTypeNames.Modbus: - - break; - case DriverTypeNames.S7: - - break; - case DriverTypeNames.AbCip: - - break; - case DriverTypeNames.AbLegacy: - - break; - case DriverTypeNames.TwinCAT: - - break; - case DriverTypeNames.FOCAS: - - break; - case DriverTypeNames.OpcUaClient: - - break; - case DriverTypeNames.Galaxy: - - break; - case DriverTypeNames.Sql: - - break; - case DriverTypeNames.Mqtt: - - break; - case DriverTypeNames.MTConnect: - - break; - default: -
No typed config form for driver type @_driverType.
- break; + + } + else + { +
No typed config form for driver type @_driverType.
} - @* Galaxy + MQTT hold ONE connection per driver instance, so they author it here and - their device forms are informational. Every other driver splits the endpoint onto - the device (the v3 endpoint→DeviceConfig split). *@ - @if (_driverType is DriverTypeNames.Galaxy or DriverTypeNames.Mqtt) + @* Some drivers hold ONE connection per driver instance and author it here; the rest + split the endpoint onto the device (the v3 endpoint→DeviceConfig split). The set is + DeviceFormMap.SingleConnectionDriverTypes — this used to hardcode Galaxy-or-Mqtt and + so told Sql and MTConnect authors to go find an endpoint field on a device form that + does not exist (deferment.md G-3). *@ + @if (DeviceFormMap.IsSingleConnection(_driverType)) {

This driver holds a single connection, authored above. Test-connect lives on its @@ -133,6 +108,17 @@ private string _driverType = ""; private string _name = ""; private string _driverConfigJson = "{}"; + + ///

Parameters handed to the DynamicComponent-rendered driver form. Rebuilt on every render so + /// the child receives the CURRENT json — a cached dictionary would pin the value captured at first + /// render and silently strand every subsequent edit. + private Dictionary _formParameters => new() + { + ["DriverConfigJson"] = _driverConfigJson, + ["DriverConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _driverConfigJson = v), + ["ResilienceConfig"] = _resilienceConfig!, + ["ResilienceConfigChanged"] = EventCallback.Factory.Create(this, v => _resilienceConfig = v), + }; private string? _resilienceConfig; private byte[] _rowVersion = []; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs new file mode 100644 index 00000000..3357f68d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs @@ -0,0 +1,105 @@ +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers; + +/// +/// DriverType → typed driver-config form component, rendered by DriverConfigModal through +/// <DynamicComponent>. +/// Why this is a map and not the @switch it replaced. A Razor @switch compiles +/// into BuildRenderTree's IL, so no test can enumerate its cases — which is exactly how the modal +/// came to be missing a Calculation arm while the sibling driver-picker guard stayed green +/// (deferment.md G-1/G-4). The picker test only works because RawDriverTypeDialog keeps its +/// data in a field the markup enumerates; this type gives the two config modals the same property, and +/// being public it needs none of that test's BindingFlags.NonPublic fragility. +/// Modelled on TagConfigEditorMap. Guarded by DriverFormMapParityTests. +/// +public static class DriverConfigFormMap +{ + private static readonly IReadOnlyDictionary Map = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [DriverTypeNames.Modbus] = typeof(ModbusDriverForm), + [DriverTypeNames.S7] = typeof(S7DriverForm), + [DriverTypeNames.AbCip] = typeof(AbCipDriverForm), + [DriverTypeNames.AbLegacy] = typeof(AbLegacyDriverForm), + [DriverTypeNames.TwinCAT] = typeof(TwinCATDriverForm), + [DriverTypeNames.FOCAS] = typeof(FocasDriverForm), + [DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDriverForm), + [DriverTypeNames.Galaxy] = typeof(GalaxyDriverForm), + [DriverTypeNames.Sql] = typeof(SqlDriverForm), + [DriverTypeNames.Mqtt] = typeof(MqttDriverForm), + [DriverTypeNames.MTConnect] = typeof(MTConnectDriverForm), + [DriverTypeNames.Calculation] = typeof(CalculationDriverForm), + }; + + /// The driver types that have a typed config form. + public static IReadOnlyCollection MappedDriverTypes => (IReadOnlyCollection)Map.Keys; + + /// Resolves the form component for a driver type, or null when none is mapped (the modal + /// then renders its "no typed config form" warning rather than crashing). + /// The driver type name. + /// The form component type, or . + public static Type? Resolve(string? driverType) => + driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null; +} + +/// +/// DriverType → typed device-config form component, rendered by DeviceModal. +/// Not every driver has one, by design. The types in +/// hold ONE connection per driver instance and author it on the +/// driver form, so a per-device endpoint editor would be meaningless. The parity test consults that set +/// rather than demanding total coverage — which keeps it a real guard instead of one that has to be +/// suppressed. +/// +public static class DeviceFormMap +{ + private static readonly IReadOnlyDictionary Map = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [DriverTypeNames.Modbus] = typeof(ModbusDeviceForm), + [DriverTypeNames.S7] = typeof(S7DeviceForm), + [DriverTypeNames.AbCip] = typeof(AbCipDeviceForm), + [DriverTypeNames.AbLegacy] = typeof(AbLegacyDeviceForm), + [DriverTypeNames.TwinCAT] = typeof(TwinCATDeviceForm), + [DriverTypeNames.FOCAS] = typeof(FocasDeviceForm), + [DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDeviceForm), + [DriverTypeNames.Galaxy] = typeof(GalaxyDeviceForm), + [DriverTypeNames.Mqtt] = typeof(MqttDeviceForm), + }; + + /// + /// Driver types that hold a single connection at the DRIVER level, so they legitimately have no + /// per-device form. Galaxy and Mqtt author a connection on the driver form and keep an informational + /// device form; Sql (one connection string), MTConnect (one Agent URI) and Calculation (no + /// connection at all) have no device form to render. + /// Read by DriverConfigModal so the operator is told where the endpoint actually lives — + /// it previously hardcoded Galaxy-or-Mqtt and told Sql/MTConnect authors to go and find an endpoint + /// field on the device that does not exist (deferment.md G-3). + /// + public static readonly IReadOnlySet SingleConnectionDriverTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + DriverTypeNames.Galaxy, + DriverTypeNames.Mqtt, + DriverTypeNames.Sql, + DriverTypeNames.MTConnect, + DriverTypeNames.Calculation, + }; + + /// The driver types that have a typed device form. + public static IReadOnlyCollection MappedDriverTypes => (IReadOnlyCollection)Map.Keys; + + /// Resolves the device form component for a driver type, or null when none is mapped. + /// The driver type name. + /// The form component type, or . + public static Type? Resolve(string? driverType) => + driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null; + + /// True when this driver authors its connection on the DRIVER form rather than per-device. + /// The driver type name. + /// when the connection is driver-level. + public static bool IsSingleConnection(string? driverType) => + driverType is not null && SingleConnectionDriverTypes.Contains(driverType); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/CalculationDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/CalculationDriverForm.razor new file mode 100644 index 00000000..33d5a278 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/CalculationDriverForm.razor @@ -0,0 +1,103 @@ +@* Embeddable Calculation pseudo-driver config form body. The Calculation driver has NO connection of any + kind — it computes signal-level tags from other tags' RawPaths — so its only driver-level knob is the + per-run script timeout. rawTags is composer-owned and never authored here. + + Closes deferment.md G-1: Calculation was offered in the /raw driver picker and registered by the factory, + but DriverConfigModal had no arm for it, so it fell through to the "No typed config form" warning and + RunTimeout was unauthorable through the UI. Hosted by DriverConfigModal via DriverConfigFormMap. *@ +@using System.Text.Json +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers + +
+
Evaluation
+
+
+
+ + +
+ Per-evaluation deadline for a calculated tag's script. Blank = the driver default (2000 ms). + A script exceeding it is abandoned and the tag publishes Bad. +
+
+
+

+ This driver has no connection to author — it reads other tags by RawPath. Its calculated tags and + their scripts are authored on the tags themselves under /raw. +

+
+
+ + + +@code { + /// The driver-level DriverConfig JSON. + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + // camelCase + omit-nulls, matching the driver's case-insensitive deserialize. Omitting runTimeoutMs + // means "use the driver default" rather than pinning the current default into the blob. + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private string? _lastParsed; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed, so an in-progress edit is not clobbered. + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + _form = new FormModel { RunTimeoutMs = TryDeserialize(DriverConfigJson)?.RunTimeoutMs }; + _lastParsed = DriverConfigJson; + } + } + + /// Serializes the current config to camelCase JSON. rawTags is never emitted — the composer + /// adds it at deploy time. + public string GetConfigJson() => JsonSerializer.Serialize(new Dto { RunTimeoutMs = _form.RunTimeoutMs }, _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static Dto? TryDeserialize(string json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch (JsonException) { return null; } // a hand-edited blob must not crash the modal + } + + /// Mirrors the driver's own config DTO. rawTags is deliberately absent — it is composer-owned, + /// and round-tripping it through this form would let the editor drop tags on save. + private sealed class Dto + { + public int? RunTimeoutMs { get; init; } + } + + private sealed class FormModel + { + public int? RunTimeoutMs { get; set; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor index 510a97b1..a14c7140 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor @@ -1,7 +1,6 @@ @* Driver-type picker for the /raw "New driver" action: pick a driver type + name, then the caller creates a minimal driver (CreateDriverAsync with "{}") and the operator configures it afterwards via - the Configure-driver modal. Calculation is included so a Calculation driver row is authorable now - (its factory lands in Wave C). Name is inline-validated as a RawPath segment. Markup mirrors the other + the Configure-driver modal. Name is inline-validated as a RawPath segment. Markup mirrors the other /raw modal shells. *@ @using ZB.MOM.WW.OtOpcUa.Commons.Types @using ZB.MOM.WW.OtOpcUa.Core.Abstractions @@ -56,8 +55,8 @@ /// Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after. [Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; } - // Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway"; Calculation is - // authorable now though its factory arrives in Wave C. + // Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway". Guarded against + // DriverTypeNames by RawDriverTypeDialogParityTests, which reads this field by reflection. private static readonly (string Label, string Value)[] Types = [ ("Modbus", DriverTypeNames.Modbus), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs index 385a6838..4940f313 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs @@ -35,9 +35,15 @@ public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string Clu /// Latest error message; null when none. /// Faulted-transition count in the last 5 minutes. /// Timestamp the snapshot was published. +/// When the driver last reported that the remote's tag set may have +/// changed; null when never (or when the driver cannot report it). Advisory — the served address space +/// is unchanged and an operator must re-browse the device via /raw to pick anything up. +/// The driver-supplied reason for that report; null when +/// is null. public sealed record HostsDriverRow( string DriverInstanceId, string? Name, string? DriverType, string State, - DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc); + DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc, + DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null); /// /// One cluster's section on the /hosts page: its configured nodes plus its enriched @@ -110,7 +116,9 @@ public static class HostsDriverView s.LastSuccessfulReadUtc, s.LastError, s.ErrorCount5Min, - s.PublishedUtc); + s.PublishedUtc, + s.RediscoveryNeededUtc, + s.RediscoveryReason); }) .OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase) .ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs index 49c48764..43fde69d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs @@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; @@ -139,9 +140,19 @@ public static class RawBrowseCommitMapper return new MTConnectTagConfigModel { FullName = address }.ToJson(); if (Is(driverType, DriverTypeNames.Galaxy)) return WriteSingleKey("attributeRef", address); + // Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel + // needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields. + // Without this branch a Sql commit fell through to the generic "address" key below, which the typed + // Sql editor does not read: it would open with empty fields and blank them on save — the exact + // failure the MTConnect branch above was added to avoid (deferment.md G-6). Sql IS browsable + // (SqlDriverBrowser), so the fallback's "browsable drivers are all handled above" was untrue. + if (Is(driverType, DriverTypeNames.Sql)) + return BuildSqlTagConfig(address, addressFields); // Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic - // "address" key so nothing is lost. Browsable drivers are all handled above. + // "address" key so nothing is lost. Every BROWSABLE driver is handled above — guarded by + // RawBrowseCommitMapperParityTests, which enumerates DriverTypeNames.All rather than trusting + // this comment. return WriteSingleKey("address", address); } @@ -261,6 +272,41 @@ public static class RawBrowseCommitMapper private static bool Is(string driverType, string name) => string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase); + /// + /// Builds a SqlTagConfigModel blob from a browsed COLUMN leaf. Maps to + /// SqlTagModel.WideRow — the browse tree is schema → table → column, which is precisely the + /// wide-row shape (one row holds many signals as columns). A KeyValue (EAV) tag cannot be derived + /// from a browse pick because its key VALUE is data, not schema, so the operator authors that in the + /// typed editor. + /// Falls back to the generic key when the fields are absent — an older browser build, or a + /// hand-made selection — rather than emitting a half-built Sql blob. + /// + private static string BuildSqlTagConfig(string address, IReadOnlyDictionary? addressFields) + { + if (addressFields is null + || !addressFields.TryGetValue("table", out var table) + || string.IsNullOrWhiteSpace(table)) + { + return WriteSingleKey("address", address); + } + + addressFields.TryGetValue("schema", out var schema); + addressFields.TryGetValue("columnName", out var columnName); + + // Qualify the table with its schema when the schema is not the default, so a tag authored against + // "sales.Readings" cannot silently resolve to "dbo.Readings". + var qualified = !string.IsNullOrWhiteSpace(schema) && !string.Equals(schema, "dbo", StringComparison.OrdinalIgnoreCase) + ? $"{schema}.{table}" + : table; + + return new SqlTagConfigModel + { + Model = SqlTagModel.WideRow, + Table = qualified, + ColumnName = string.IsNullOrWhiteSpace(columnName) ? address : columnName, + }.ToJson(); + } + private static string WriteSingleKey(string key, string value) { var o = new JsonObject { [key] = value }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs index aa5e71af..74ba100f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs @@ -319,9 +319,11 @@ public static class CsvColumnMap public static IReadOnlyList CommonColumns { get; } = [.. CommonLeadingColumns, TagConfigJsonColumn]; - /// The Calculation (virtual-tag) driver-type string. Not yet a - /// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface. - public const string CalculationDriverType = "Calculation"; + /// The Calculation (virtual-tag) driver-type string. + /// Retained as an alias for , which now exists — the + /// original note here ("not yet a DriverTypeNames constant; its factory lands in a later Batch-2 + /// package") went stale once the factory registered. + public const string CalculationDriverType = DriverTypeNames.Calculation; private static readonly IReadOnlyDictionary Maps = BuildMaps(); @@ -449,6 +451,46 @@ public static class CsvColumnMap new CsvRawKeyColumn(new CsvTypedColumn("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool), new CsvRawKeyColumn(new CsvTypedColumn("TimerIntervalMs", "timerIntervalMs", null), CsvRawKeyKind.Int), }), + + // deferment.md G-5 — Sql, Mqtt and MTConnect all have typed tag editors but had NO CSV map, so + // import/export silently degraded them to the raw TagConfigJson fallback while the seven older + // drivers got typed columns. Raw-key maps (as Galaxy and Calculation use) rather than + // model-backed ones: they address the identity keys directly, which is what a CSV round-trip + // needs, without duplicating each model's full accessor surface. + new RawKeyCsvDriverTagMap(DriverTypeNames.Sql, new[] + { + new CsvRawKeyColumn(new CsvTypedColumn("Model", "model", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("Table", "table", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("KeyColumn", "keyColumn", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("KeyValue", "keyValue", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("ValueColumn", "valueColumn", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("ColumnName", "columnName", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("SqlType", "type", null), CsvRawKeyKind.String), + }), + + new RawKeyCsvDriverTagMap(DriverTypeNames.Mqtt, new[] + { + new CsvRawKeyColumn(new CsvTypedColumn("Mode", "mode", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("Topic", "topic", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("PayloadFormat", "payloadFormat", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("JsonPath", "jsonPath", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MqttDataType", "dataType", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("GroupId", "groupId", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("EdgeNodeId", "edgeNodeId", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MqttDeviceId", "deviceId", null), CsvRawKeyKind.String), + }), + + new RawKeyCsvDriverTagMap(DriverTypeNames.MTConnect, new[] + { + new CsvRawKeyColumn(new CsvTypedColumn("DataItemId", "fullName", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtDataType", "dataType", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtCategory", "mtCategory", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtType", "mtType", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtSubType", "mtSubType", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtUnits", "units", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtDevice", "mtDevice", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtComponent", "mtComponent", null), CsvRawKeyKind.String), + }), }; return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index 11adc332..978e4d9c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -22,9 +22,9 @@ public static class TagConfigEditorMap [DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor), [DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor), [DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor), - // Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared - // constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests - // asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql. + // Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The + // note that used to sit here — "that shared constant is deliberately absent until Task 11 wires + // the factory" — went stale when the constant landed; the key is correct either way. [SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor), [DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor), [DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs index f851f761..bae54518 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs @@ -118,7 +118,9 @@ public static class TelemetryProtoMapCentral LastSuccessfulReadUtc: msg.LastSuccessfulReadUtc?.ToDateTime(), LastError: msg.HasLastError ? msg.LastError : null, ErrorCount5Min: msg.ErrorCount5Min, - PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc")); + PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc"), + RediscoveryNeededUtc: msg.RediscoveryNeededUtc?.ToDateTime(), + RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null); } /// Projects a onto a . diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs index 8682a16f..066c6de1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs @@ -128,6 +128,10 @@ public static class TelemetryProtoMapNode msg.LastSuccessfulReadUtc = ToUtcTimestamp(e.LastSuccessfulReadUtc.Value); if (e.LastError is not null) msg.LastError = e.LastError; + if (e.RediscoveryNeededUtc is not null) + msg.RediscoveryNeededUtc = ToUtcTimestamp(e.RediscoveryNeededUtc.Value); + if (e.RediscoveryReason is not null) + msg.RediscoveryReason = e.RediscoveryReason; return msg; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 2c6b8d41..bb776ad2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -886,49 +886,6 @@ public sealed class AddressSpaceApplier return failed; } - /// - /// Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent: - /// re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so - /// this is safely re-run after every address-space rebuild. Folders are ensured parent-first. - /// Emits a NodeAdded model-change so connected clients can refresh. Discovered nodes are read-only - /// value nodes; array discovered nodes (rare) are forced read-only like the equipment-tag pass. - /// - /// The equipment root node the discovered nodes hang under; the - /// NodeAdded model-change is announced under this node. - /// The discovered folders to ensure (parent-first by depth). - /// The discovered variables to ensure (read-only value nodes). - /// The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor - /// surfaces a non-zero count as a degraded discovered-node injection (archreview 01/S-1). - public int MaterialiseDiscoveredNodes( - string equipmentRootNodeId, - IReadOnlyList folders, - IReadOnlyList variables) - { - ArgumentException.ThrowIfNullOrEmpty(equipmentRootNodeId); - ArgumentNullException.ThrowIfNull(folders); - ArgumentNullException.ThrowIfNull(variables); - if (folders.Count == 0 && variables.Count == 0) return 0; - - var failed = 0; - // Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth. - foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/'))) - if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName, AddressSpaceRealm.Raw)) failed++; - - foreach (var v in variables) - { - // Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays). - var writable = v.Writable && !v.IsArray; - if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable, - AddressSpaceRealm.Raw, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++; - } - - _sink.RaiseNodesAddedModelChange(equipmentRootNodeId, AddressSpaceRealm.Raw); - - _logger.LogInformation( - "AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})", - equipmentRootNodeId, folders.Count, variables.Count, failed); - return failed; - } /// /// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs deleted file mode 100644 index 90a1abe1..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; - -/// A folder to ensure during discovered-node injection (NodeId + parent + display). -public sealed record DiscoveredFolder(string NodeId, string? ParentNodeId, string DisplayName); - -/// A read-or-write variable to ensure during discovered-node injection. -public sealed record DiscoveredVariable( - string NodeId, string ParentNodeId, string DisplayName, string DataType, bool Writable, bool IsArray, uint? ArrayLength); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs index 6f93d39f..d53a4799 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs @@ -30,7 +30,13 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher } /// - public void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min) + public void Publish( + string clusterId, + string driverInstanceId, + DriverHealth health, + int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null) { var msg = new DriverHealthChanged( clusterId, @@ -39,7 +45,9 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher health.LastSuccessfulRead, health.LastError, errorCount5Min, - DateTime.UtcNow); + DateTime.UtcNow, + rediscoveryNeededUtc, + rediscoveryReason); DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg)); // Phase 5: fan the same snapshot into the node-local live-telemetry hub (no-op until a gRPC // client subscribes). The DPS publish above is unchanged — the hub is a strictly additive tap. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs deleted file mode 100644 index 626807d5..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs +++ /dev/null @@ -1,71 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; - -/// -/// An that RECORDS the streamed tree instead of creating OPC UA -/// nodes — used to capture an driver's discovered hierarchy so the -/// runtime can graft it under an equipment node. Folder nesting is tracked (each child builder -/// carries its accumulated path), so every variable records its full . -/// Value nodes only: is ignored and alarm marking returns a no-op sink -/// (discovered alarms are out of scope — alarms come via the config path). -/// Single-threaded: a driver's DiscoverAsync streams on one caller; the root and its child -/// builders share one . Not thread-safe by design. -/// -public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder -{ - private readonly List _nodes; - private readonly IReadOnlyList _path; - - /// Create a root capturing builder with an empty folder path and a fresh node list. - public CapturingAddressSpaceBuilder() : this([], []) { } - - private CapturingAddressSpaceBuilder(List nodes, IReadOnlyList path) - { - _nodes = nodes; - _path = path; - } - - /// All variables captured across the whole tree (shared by the root and every child scope). - public IReadOnlyList Nodes => _nodes; - - /// - public IAddressSpaceBuilder Folder(string browseName, string displayName) - => new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]); - - /// - public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) - { - _nodes.Add(new DiscoveredNode( - FolderPathSegments: _path, - BrowseName: browseName, - DisplayName: displayName, - FullReference: attributeInfo.FullName, - DataType: attributeInfo.DriverDataType, - IsArray: attributeInfo.IsArray, - ArrayDim: attributeInfo.ArrayDim, - Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly, - IsHistorized: attributeInfo.IsHistorized)); - return new NullHandle(attributeInfo.FullName); - } - - /// - public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ } - - /// A variable handle whose alarm marking is a no-op (discovered alarms are out of scope). - private sealed class NullHandle(string fullRef) : IVariableHandle - { - /// - public string FullReference => fullRef; - - /// - public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); - } - - /// A null sink that ignores alarm condition transitions. - private sealed class NullSink : IAlarmConditionSink - { - /// - public void OnTransition(AlarmEventArgs args) { } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs deleted file mode 100644 index 7ff7d43e..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs +++ /dev/null @@ -1,19 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; - -/// -/// A flattened variable captured from a driver's stream -/// by . Folder nesting is preserved in -/// so the injector can re-root the node under an equipment. -/// -public sealed record DiscoveredNode( - IReadOnlyList FolderPathSegments, - string BrowseName, - string DisplayName, - string FullReference, - DriverDataType DataType, - bool IsArray, - uint? ArrayDim, - bool Writable, - bool IsHistorized); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs deleted file mode 100644 index a31b1a85..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs +++ /dev/null @@ -1,118 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; -using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.OpcUaServer; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; - -/// The mapped result of grafting discovered nodes under an equipment node. -/// -/// Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT -/// globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a -/// global parent-before-child ordering across the whole list. -/// -/// Variables to ensure under the (post-collapse) folders. -/// Driver FullReference -> equipment NodeId, for live-value routing. -public sealed record DiscoveredInjectionPlan( - IReadOnlyList Folders, - IReadOnlyList Variables, - IReadOnlyDictionary RoutingByRef); // driver FullReference -> equipment NodeId - -/// -/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping -/// authored Config-DB refs and collapsing the single device-host folder. See the design doc -/// 2026-06-26-otopcua-fixedtree-equipment-injection-design.md. -/// -public static class DiscoveredNodeMapper -{ - /// - /// Maps captured into folders + variables (NodeIds scoped under - /// ) plus a driver-FullReference → equipment-NodeId routing map. - /// - /// The owning equipment's NodeId (root of the grafted subtree). - /// The captured discovery tree (from CapturingAddressSpaceBuilder). - /// - /// Driver FullReferences already authored as Config-DB equipment tags for this driver — - /// skipped so a discovered node never shadows an authored one. - /// - /// The folders, variables, and routing map to apply against the OPC UA address space. - public static DiscoveredInjectionPlan Map( - string rootNodeId, IReadOnlyList nodes, IReadOnlySet authoredRefs) - { - // v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is - // // (slash-joined RawPath), NOT the retired equipment-scoped - // {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath). - static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail; - var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList(); - - // Device-folder collapse: when every kept node shares one identical index-1 segment (the single - // device-host folder under the driver root, e.g. "10.0.0.5:8193"), drop it so the path reads - // FOCAS/Identity/... rather than FOCAS/10.0.0.5:8193/Identity/.... With >=2 distinct devices the - // level is retained so identical leaf names across devices don't collide (degrades gracefully). - var collapseIndex1 = kept.Count > 0 - && kept.All(n => n.FolderPathSegments.Count >= 2) - && kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1; - - static IReadOnlyList Effective(IReadOnlyList segs, bool collapse) - => collapse ? [segs[0], .. segs.Skip(2)] : segs; - - var folders = new Dictionary(StringComparer.Ordinal); - var variables = new List(); - var routing = new Dictionary(StringComparer.Ordinal); - - foreach (var n in kept) - { - var segs = Effective(n.FolderPathSegments, collapseIndex1); - - // Ensure every prefix folder, deduped, each parented at its prefix (the first segment's - // parent is the equipment itself). - for (var i = 0; i < segs.Count; i++) - { - var folderPath = string.Join('/', segs.Take(i + 1)); - var nodeId = Combine(rootNodeId, folderPath); - if (folders.ContainsKey(nodeId)) continue; - var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i))); - folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]); - } - - var varFolderPath = string.Join('/', segs); - var varNodeId = string.IsNullOrEmpty(varFolderPath) - ? Combine(rootNodeId, n.BrowseName) - : Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName); - // A folder-less variable parents directly at the root device node. - var varParent = string.IsNullOrEmpty(varFolderPath) - ? rootNodeId - : Combine(rootNodeId, varFolderPath); - variables.Add(new DiscoveredVariable( - varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim)); - routing[n.FullReference] = varNodeId; - } - - return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing); - } - - /// - /// Maps a to the OPC-UA-built-in type STRING that - /// OtOpcUaNodeManager.EnsureVariable's ResolveBuiltInDataType accepts — so a - /// discovered variable resolves to the same built-in type as an authored equipment tag. Most - /// enum names pass through verbatim; / - /// map to the SDK's "Float"/"Double" names, and (a Galaxy - /// attribute reference) is carried as an OPC UA String per the enum's own contract. - /// - private static string ToBuiltinTypeString(DriverDataType dt) => dt switch - { - DriverDataType.Boolean => "Boolean", - DriverDataType.Int16 => "Int16", - DriverDataType.Int32 => "Int32", - DriverDataType.Int64 => "Int64", - DriverDataType.UInt16 => "UInt16", - DriverDataType.UInt32 => "UInt32", - DriverDataType.UInt64 => "UInt64", - DriverDataType.Float32 => "Float", - DriverDataType.Float64 => "Double", - DriverDataType.String => "String", - DriverDataType.DateTime => "DateTime", - DriverDataType.Reference => "String", - _ => throw new ArgumentOutOfRangeException(nameof(dt), dt, "Unmapped DriverDataType."), - }; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index c1025d56..82d5267a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -244,37 +244,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// value maps so stale condition state never leaks across redeploys. private readonly NativeAlarmProjector _nativeAlarmProjector = new(); + /// In-flight sends, keyed by correlation, so + /// can advance the cached spec only once the child confirms it adopted + /// the config (#516). Bounded by the number of driver children with a delta in flight. + private readonly Dictionary _pendingDelta = new(); + /// The composition from the most-recent apply (set at the END of - /// ). Discovered-node injection - /// () reads it to resolve the equipment bound to a driver (from the - /// composition's EquipmentNodes whose DriverInstanceId matches, UNION the authored - /// EquipmentTags for that driver — so a driver with zero authored tags can still graft onto an - /// equipment bound via EquipmentNode.DriverInstanceId) and to recompute the authored value + alarm - /// subscription sets when merging FixedTree refs. Null until the first apply — a - /// arriving before any apply is ignored. + /// ). Null until the first apply. private AddressSpaceComposition? _lastComposition; - /// The most-recent discovered-injection plan(s) per driver instance, cached so the redeploy - /// re-inject tail can re-apply the live graft after an address-space rebuild without re-running discovery. - /// Keyed by DriverInstanceId at the OUTER level, then by EquipmentId at the INNER level (driver → (equipment - /// → plan)). Today only the single-equipment case is populated, so the inner map always has exactly one - /// entry; the inner map is shaped per-equipment now so the follow-up multi-device-partition task can hold - /// multiple (equipmentId → plan) entries per driver without reshaping this cache. Inner dict is mutable - /// (the redeploy tail drops stale per-equipment entries in place); both levels are Ordinal-keyed. - /// Last-writer-wins on a re-discovery (the whole inner map is replaced). - private readonly Dictionary> _discoveredByDriver = - new(StringComparer.Ordinal); - - /// Per-driver signature of the last-logged device-host PARTITION diagnostic (unmatched / ambiguous - /// / degenerate host), folded with the current revision, so the ~15 repeated re-discovery passes within a - /// connect don't re-warn an unchanged condition: it is WARNED once when it first appears (or changes), and - /// DEBUG-logged on the identical repeat passes. Folding in makes a redeploy - /// re-warn once. Best-effort LOG-LEVEL dedup ONLY — never affects grafting; the matched-plan re-apply is - /// separately short-circuited by . Cleared for a driver whose partition comes - /// back clean so a later recurrence re-warns; bounded by driver count (a few). Only touched on the - /// multi-candidate path (). - private readonly Dictionary _lastPartitionWarnSignature = new(StringComparer.Ordinal); - /// /// Cached local from the latest /// snapshot (null = unknown until the first snapshot arrives, or no local node match). The Primary @@ -894,8 +872,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(ForwardToMux); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); - Receive(HandleDiscoveredNodes); Receive(HandleDeltaApplied); + Receive(HandleApplyResult); Receive(HandleRestartDriver); Receive(HandleReconnectDriver); Receive(HandleRouteNodeWrite); @@ -928,8 +906,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(ForwardToMux); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); - Receive(HandleDiscoveredNodes); Receive(HandleDeltaApplied); + Receive(HandleApplyResult); Receive(HandleRestartDriver); Receive(HandleReconnectDriver); Receive(HandleRouteNodeWrite); @@ -973,349 +951,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers } } - /// - /// Handles a driver child's post-connect : - /// resolves the equipment the driver is bound to from the most-recent applied composition (its - /// EquipmentNodes bound by DriverInstanceId UNION its authored EquipmentTags), - /// maps the captured FixedTree under it via (deduping any node that - /// shadows an authored equipment-tag ref), caches the per-equipment plan map, and grafts it onto the - /// served address space + live-value maps + subscription set via - /// . Idempotent / duplicate-safe: the mapper is pure, - /// materialisation is idempotent, and the routing-map extension + subscription merge are set-based. - /// - private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg) - { - // v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a - // driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3 - // retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are - // authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The - // downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an - // equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any - // caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert - // too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree - // is a separate follow-up. - _log.Debug( - "DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)", - _localNode, msg.DriverInstanceId); - return; - -#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up. - if (_lastComposition is null) - { - _log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored", - _localNode, msg.DriverInstanceId); - return; - } - - // Resolve the equipment bound to this driver from BOTH the composition's EquipmentNodes (whose - // DriverInstanceId matches — this lets a driver with ZERO authored tags graft onto a tag-less - // equipment) UNION the authored EquipmentTags for the driver (the original resolution). Distinct so a - // driver that is both EquipmentNode-bound AND has authored tags under the same equipment resolves once. - var fromNodes = _lastComposition.EquipmentNodes - .Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) - .Select(e => e.EquipmentId); - var fromTags = _lastComposition.EquipmentTags - .Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) - .Select(t => t.EquipmentId); - var equipmentIds = fromNodes.Concat(fromTags).Distinct(StringComparer.Ordinal).ToList(); - if (equipmentIds.Count == 0) - { - _log.Info("DriverHost {Node}: no equipment for driver {Driver} — skipping discovered-node injection", - _localNode, msg.DriverInstanceId); - return; - } - // Authored refs for THIS driver (DRIVER-WIDE — both value + alarm tags) so a discovered node never - // shadows an authored one — the mapper drops any captured node whose FullReference is already authored. - // May be EMPTY for a tag-less equipment, which is fine: Map dedups against an empty set (keeps - // everything). Safe even for the multi-device partition below: a FOCAS FullReference is host-prefixed, - // so a device-X discovered node can't collide with a device-Y authored ref — the driver-wide set is - // correct per partition. - var authoredRefs = _lastComposition.EquipmentTags - .Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) - .Select(t => t.FullName) - .ToHashSet(StringComparer.Ordinal); - - // Build this discovery's per-equipment plan map. - // • EXACTLY ONE candidate ⇒ map the WHOLE captured tree under it (the mapper collapses the single - // device-host folder ⇒ clean EQ-n/FOCAS/...). Unchanged from before. - // • MORE THAN ONE candidate ⇒ PARTITION the captured tree by its (normalized) device-host folder - // segment and graft each device's subset under the equipment whose DeviceHost matches (follow-up E - // part 2). Unmatched/ambiguous hosts are warn-skipped (safe), not mis-grafted; a degenerate case - // (>1 candidate, none has a DeviceHost) warn-skips the whole driver. See PartitionDiscoveredByDeviceHost. - Dictionary newPlans; - if (equipmentIds.Count == 1) - { - var plan = DiscoveredNodeMapper.Map(equipmentIds[0], msg.Nodes, authoredRefs); - if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored) - newPlans = new Dictionary(StringComparer.Ordinal) { [equipmentIds[0]] = plan }; - } - else - { - newPlans = PartitionDiscoveredByDeviceHost(msg, equipmentIds, authoredRefs); - if (newPlans.Count == 0) return; // degenerate / no host matched a graftable partition — already logged - } - - // Unchanged-plan short-circuit (shared by the single- AND multi-device paths): the driver re-discovers - // every ~2s (up to ~15 passes) until the FixedTree set stabilises, re-sending DiscoveredNodesReady each - // pass. Re-applying an IDENTICAL set would re-send SetDesiredSubscriptions, forcing the child to - // UnsubscribeAsync (dropping the WHOLE handle — authored tags included) then re-Subscribe — blipping - // authored-tag values up to ~15× across the discovery window. Skip when the WHOLE per-equipment routing - // is unchanged from the last applied pass; a GROWING set still differs (superset) and re-applies. (This - // is also why an unmatched/ambiguous partition warning settles: once the matched partitions stabilise we - // short-circuit here, and the partition warns are themselves signature-deduped — see ShouldWarnPartition.) - if (_discoveredByDriver.TryGetValue(msg.DriverInstanceId, out var cached) - && PlansRoutingEqual(cached, newPlans)) - { - var total = newPlans.Values.Sum(p => p.Variables.Count); - _log.Debug("DriverHost {Node}: discovered set for driver {Driver} unchanged ({Count} node(s) across {Equipment} equipment(s)) — re-apply skipped", - _localNode, msg.DriverInstanceId, total, newPlans.Count); - return; - } - - _discoveredByDriver[msg.DriverInstanceId] = newPlans; - ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans); -#pragma warning restore CS0162 - } - - /// - /// Partitions a multi-device driver's captured FixedTree by its (normalized) device-host folder segment - /// (FolderPathSegments[1]) and maps each device's subset under the candidate equipment whose - /// matches — the multi-device graft. Returns - /// the per-equipment plan map (one entry per device that matched AND had at least one new variable); - /// EMPTY when nothing is graftable. - /// - /// Builds normalizedHost → equipmentId from the candidate s - /// that carry a non-null DeviceHost. Two distinct candidates sharing a host is AMBIGUOUS — that host - /// is un-mapped (its nodes are warn-skipped) rather than grafted onto an arbitrary equipment. - /// I1 divergence: a candidate WITHOUT a DeviceHost (e.g. resolved via authored tags only, - /// no device binding) simply gets no partition — the FixedTree is the device's structure, so it - /// belongs under the device-bound equipment. No crash; that candidate is just not a partition target. - /// If NO candidate has a DeviceHost at all there is nothing to partition on ⇒ DEGENERATE ⇒ - /// warn-skip the whole driver (returns empty). - /// A discovered partition whose host is unmatched (or whose node has <2 folder segments, so no - /// host folder) is warn-skipped — its nodes are NOT mis-grafted; the matched partitions still graft. - /// - /// The device-host folder segment AND the stored DeviceHost are both run through the SAME - /// (single source of truth), so they compare equal - /// regardless of case/whitespace. - /// Warn-spam taming. The unmatched/ambiguous/degenerate condition is warned ONCE then - /// Debug-logged on the repeated re-discovery passes (see ). - /// Mid-connect partition shrink. If a later pass yields FEWER device partitions than a - /// prior pass within the same connect, the dropped partition's routes + materialised nodes are NOT - /// actively pruned until the next full redeploy ( Clears + rebuilds - /// the maps). This matches the existing "FixedTree grows-then-stabilises within a connect" assumption — - /// no mid-connect pruning is built here (out of scope). - /// - private Dictionary PartitionDiscoveredByDeviceHost( - DriverInstanceActor.DiscoveredNodesReady msg, - IReadOnlyList equipmentIds, - IReadOnlySet authoredRefs) - { - var driverId = msg.DriverInstanceId; - var candidateSet = equipmentIds.ToHashSet(StringComparer.Ordinal); - - // normalizedHost → equipmentId, from candidate EquipmentNodes that carry a DeviceHost. A host shared by - // two DISTINCT candidates is ambiguous: un-map it (warn-skip) so its nodes aren't grafted arbitrarily. - var hostToEquipment = new Dictionary(StringComparer.Ordinal); - var ambiguousHosts = new HashSet(StringComparer.Ordinal); - foreach (var node in _lastComposition!.EquipmentNodes) - { - if (!candidateSet.Contains(node.EquipmentId) || node.DeviceHost is null) continue; - // DeviceHost is already normalized at compose/decode time; re-normalize through the shared helper so - // the comparison is the single source of truth (idempotent — harmless if it was already normalized). - var host = DeviceConfigIntent.NormalizeHost(node.DeviceHost); - if (ambiguousHosts.Contains(host)) continue; - if (hostToEquipment.TryGetValue(host, out var existing)) - { - if (!string.Equals(existing, node.EquipmentId, StringComparison.Ordinal)) - { - hostToEquipment.Remove(host); - ambiguousHosts.Add(host); - } - continue; - } - hostToEquipment[host] = node.EquipmentId; - } - - // DEGENERATE: >1 candidate but none resolved a DeviceHost ⇒ nothing to partition on ⇒ warn-skip the - // whole driver. (Falls through the same warn-once dedup as the unmatched case.) - if (hostToEquipment.Count == 0 && ambiguousHosts.Count == 0) - { - if (ShouldWarnPartition(driverId, "degenerate")) - _log.Warning("DriverHost {Node}: driver {Driver} maps to {Count} equipments but none has a DeviceHost — discovered-node injection skipped (no device-host to partition on)", - _localNode, driverId, equipmentIds.Count); - else - _log.Debug("DriverHost {Node}: driver {Driver} still has no DeviceHost on any of {Count} equipments — skipped (repeat)", - _localNode, driverId, equipmentIds.Count); - return new Dictionary(StringComparer.Ordinal); - } - - // Partition the captured tree by its device-host folder segment (FolderPathSegments[1]); a node with - // <2 segments has no host folder (null ⇒ unmatched). Keep only nodes whose host matches a candidate. - var matchedNodes = new Dictionary>(StringComparer.Ordinal); - var unmatchedHosts = new HashSet(StringComparer.Ordinal); - foreach (var n in msg.Nodes) - { - var key = n.FolderPathSegments.Count >= 2 - ? DeviceConfigIntent.NormalizeHost(n.FolderPathSegments[1]) - : null; - if (key is not null && hostToEquipment.ContainsKey(key)) - { - if (!matchedNodes.TryGetValue(key, out var list)) - matchedNodes[key] = list = new List(); - list.Add(n); - } - else - { - unmatchedHosts.Add(key ?? "(no-device-host-folder)"); - } - } - - // Map each matched device's subset under its equipment. ONE device per partition ⇒ the mapper collapses - // that partition's single host folder ⇒ clean EQ-n/FOCAS/...; a plan with zero new variables (all - // shadowed by authored refs) contributes no entry. - // NOTE: DiscoveredNodeMapper.Map's collapse predicate compares the host segment with RAW - // StringComparer.Ordinal, whereas we grouped on the NORMALIZED host. Harmless: a real FOCAS device - // emits one consistent HostAddress string per device, so a partition is single-host either way (collapse - // fires). Even if two raw spellings of the same host slipped into one partition, the only effect would be - // a retained (non-collapsed) host folder — never a mis-graft or NodeId collision (the equipment scope - // already isolates them). - var plans = new Dictionary(StringComparer.Ordinal); - foreach (var (host, nodes) in matchedNodes) - { - var equipmentId = hostToEquipment[host]; - var plan = DiscoveredNodeMapper.Map(equipmentId, nodes, authoredRefs); - if (plan.Variables.Count > 0) plans[equipmentId] = plan; - } - - // Surface unmatched/ambiguous hosts ONCE (then Debug on the repeated passes). The matched partitions - // above still graft regardless. When the partition came back fully clean, drop the driver's signature so - // a later recurrence re-warns. - if (unmatchedHosts.Count > 0 || ambiguousHosts.Count > 0) - { - var unmatched = string.Join(",", unmatchedHosts.OrderBy(h => h, StringComparer.Ordinal)); - var ambiguous = string.Join(",", ambiguousHosts.OrderBy(h => h, StringComparer.Ordinal)); - if (ShouldWarnPartition(driverId, "u:" + unmatched + "|a:" + ambiguous)) - _log.Warning("DriverHost {Node}: driver {Driver}: discovered device-host partition(s) skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}]; matched partitions still grafted", - _localNode, driverId, unmatched, ambiguous); - else - _log.Debug("DriverHost {Node}: driver {Driver}: device-host partition(s) still skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}] (repeat)", - _localNode, driverId, unmatched, ambiguous); - } - else - { - _lastPartitionWarnSignature.Remove(driverId); - } - - return plans; - } - - /// Best-effort LOG-LEVEL dedup for the device-host partition diagnostics: returns true (⇒ WARN) - /// when is newly-seen for the driver this revision, false (⇒ DEBUG) on the - /// identical repeat passes that the ~15×/connect re-discovery produces. Folds the current revision in so a - /// redeploy re-warns once. Records the signature as a side effect. Never affects grafting behavior — only - /// the log level — so a stale entry (e.g. after a transient single↔multi candidate flip) at worst demotes - /// one duplicate warn to Debug. - private bool ShouldWarnPartition(string driverId, string conditionKey) - { - var signature = (_currentRevision?.ToString() ?? "none") + "|" + conditionKey; - var isNew = !_lastPartitionWarnSignature.TryGetValue(driverId, out var prev) - || !string.Equals(prev, signature, StringComparison.Ordinal); - _lastPartitionWarnSignature[driverId] = signature; - return isNew; - } - - /// Routing-map equality: same count + every key maps to the same NodeId. Lets - /// skip re-applying an unchanged discovered set across the driver's - /// repeated post-connect re-discovery passes (a grown/changed set differs and re-applies). - private static bool RoutingEquals(IReadOnlyDictionary a, IReadOnlyDictionary b) - => a.Count == b.Count - && a.All(kv => b.TryGetValue(kv.Key, out var v) && string.Equals(v, kv.Value, StringComparison.Ordinal)); - - /// Per-equipment plan-map routing equality: same equipment keys + each equipment's plan has the - /// same (via ). Lets - /// short-circuit a re-discovery whose WHOLE per-driver set is unchanged - /// (a grown/changed set on any equipment differs and re-applies). - private static bool PlansRoutingEqual( - IReadOnlyDictionary a, - IReadOnlyDictionary b) - => a.Count == b.Count - && a.All(kv => b.TryGetValue(kv.Key, out var p) && RoutingEquals(kv.Value.RoutingByRef, p.RoutingByRef)); - - /// - /// Grafts a driver's per-equipment map onto the served state in - /// two phases so the resubscribe stays a single push per driver (the shape the multi-device-partition - /// follow-up needs without resubscribe churn): - /// - /// Materialise per equipment — for each (equipmentId, plan) entry, extend the - /// live-value routing map (mirroring ' fan-out so - /// lands FixedTree values on the right node) and Tell the publish actor - /// for - /// that equipment (idempotent). - /// Subscribe ONCE per driver — compute the union of the driver's authored value refs - /// (recomputed the same way does) and the FixedTree refs of - /// ALL the driver's cached plans, then Tell the child a single - /// so the poll engine reads them and the - /// values flow. For a single-equipment driver this equals the prior per-plan behavior. - /// - /// Extracted as a standalone method so the redeploy re-inject tail can re-apply the cached plans after - /// an address-space rebuild without re-running discovery. - /// - private void ApplyDiscoveredPlansForDriver( - string driverId, IReadOnlyDictionary plansByEquipment) - { - // (a) Per-equipment: extend the live-value routing map (fan-out, mirroring PushDesiredSubscriptions' - // pattern) + materialise the discovered folders + variables under that equipment (idempotent). This is - // purely ADDITIVE across passes: a shrinking discovery set would leave the dropped refs' stale routes - // until the next full apply (PushDesiredSubscriptions) clears + rebuilds the maps — acceptable because - // a FOCAS FixedTree only grows-then-stabilises, never shrinks within a connect. - var totalVariables = 0; - foreach (var (equipmentId, plan) in plansByEquipment) - { - foreach (var (driverRef, nodeId) in plan.RoutingByRef) - { - var key = (driverId, driverRef); - if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) - _nodeIdByDriverRef[key] = set = new HashSet(); - // v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route - // through the Raw realm. - set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw)); - _driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key; - } - _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes( - equipmentId, plan.Folders, plan.Variables)); - totalVariables += plan.Variables.Count; - } - - // (b) ONE subscription push per driver: merge the FixedTree refs from ALL the driver's plans into the - // driver's desired subscription set so the poll engine reads them and ForwardToMux routes the values. - // Recompute the authored value + alarm refs the same way PushDesiredSubscriptions does, then union the - // FixedTree refs onto the value set. Doing the union here (rather than once per plan) means the - // multi-device task adds inner-map entries without changing this single-send shape. - if (!_children.TryGetValue(driverId, out var entry)) return; - // The _lastComposition null-guards below are defensive: HandleDiscoveredNodes already proved it - // non-null, but the redeploy tail also calls this from the PushDesiredSubscriptions tail — keep them - // so that re-apply path can't NRE. - var authoredValueRefs = _lastComposition is null - ? Enumerable.Empty() - : _lastComposition.EquipmentTags - .Where(t => t.Alarm is null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(t => t.FullName); - var alarmRefs = _lastComposition is null - ? Array.Empty() - : _lastComposition.EquipmentTags - .Where(t => t.Alarm is not null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(t => t.FullName) - .Distinct(StringComparer.Ordinal) - .ToArray(); - var discoveredRefs = plansByEquipment.Values.SelectMany(p => p.RoutingByRef.Keys); - var union = authoredValueRefs.Concat(discoveredRefs).Distinct(StringComparer.Ordinal).ToArray(); - entry.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs)); - - _log.Info("DriverHost {Node}: injected {Count} discovered node(s) for driver {Driver} across {Equipment} equipment(s)", - _localNode, totalVariables, driverId, plansByEquipment.Count); - } - /// /// Routes a native alarm transition (published by a driver child as /// ) to its materialised Part 9 condition @@ -1719,16 +1354,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(msg => _log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)", _localNode, msg.ConditionNodeId)); - // A driver child's post-connect DiscoveredNodesReady can't be injected while Stale (no composition is - // applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it - // and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above). - Receive(_ => { }); // A child connectivity transition while the host is Stale has no live address space to annotate — drop // it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry). Receive(_ => { }); // A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's // mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access). Receive(HandleDeltaApplied); + Receive(HandleApplyResult); Receive(_ => { /* PubSub ack */ }); Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval); } @@ -2316,15 +1948,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers } } - // Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver - // is still untouched (the re-inject tail below drops/removes entries). Cached drivers are SKIPPED in the - // bulk loop because the tail sends each of them EXACTLY ONE SetDesiredSubscriptions for this pass: the - // authored∪discovered union (ApplyDiscoveredPlansForDriver) for a survivor, or — if its plan is fully - // dropped — an authored-only fallback. Sending the bulk authored-only set HERE too would force the child - // to drop the whole handle (authored tags included) then re-subscribe — an extra unsub/resub blip of the - // authored values once per cached driver per redeploy. Net effect: exactly ONE send per driver per pass. - var cachedDriverIds = _discoveredByDriver.Keys.ToHashSet(StringComparer.Ordinal); - // One authored-only push (value refs + alarm refs from the maps built above), shared by the bulk loop AND // the dropped-driver fallback so the two CANNOT drift: the fallback's correctness depends on sending the // SAME payload the bulk loop would have, so it's a shared helper (structural), not a comment-maintained @@ -2341,9 +1964,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers var total = 0; foreach (var (driverId, entry) in _children) { - // Cached drivers are owned exclusively by the re-inject tail (one send each) — skip here. Non-cached - // drivers keep the bulk authored-only send exactly as before. - if (cachedDriverIds.Contains(driverId)) continue; total += SendAuthoredOnly(entry.Actor, driverId); } @@ -2380,94 +2000,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers _localNode, composition.EquipmentScriptedAlarms.Count); } - // Cache the applied composition LAST so discovered-node injection (HandleDiscoveredNodes) can resolve - // the equipment bound to a driver + recompute the authored subscription sets when a driver later - // reports its FixedTree. Set here (not in ApplyAndAck) so both the fresh-apply and bootstrap-restore - // paths — which both route through this method — leave a current composition. + // Cache the applied composition LAST. Set here (not in ApplyAndAck) so both the fresh-apply and + // bootstrap-restore paths — which both route through this method — leave a current composition. _lastComposition = composition; - // Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared - // _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, an IN-PROCESS - // redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated) - // would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect - // and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor - // is freshly constructed so _discoveredByDriver is empty — restart survival comes from the - // post-connect re-discovery loop, NOT this re-apply. Re-resolve each cached driver's candidate equipments - // from the CURRENT composition (the SAME EquipmentNodes-UNION-EquipmentTags logic HandleDiscoveredNodes - // uses), then validate each cached (equipmentId → plan) entry PER ENTRY: drop the entry if its - // equipmentId is no longer a resolved candidate for the driver, OR the plan's NodeIds aren't scoped to - // that equipmentId (a rebind). A driver whose inner map empties out is removed entirely. The surviving - // entries are re-applied via the single-send-per-driver structure. (The single-equipment case today has - // exactly one inner entry; the multi-device task adds more.) - foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below - { - var fromNodes = composition.EquipmentNodes - .Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(e => e.EquipmentId); - var fromTags = composition.EquipmentTags - .Where(t => string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(t => t.EquipmentId); - var candidates = fromNodes.Concat(fromTags).ToHashSet(StringComparer.Ordinal); - - var plansByEquipment = _discoveredByDriver[driverId]; - // Track whether ANY entry was dropped (no-longer-candidate or rebind) so we can re-trigger this - // driver's discovery exactly ONCE after the inner map is processed (see the post-loop block). - var droppedAny = false; - foreach (var equipmentId in plansByEquipment.Keys.ToList()) // snapshot — we mutate the inner dict - { - var plan = plansByEquipment[equipmentId]; - if (!candidates.Contains(equipmentId)) - { - plansByEquipment.Remove(equipmentId); - droppedAny = true; - _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment no longer resolves", _localNode, driverId, equipmentId); - continue; - } - // If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop + - // let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...". - var planEquipmentConsistent = plan.Variables.Count > 0 - && plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal); - if (!planEquipmentConsistent) - { - plansByEquipment.Remove(equipmentId); - droppedAny = true; - _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment rebound", _localNode, driverId, equipmentId); - } - } - - // Re-trigger discovery when ANY entry was dropped (no-longer-candidate or rebind). A CONFIG-UNCHANGED - // rebind (the driver's DriverConfig is identical, only its authored tag's EquipmentId moved) is NOT - // restarted by ReconcileDrivers — the child stays Connected — so without this nudge the FixedTree - // subtree would stay ABSENT under the new equipment until the driver's next natural reconnect. We now - // ask the child to re-run discovery so it re-grafts promptly: the next pass resolves against the new - // _lastComposition (the now-bound equipment). This is a DISCOVERY action, not lifecycle control — no - // stop/restart; it is idempotent, and the child no-ops it if not Connected (handled in - // DriverInstanceActor). Sent at most ONCE per driver per re-inject pass (here, after the inner map is - // processed — so even when the inner map empties below), guarded on the child still existing. - if (droppedAny && _children.TryGetValue(driverId, out var rediscoverEntry)) - rediscoverEntry.Actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - if (plansByEquipment.Count == 0) - { - _discoveredByDriver.Remove(driverId); - // Drop the driver's partition warn-signature too so a permanently-removed/rebound driver doesn't - // leak a stale entry (log-level-only state; bounded by driver count — just tidiness). - _lastPartitionWarnSignature.Remove(driverId); - // FALLBACK (one-send invariant): this driver was SKIPPED in the bulk loop (it was cached), and its - // plan is now FULLY DROPPED — so ApplyDiscoveredPlansForDriver won't run for it and it would - // otherwise receive ZERO sends this pass, losing its AUTHORED subscriptions. Send the authored-only - // set NOW (the SAME payload the bulk loop computes), so the authored tags subscribe in THIS pass. - // (The TriggerRediscovery above handles the async FixedTree re-graft separately; this just keeps - // the authored values live meanwhile.) Guarded on the child still existing — a driver removed by - // ReconcileDrivers has no child and correctly gets no send. Shares SendAuthoredOnly with the bulk - // loop so the payload can't drift; a ZERO-authored driver sends an empty set → Unsubscribe (drops - // the stale FixedTree handle without a spurious subscribe). - if (_children.TryGetValue(driverId, out var fallbackEntry)) - SendAuthoredOnly(fallbackEntry.Actor, driverId); - continue; - } - ApplyDiscoveredPlansForDriver(driverId, plansByEquipment); - } } private void SpawnChild(DriverInstanceSpec spec) @@ -2479,7 +2015,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers try { driver = _driverFactory.TryCreate(spec.DriverType, spec.DriverInstanceId, spec.DriverConfig); } catch (Exception ex) { - _log.Warning(ex, "DriverHost {Node}: factory for {Type} threw on {Id}; stubbing", + // A factory throw is a CONFIG error, not a connectivity one — TryCreate is pure parsing; + // device I/O happens later in InitializeAsync. Logged at Error because the node silently + // degrades to a stub that answers nothing, and since #516 routed DriverConfig changes + // through a respawn this path is now reachable by an ordinary operator edit rather than + // only by a brand-new driver. It still does NOT fail the deployment — making it do so is + // a deliberate follow-up, since it would let one malformed driver block a fleet deploy. + _log.Error(ex, + "DriverHost {Node}: factory for {Type} REJECTED the config for {Id} — the driver is " + + "stubbed and will serve nothing until the config is fixed and redeployed", _localNode, spec.DriverType, spec.DriverInstanceId); } if (driver is null) @@ -2559,15 +2103,54 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Context.Stop(adapter); } + /// + /// Sends an in-place config delta to a running child. + /// Unreachable from the reconcile path since #516 — a DriverConfig change is now a + /// stop + respawn (), so ToApplyDelta is always empty. + /// Kept because the seam is still exercised by DriverInstanceActor's own mid-connect config + /// adoption. + /// Two seals were removed here. This used to overwrite the cached + /// Spec SYNCHRONOUSLY, before the child had even dequeued the message — so the host + /// immediately believed the new config was live, the NEXT reconcile computed no delta against it, + /// and any drift was sealed permanently. It also Telld with no Receive<ApplyResult> + /// handler registered, so a FAILED reinit — including Galaxy's deliberate + /// NotSupportedException — dead-lettered and was never surfaced. + /// private void ApplyChildDelta(DriverInstanceSpec spec) { if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return; - entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, CorrelationId.NewId())); - // Store the full new spec — a delta can change Name, Enabled, ClusterId, etc. in addition to config. - _children[spec.DriverInstanceId] = entry with { Spec = spec }; + var correlation = CorrelationId.NewId(); + _pendingDelta[correlation] = spec; + entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, correlation), Self); _log.Debug("DriverHost {Node}: ApplyDelta queued for {Id}", _localNode, spec.DriverInstanceId); } + /// + /// A child's reply to . On success the cached Spec is advanced — + /// only now, when the child has actually adopted the config, so a failed reinit leaves the host + /// believing the OLD config is live and the next reconcile re-attempts the change instead of + /// silently treating the drift as applied. A failure is logged at Error rather than swallowed. + /// + private void HandleApplyResult(DriverInstanceActor.ApplyResult msg) + { + if (msg.Success) + { + if (_pendingDelta.Remove(msg.Correlation, out var spec) + && _children.TryGetValue(spec.DriverInstanceId, out var entry)) + { + // A delta can change Name, Enabled, ClusterId etc. alongside the config. + _children[spec.DriverInstanceId] = entry with { Spec = spec }; + } + return; + } + + _pendingDelta.Remove(msg.Correlation, out var failed); + _log.Error( + "DriverHost {Node}: driver {Id} REJECTED an in-place config change ({Reason}) — the host keeps " + + "the previous config so the next reconcile re-attempts it", + _localNode, failed?.DriverInstanceId ?? "", msg.Reason ?? "no reason given"); + } + /// /// A driver child finished applying an in-place (its /// completed). Re-register THAT driver's dependency-consumer diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index c0ca7f97..19e2331e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -1,5 +1,6 @@ using Akka.Actor; using Akka.Event; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; @@ -32,15 +33,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10); - /// Default interval between bounded post-connect re-discovery passes. - public static readonly TimeSpan DefaultRediscoverInterval = TimeSpan.FromSeconds(2); - - /// Default cap on the number of post-connect re-discovery passes. - public const int DefaultRediscoverMaxAttempts = 15; - - /// Default per-pass timeout for during - /// bounded post-connect re-discovery. Bounds the mailbox suspension time; production default 30 s. - public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30); + /// Default period of the tag-set drift check. Deliberately slow: it browses a real device, and + /// a tag set changing is an engineering event (a PLC re-download, a CNC option install), not a runtime + /// one. Five minutes bounds the wire cost while still surfacing drift the same shift it happens. + public static readonly TimeSpan DefaultDriftCheckInterval = TimeSpan.FromMinutes(5); public sealed record InitializeRequested(string DriverConfigJson); public sealed record InitializeSucceeded(int Generation); @@ -124,25 +120,22 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// subscription that un-gates an driver's feed. Handled async so the /// call is bounded + off the synchronous handlers. private sealed record SubscribeAlarms; - /// Published to the parent (DriverHostActor) after each post-connect discovery pass so it can - /// graft the driver's discovered FixedTree nodes under the equipment. Empty/duplicate sets are fine — - /// the parent dedups and injection is idempotent. - public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList Nodes); + /// Self-sent when the wrapped driver raises — + /// it observed that the remote's tag set may have changed. Marshals the event off the driver's thread + /// onto the actor thread. Handled in EVERY behaviour, including Stubbed and Reconnecting: the raise has no + /// connection affinity (a Galaxy redeploy or a TwinCAT symbol-version bump can land while the driver is + /// between connects), and dropping it in one state would lose the signal silently. + private sealed record RediscoveryRaised(RediscoveryEventArgs Args); - /// - /// Sent by to ask this driver child to re-run post-connect discovery - /// after the host rebinds the driver to a new equipment. Handled only in Connected, where it - /// re-kicks — which already honours the driver's - /// and the guard, tagging the - /// fresh pass with the current init generation. In any non-Connected state it is a deliberate no-op: - /// the driver's eventual (re)connect re-discovers anyway, so there is nothing to do and nothing to log. - /// - public sealed record TriggerRediscovery; + /// Self-sent on a slow timer to re-browse a genuinely-browsable driver and compare what the + /// remote offers against what an operator authored. Only ever scheduled for a driver that opts in — see + /// . + private sealed record DriftCheckTick + { + public static readonly DriftCheckTick Instance = new(); + private DriftCheckTick() { } + } - /// Internal self-tick driving bounded post-connect re-discovery (FixedTree populates ~0–2s after connect). - /// is the ordered-distinct full-reference signature of the prior pass's - /// captured set (empty string on the first tick); re-discovery stops once a non-empty set repeats it. - private sealed record RediscoverTick(int Generation, int Attempt, string PreviousSignature); public sealed class RetryConnect { public static readonly RetryConnect Instance = new(); @@ -167,19 +160,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers private readonly IDriverHealthPublisher _healthPublisher; private readonly TimeSpan _reconnectInterval; - /// Interval between bounded post-connect re-discovery passes. Production default 2s; tests - /// inject a tiny value so the loop runs without real-time waits. - private readonly TimeSpan _rediscoverInterval; private readonly TimeSpan _healthPollInterval; - /// Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising - /// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15. - private readonly int _rediscoverMaxAttempts; - - /// Per-pass timeout for during bounded post-connect - /// re-discovery. Bounds the mailbox suspension time. Production default 30 s; tests may inject a shorter - /// value. Stored to allow injection rather than hardcoding. - private readonly TimeSpan _rediscoverDiscoverTimeout; private readonly ILoggingAdapter _log = Context.GetLogger(); private string? _currentConfigJson; @@ -203,6 +185,24 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers private ISubscriptionHandle? _subscriptionHandle; private EventHandler? _dataChangeHandler; private EventHandler? _alarmEventHandler; + private EventHandler? _rediscoveryHandler; + + /// When the driver last raised , and the reason + /// it gave. Null until the first raise. Carried on every subsequent health snapshot so the AdminUI can + /// prompt an operator to re-browse the device. + /// Deliberately sticky — it is not cleared on reconnect or on a later clean pass. The remote's + /// tag set stayed changed; only an operator re-browsing and committing resolves it, and this actor cannot + /// observe that happening. A redeploy respawns the child, which clears it naturally. + private DateTime? _rediscoveryNeededUtc; + private string? _rediscoveryReason; + + /// Period of the tag-set drift check; tests inject a tiny value. + private readonly TimeSpan _driftCheckInterval; + + /// Signature of the last drift REPORTED, so an unchanged drift is not re-announced on every + /// check. Without this the operator's prompt would re-stamp its timestamp every interval forever, + /// which reads as "it just happened again" rather than "it is still true". + private string? _lastDriftSignature; /// The references the host wants kept subscribed (set by ). /// Re-applied on every entry into Connected so values resume after a reconnect or redeploy. @@ -235,11 +235,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// stub paths don't need to provide one. /// Optional cluster identifier forwarded in messages; /// defaults to an empty string when not provided (e.g. in unit tests). - /// Optional interval between post-connect re-discovery passes; defaults to 2 seconds. - /// Optional cap on re-discovery passes; defaults to 15. - /// Optional per-pass timeout for ; defaults to 30 seconds. /// Optional Phase 6.1 resilience invoker wrapping this driver's capability /// calls; defaults to (pass-through) when not supplied. + /// Optional period of the tag-set drift check; defaults to + /// . Tests inject a tiny value so the check runs without a wait. /// Optional period of the health-poll heartbeat, which also drives the /// subscription reconcile (); defaults to /// . Exists so a test can drive the reconcile without waiting 30 s. @@ -250,22 +249,18 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers bool startStubbed = false, IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, - TimeSpan? rediscoverInterval = null, - int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, - TimeSpan? rediscoverDiscoverTimeout = null, IDriverCapabilityInvoker? invoker = null, - TimeSpan? healthPollInterval = null) => + TimeSpan? healthPollInterval = null, + TimeSpan? driftCheckInterval = null) => Akka.Actor.Props.Create(() => new DriverInstanceActor( driver, reconnectInterval ?? DefaultReconnectInterval, startStubbed, healthPublisher ?? NullDriverHealthPublisher.Instance, clusterId ?? string.Empty, - rediscoverInterval, - rediscoverMaxAttempts, - rediscoverDiscoverTimeout, invoker, - healthPollInterval)); + healthPollInterval, + driftCheckInterval)); /// /// Returns true when the driver should boot in DEV-STUB mode based on host platform and @@ -294,11 +289,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// If true, start in stub mode for testing or unavailable platforms. /// Sink for health-change notifications; must not be null. /// Cluster identifier forwarded in health snapshots. - /// Interval between post-connect re-discovery passes; defaults to 2 seconds. - /// Cap on the number of re-discovery passes; defaults to 15. - /// Per-pass timeout for ; defaults to 30 seconds. /// Phase 6.1 resilience invoker wrapping this driver's capability calls; /// defaults to (pass-through) when null. + /// Period of the tag-set drift check; defaults to + /// . /// Period of the health-poll heartbeat, which also drives the /// subscription reconcile; defaults to when null. public DriverInstanceActor( @@ -307,21 +301,17 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers bool startStubbed = false, IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, - TimeSpan? rediscoverInterval = null, - int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, - TimeSpan? rediscoverDiscoverTimeout = null, IDriverCapabilityInvoker? invoker = null, - TimeSpan? healthPollInterval = null) + TimeSpan? healthPollInterval = null, + TimeSpan? driftCheckInterval = null) { _driver = driver; _invoker = invoker ?? NullDriverCapabilityInvoker.Instance; _driverInstanceId = driver.DriverInstanceId; _clusterId = clusterId ?? string.Empty; _healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance; + _driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval; _reconnectInterval = reconnectInterval; - _rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval; - _rediscoverMaxAttempts = rediscoverMaxAttempts; - _rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout; _healthPollInterval = healthPollInterval ?? HealthPollInterval; OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, new KeyValuePair("event", startStubbed ? "spawn_stub" : "spawn"), @@ -344,6 +334,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // Warm up the snapshot store immediately so AdminUI sees current state as soon as the // actor starts, before any state transition fires. Also start the periodic heartbeat so // long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients. + // Attach the rediscovery signal before the first publish. Not per-connect: an IRediscoverable raise + // has no connection affinity, and a driver can observe a remote change while disconnected. + AttachRediscoverySource(); PublishHealthSnapshot(); Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval); } @@ -362,9 +355,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers Receive(StoreDesiredSubscriptions); // Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a // re-discovery self-tick is ever routed here so it doesn't surface as an Akka Unhandled message. - Receive(_ => { }); - // A TriggerRediscovery is meaningless to a stubbed (never-Connected) driver — silently ignore it. - Receive(_ => { }); + Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); } @@ -390,7 +381,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers ResubscribeDesired(); AttachAlarmSource(); SubscribeDesiredAlarms(); - StartDiscovery(); + StartDriftCheck(); }); Receive(msg => { @@ -418,12 +409,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into // this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes. Receive(_ => { }); - // Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an - // already-queued disconnect; swallow it — the next Connected entry re-kicks discovery. - Receive(_ => { }); - // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path - // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event). - Receive(_ => { }); + Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); } @@ -439,34 +425,20 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { _log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting", _driverInstanceId, msg.Reason); - Timers.Cancel("rediscover"); DetachSubscription(); RecordFault(); + StopDriftCheck(); Become(Reconnecting); PublishHealthSnapshot(); }); Receive(_ => { _log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId); - Timers.Cancel("rediscover"); DetachSubscription(); + StopDriftCheck(); Become(Reconnecting); PublishHealthSnapshot(); }); - ReceiveAsync(HandleRediscoverAsync); - // The host asks for a fresh discovery pass after rebinding the driver to a new equipment. Cancel any - // pending rediscover tick FIRST — mirroring ForceReconnect/DisconnectObserved — so a stale tick left - // over from the prior loop can't fire alongside the freshly-kicked one, then re-kick the bounded loop - // via StartDiscovery (honours RediscoverPolicy + the ITagDiscovery guard, tagged with the current - // _initGeneration). Only handled here in Connected — non-Connected states no-op it below. A stale tick - // that still slips through (one already mid-async-handler) is benign: the parent dedups - // DiscoveredNodesReady and node injection is idempotent — the Cancel just avoids the avoidable double - // pass in the common case. - Receive(_ => - { - Timers.Cancel("rediscover"); - StartDiscovery(); - }); ReceiveAsync(HandleWriteAsync); ReceiveAsync(HandleAcknowledgeAsync); ReceiveAsync(HandleSubscribeAsync); @@ -486,6 +458,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers SubscribeDesiredAlarms(); }); ReceiveAsync(HandleSubscribeAlarmsAsync); + ReceiveAsync(HandleDriftCheckAsync); Receive(OnDataChangeForward); // Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent; // project it to the parent the same way DataChangeForward projects AttributeValuePublished. @@ -499,6 +472,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter. Receive(msg => _log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason)); + Receive(HandleRediscoveryRaised); Receive(_ => { PublishHealthSnapshot(); @@ -579,7 +553,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers ResubscribeDesired(); AttachAlarmSource(); SubscribeDesiredAlarms(); - StartDiscovery(); // re-run discovery on reconnect — keeps the injected tree fresh if the backend's capabilities changed + StartDriftCheck(); }); // A failure here is a no-op regardless of generation — the retry timer keeps trying the // current config; only a (generation-matched) InitializeSucceeded transitions state. @@ -602,12 +576,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into // this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes. Receive(_ => { }); - // Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an - // already-queued disconnect; swallow it — the next Connected entry re-kicks discovery. - Receive(_ => { }); - // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path - // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event). - Receive(_ => { }); + Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval); } @@ -829,6 +798,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } } + /// Stops the drift check. Called on every Connected exit — browsing a disconnected driver would + /// fail every pass, and a failed browse is deliberately NOT treated as drift. + private void StopDriftCheck() => Timers.Cancel("drift-check"); + /// Tear down the data-change + native-alarm event handlers + null the handle. Called from the /// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't /// push data-change / alarm events to an actor that has lost its driver connection. @@ -853,6 +826,157 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers src.OnAlarmEvent += _alarmEventHandler; } + /// Subscribe the driver's (if it is one), + /// marshaling each raise to the actor thread. Idempotent; mirrors . + /// Attached once in PreStart rather than per-connect, because the interesting raises + /// (a Galaxy redeploy, a TwinCAT symbol-version bump) can happen while the driver is between + /// connects, and the event carries no connection affinity. + private void AttachRediscoverySource() + { + if (_driver is not IRediscoverable src || _rediscoveryHandler is not null) return; + var self = Self; + _rediscoveryHandler = (_, e) => self.Tell(new RediscoveryRaised(e)); + src.OnRediscoveryNeeded += _rediscoveryHandler; + } + + /// Symmetric teardown, called from PostStop. Load-bearing: the instance + /// can OUTLIVE this actor (the host respawns a child around the same driver object), so a missing + /// unsubscribe would accumulate one handler per respawn, each holding a dead Self. + private void DetachRediscoverySource() + { + if (_driver is IRediscoverable src && _rediscoveryHandler is not null) + src.OnRediscoveryNeeded -= _rediscoveryHandler; + _rediscoveryHandler = null; + } + + /// + /// True when this driver is worth drift-checking: it must genuinely BROWSE its remote + /// () and must report the refs its authored tags + /// bind (). + /// The first condition is what makes the check meaningful rather than reassuring. Most drivers + /// — Modbus, S7, AbLegacy, Sql, Mqtt — stream their AUTHORED tags back out of DiscoverAsync + /// rather than enumerating the device, so diffing the two sets for them is a tautology that would + /// report "no drift" forever. A check that cannot fail is worse than no check: it looks like + /// coverage. + /// + private bool QualifiesForDriftCheck() => + _driver is ITagDiscovery { SupportsOnlineDiscovery: true, AuthoredDiscoveryRefs: not null }; + + /// Starts the slow drift check on a Connected entry, for qualifying drivers only. + private void StartDriftCheck() + { + if (!QualifiesForDriftCheck()) return; + // Periodic, not fire-on-connect: a driver whose discovered shape fills in asynchronously after + // connect (the FOCAS FixedTree) would otherwise be compared against a half-populated browse and + // report phantom drift on every reconnect. + Timers.StartPeriodicTimer("drift-check", DriftCheckTick.Instance, _driftCheckInterval); + } + + /// + /// Re-browses the remote and compares it against the driver's authored refs. A CHANGED drift raises + /// the same operator prompt an raise does — this is the signal for the + /// browsable drivers that have no native change notification (AbCip, FOCAS), where a periodic + /// compare is the only way to notice a PLC program re-download. + /// Advisory, like every rediscovery signal: the served address space is not touched. Raw tags + /// are authored through the /raw browse-commit flow, so an operator re-browses and commits. + /// + private async Task HandleDriftCheckAsync(DriftCheckTick _) + { + if (_driver is not ITagDiscovery discovery) return; + var authored = discovery.AuthoredDiscoveryRefs; + if (authored is null) return; + + CapturedTree tree; + try + { + var builder = new CapturingAddressSpaceBuilder(); + // Bounded: ReceiveAsync suspends the mailbox for the whole handler, so an unbounded browse would + // block writes, reconnects and health polls behind it. + using var cts = new CancellationTokenSource(DriftBrowseTimeout); + await _invoker.ExecuteAsync( + DriverCapability.Discover, + _driverInstanceId, + async ct => await discovery.DiscoverAsync(builder, ct), + cts.Token); + tree = builder.Build(); + } + catch (Exception ex) + { + // A failed browse is not drift — the device may simply be unreachable, which the health surface + // already reports. Reporting drift here would turn every comms blip into "all your tags vanished". + _log.Debug(ex, "DriverInstance {Id}: drift check browse failed; skipping this pass", _driverInstanceId); + return; + } + + if (tree.Truncated) + { + // A truncated capture is a PARTIAL view, so every un-captured authored ref would look vanished. + _log.Warning( + "DriverInstance {Id}: drift check skipped — the browse hit the capture cap and is partial", + _driverInstanceId); + return; + } + + var discovered = new List(); + CollectLeafRefs(tree.Root, discovered); + var drift = TagSetDriftDetector.Compare( + discovered, authored, detectVanished: !discovery.DiscoveryStreamIncludesAuthoredTags); + + if (!drift.HasDrift) + { + // Recovered: the device matches the authored set again, so drop the prompt and let the next + // genuine drift re-raise with a fresh timestamp. + if (_lastDriftSignature is not null) + { + _log.Info("DriverInstance {Id}: tag-set drift cleared — device matches the authored set", + _driverInstanceId); + _lastDriftSignature = null; + _rediscoveryNeededUtc = null; + _rediscoveryReason = null; + PublishHealthSnapshot(); + } + + return; + } + + // Report a CHANGED drift once. An unchanged one re-stamping its timestamp every interval would read + // as "it just happened again" rather than "it is still true". + if (string.Equals(_lastDriftSignature, drift.Signature, StringComparison.Ordinal)) return; + _lastDriftSignature = drift.Signature; + _rediscoveryNeededUtc = DateTime.UtcNow; + _rediscoveryReason = "device tag set differs from authored: " + drift.Describe(); + _log.Info( + "DriverInstance {Id}: tag-set drift detected ({Drift}) — surfaced for operator re-browse; the served address space is unchanged", + _driverInstanceId, drift.Describe()); + PublishHealthSnapshot(); + } + + /// Per-pass timeout for the drift browse. Bounds the mailbox suspension. + private static readonly TimeSpan DriftBrowseTimeout = TimeSpan.FromSeconds(30); + + /// Flattens a captured tree to its leaf ids — the driver-side FullNames, which is the + /// vocabulary AuthoredDiscoveryRefs is defined in. + private static void CollectLeafRefs(CapturedNode node, List into) + { + if (node.IsLeaf) into.Add(node.Id); + foreach (var child in node.Children) CollectLeafRefs(child, into); + } + + /// Records the driver's rediscovery raise and re-publishes health so the signal reaches the + /// AdminUI promptly rather than waiting for the next 30 s heartbeat. + /// Advisory only. The served address space is deliberately NOT rebuilt: v3 authors raw tags + /// explicitly through the /raw browse-commit flow, so a runtime graft would create nodes no + /// operator approved and that no deployment artifact records. This prompts a human to re-browse. + private void HandleRediscoveryRaised(RediscoveryRaised msg) + { + _rediscoveryNeededUtc = DateTime.UtcNow; + _rediscoveryReason = msg.Args.Reason; + _log.Info( + "DriverInstance {Id}: driver reports its tag set may have changed ({Reason}) — surfaced for operator re-browse; the served address space is unchanged", + _driverInstanceId, msg.Args.Reason); + PublishHealthSnapshot(); + } + /// Symmetric teardown — called from and PostStop so a stale /// handler never pushes to a disconnected actor. private void DetachAlarmSource() @@ -909,97 +1033,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } } - /// Kick the bounded post-connect re-discovery loop on a Connected entry. A no-op unless the - /// driver exposes (nothing to inject otherwise). Self-sends the first - /// tagged with the current init generation so a tick that outlives a reconnect - /// is rejected by the generation guard in . - /// Honours the driver's : Never opts out entirely - /// (no tick scheduled); Once runs a single pass (the loop stops after the first publish in - /// ); UntilStable retries each (re)connect, bounded by - /// stop-on-stable (the discovered-set signature repeats) + the attempt cap. - private void StartDiscovery() - { - if (_driver is not ITagDiscovery discovery) return; // driver doesn't expose discovery — nothing to inject - if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Never) - { - // Driver opts out of post-connect discovery — don't even schedule the first tick. - _log.Debug("DriverInstance {Id}: RediscoverPolicy=Never — skipping post-connect discovery", _driverInstanceId); - return; - } - Self.Tell(new RediscoverTick(_initGeneration, Attempt: 0, PreviousSignature: string.Empty)); - } - - /// Runs one post-connect discovery pass: captures the driver's streamed FixedTree via a - /// and ships the result to the parent as - /// (empty/duplicate sets are fine — the parent dedups and injection - /// is idempotent). Retries on the until the non-empty discovered SET - /// has STABILISED (the ordered-distinct full-reference signature repeats — robust for incremental/paged - /// browsers where a count alone could falsely settle a partial tree) or the - /// cap is hit, whichever comes first; keeps retrying while empty because a FOCAS-style FixedTree cache may - /// still be populating. - /// Limitation: this assumes a driver's discovered set only GROWS toward a stable shape (true for - /// FOCAS — its FixedTree appears once, and on the wonder deploy the driver-config _options.Tags is - /// empty so the set is 0 until the cache populates). A driver that emits an initial non-empty set and - /// later grows could stop early on a transient repeat; acceptable for current scope. - private async Task HandleRediscoverAsync(RediscoverTick tick) - { - if (tick.Generation != _initGeneration) return; // stale (a reconnect superseded this pass) - if (_driver is not ITagDiscovery discovery) return; - - IReadOnlyList nodes; - try - { - var builder = new CapturingAddressSpaceBuilder(); - // Bound the browse — ReceiveAsync suspends the mailbox for the whole handler, so an unbounded - // DiscoverAsync would block DisconnectObserved / ForceReconnect / writes / health-poll behind it. - using var cts = new CancellationTokenSource(_rediscoverDiscoverTimeout); - // NO ConfigureAwait(false) on this outer await: a genuinely-async DiscoverAsync (Galaxy / - // OpcUaClient / TwinCAT) must resume on the actor task scheduler so the Context.Parent.Tell + - // Timers calls below run with a live ActorContext. ConfigureAwait(false) would resume - // off-context and throw NotSupportedException("no active ActorContext"). The invoker's own - // internal ConfigureAwait(false) does NOT propagate to this caller's await — the actor - // continuation still resumes on the captured actor scheduler. (Discover retries per tier.) - await _invoker.ExecuteAsync( - DriverCapability.Discover, - _driverInstanceId, - async ct => await discovery.DiscoverAsync(builder, ct), - cts.Token); - nodes = builder.Nodes.ToArray(); // immutable snapshot — never hand the builder's live list across actors - } - catch (Exception ex) - { - _log.Warning(ex, "DriverInstance {Id}: discovery pass {Attempt} failed; will retry", _driverInstanceId, tick.Attempt); - nodes = Array.Empty(); - } - - // Belt-and-suspenders: under ReceiveAsync the mailbox is suspended for the whole handler, so - // _initGeneration cannot change mid-await — the pre-await guard + Timers.Cancel("rediscover") on - // disconnect + single-timer key reuse are the primary protections. Re-checked in case that changes. - if (tick.Generation != _initGeneration) return; - - Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, nodes)); - - // Honour the driver's re-discovery policy. A Once driver runs a single post-connect pass per - // (re)connect regardless of whether DiscoverAsync is synchronous or async — one published pass is - // complete, so the retry loop is skipped (no further tick scheduled). (Never never reaches here — - // StartDiscovery returns before the first tick.) UntilStable falls through to the stop-on-stable + - // attempt-cap logic below. - if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Once) - { - _log.Debug("DriverInstance {Id}: RediscoverPolicy=Once — single discovery pass, not scheduling another", _driverInstanceId); - return; - } - - // Stop when the non-empty discovered SET has stabilised (its signature repeats), or the attempt cap - // is hit. Keep retrying while empty (a FixedTree cache may still be populating). First tick carries "". - var signature = string.Join('\u0001', - nodes.Select(n => n.FullReference).Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal)); - var stableNonEmpty = nodes.Count > 0 && string.Equals(signature, tick.PreviousSignature, StringComparison.Ordinal); - if (tick.Attempt + 1 < _rediscoverMaxAttempts && !stableNonEmpty) - Timers.StartSingleTimer("rediscover", new RediscoverTick(tick.Generation, tick.Attempt + 1, signature), _rediscoverInterval); - else - _log.Debug("DriverInstance {Id}: discovery settled after {Attempt} pass(es), {Count} node(s)", _driverInstanceId, tick.Attempt + 1, nodes.Count); - } /// Records the host's desired subscription set without touching the live subscription. /// The set is (re)applied by on the next Connected entry. @@ -1081,11 +1114,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { var health = _driver.GetHealth(); var errorCount = ErrorCount5Min(); - var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount); + // _rediscoveryNeededUtc is PART OF THE FINGERPRINT on purpose. A rediscovery raise on an + // otherwise-unchanged Healthy driver leaves (state, lastSuccess, lastError, errorCount) + // identical, so without it the dedup below would swallow the very publish that carries the + // signal and the operator would never see the prompt. + var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount, _rediscoveryNeededUtc); if (_lastPublishedFingerprint is { } prev && prev.Equals(fingerprint)) return; _lastPublishedFingerprint = fingerprint; - _healthPublisher.Publish(_clusterId, _driverInstanceId, health, errorCount); + _healthPublisher.Publish( + _clusterId, _driverInstanceId, health, errorCount, _rediscoveryNeededUtc, _rediscoveryReason); } catch (Exception ex) { @@ -1094,12 +1132,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } /// Fingerprint of the last call; null until first publish. - private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount)? _lastPublishedFingerprint; + private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount, DateTime? RediscoveryNeededUtc)? _lastPublishedFingerprint; /// protected override void PostStop() { + StopDriftCheck(); DetachSubscription(); + // MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the + // same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self. + DetachRediscoverySource(); try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); } catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); } OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs index 5399691f..37ba98ab 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs @@ -7,7 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// spawn / ApplyDelta / stop on its child actors accordingly. /// /// Specs with no current child — create a new actor. -/// Specs whose child exists but config JSON or type differs. +/// +/// In-place config deltas. Always empty since #516 — a DriverConfig change is now a +/// stop + respawn so the factory is the single parse authority. Retained because +/// DriverInstanceActor still handles ApplyDelta on its own config-adoption path +/// (a config arriving mid-connect), and removing the list would hide that seam. +/// /// DriverInstanceIds currently running but missing from the new artifact, or now disabled. public sealed record DriverSpawnPlan( IReadOnlyList ToSpawn, @@ -42,23 +47,33 @@ public static class DriverSpawnPlanner toStop.Add(id); continue; } - // A driver TYPE change can't be reinitialized in-place (factory-bound) — stop + respawn. - // A RESILIENCE-CONFIG change likewise forces a respawn: the CapabilityInvoker (and its - // resolved options) is bound to the child actor at spawn time, so the only way a changed - // ResilienceConfig takes effect is to rebuild the child. The factory invalidates the stale - // cached pipelines on the respawn's Create call. (A pure DriverConfig change stays an - // in-place delta — no reconnect — because it doesn't touch the resilience pipeline.) + // ANY of the three config surfaces changing forces a stop + respawn, so the FACTORY is the + // single parse authority for everything a driver is built from. + // + // • DriverType — factory-bound, can't be reinitialized in place. + // • ResilienceConfig — the CapabilityInvoker (and its resolved options) binds to the child + // actor at spawn time; the factory invalidates the stale cached pipelines on respawn. + // • DriverConfig — was an in-place ApplyDelta until #516. It is now a respawn. + // + // #516: the in-place delta silently DISCARDED the edit on five drivers (Modbus, FOCAS, + // OpcUaClient, AbLegacy, Sql), whose InitializeAsync served the options captured by their + // constructor and never looked at the driverConfigJson they were handed. The deployment still + // sealed green. Those five now re-parse (belt), and this respawn is the braces: several + // drivers build MORE than options from config — Sql derives its dialect + connection string + // and FOCAS its client-factory backend, both injected at construction — so an in-place + // re-parse of options ALONE would apply a new tag set against an old connection. Only a + // respawn rebuilds all of it. + // + // The accepted cost is a reconnect on every DriverConfig edit, replacing the prior + // no-reconnect in-place path. That is deliberate: a correct reconnect beats a silent no-op. if (!string.Equals(snap.DriverType, spec.DriverType, StringComparison.Ordinal) - || !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal)) + || !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal) + || !string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal)) { toStop.Add(id); toSpawn.Add(spec); continue; } - if (!string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal)) - { - toDelta.Add(spec); - } } foreach (var (id, spec) in targetById) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs new file mode 100644 index 00000000..7a2e0015 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs @@ -0,0 +1,92 @@ +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// +/// The difference between what a driver discovers on its remote and what an operator +/// authored — i.e. whether the device's tag set has moved out from under the deployed config. +/// +/// Refs the remote now offers that no authored tag binds. +/// +/// Refs authored tags bind that the remote no longer offers. Always empty when +/// is false — see that parameter. +/// +/// +/// False when the driver re-emits its authored tags into the same discovery stream as the device-derived +/// ones (ITagDiscovery.DiscoveryStreamIncludesAuthoredTags). The discovered set then always +/// contains the authored set, so a vanished tag cannot be seen. Carried explicitly so +/// can say "new-on-device only" rather than implying a clean bill of health. +/// +public sealed record TagSetDrift( + IReadOnlyList Appeared, + IReadOnlyList Vanished, + bool VanishedDetectable) +{ + /// True when the two sets differ in either direction. + public bool HasDrift => Appeared.Count > 0 || Vanished.Count > 0; + + /// + /// A stable identity for this drift, used to report a CHANGED drift once rather than re-reporting an + /// unchanged one on every check. Ordinal-ordered so set enumeration order cannot make an identical + /// drift look new. + /// + public string Signature { get; } = + string.Join('', Appeared) + '' + string.Join('', Vanished); + + /// An operator-facing one-liner naming what moved, suitable for a UI tooltip. + /// A short human-readable description, or "no drift" when there is none. + public string Describe() + { + if (!HasDrift) return "no drift"; + var parts = new List(3); + if (Appeared.Count > 0) parts.Add($"{Appeared.Count} new on device (e.g. {Sample(Appeared)})"); + if (Vanished.Count > 0) parts.Add($"{Vanished.Count} authored missing (e.g. {Sample(Vanished)})"); + // Say so rather than let the absence of a "missing" clause read as "nothing is missing". + if (!VanishedDetectable) parts.Add("missing-tag detection unavailable for this driver"); + return string.Join("; ", parts); + } + + /// Names at most two refs so the message stays readable when a whole PLC program is swapped. + private static string Sample(IReadOnlyList refs) => + refs.Count <= 2 ? string.Join(", ", refs) : $"{refs[0]}, {refs[1]}, +{refs.Count - 2} more"; +} + +/// +/// Compares a driver's discovered refs against its authored refs. Pure and allocation-light so the +/// actor's periodic check stays cheap; kept out of DriverInstanceActor so it is directly testable +/// without an actor system. +/// +public static class TagSetDriftDetector +{ + /// + /// Computes the two-way difference. + /// Ordinal comparison, matching how every driver keys its tag tables — a device that + /// genuinely exposes both Speed and speed must not have them collapsed. + /// + /// Refs streamed by the driver's most recent discovery pass. + /// Refs the driver's authored tags bind (ITagDiscovery.AuthoredDiscoveryRefs). + /// + /// False when the driver re-emits authored tags into its discovery stream, which makes a vanished tag + /// structurally invisible. The vanished half is then not computed at all rather than computed to a + /// misleading empty — see . + /// + /// The drift, which may be empty. + public static TagSetDrift Compare( + IReadOnlyCollection discovered, + IReadOnlyCollection authored, + bool detectVanished = true) + { + ArgumentNullException.ThrowIfNull(discovered); + ArgumentNullException.ThrowIfNull(authored); + + var authoredSet = new HashSet(authored, StringComparer.Ordinal); + var discoveredSet = new HashSet(discovered, StringComparer.Ordinal); + + var appeared = discoveredSet.Where(r => !authoredSet.Contains(r)) + .OrderBy(r => r, StringComparer.Ordinal).ToArray(); + var vanished = detectVanished + ? authoredSet.Where(r => !discoveredSet.Contains(r)) + .OrderBy(r => r, StringComparer.Ordinal).ToArray() + : []; + + return new TagSetDrift(appeared, vanished, detectVanished); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 61a0019f..e3125339 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -85,15 +85,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers public sealed record RebuildAddressSpace( CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null); - /// Inject driver-discovered nodes (FixedTree) under an equipment at runtime (post-connect). - /// The OPC UA NodeId of the equipment root folder to inject the - /// discovered nodes under (e.g. "EQ-3686c0272279"); also the node the NodeAdded model-change is - /// announced under. - public sealed record MaterialiseDiscoveredNodes( - string EquipmentRootNodeId, - IReadOnlyList Folders, - IReadOnlyList Variables); - public sealed record ServiceLevelChanged(byte ServiceLevel); private readonly IOpcUaAddressSpaceSink _sink; @@ -284,7 +275,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers Receive(HandleAlarmUpdate); Receive(HandleAlarmQualityUpdate); Receive(HandleRebuild); - Receive(HandleMaterialiseDiscovered); Receive(HandleServiceLevelChanged); Receive(HandleRedundancyStateChanged); Receive(HandleDbHealthStatus); @@ -539,28 +529,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } } - /// Forwards driver-discovered (FixedTree) nodes to the applier so they are injected under - /// the equipment at runtime. No-op (logged) when no applier is wired (dev/Mac/legacy seam), matching the - /// optional-applier tolerance of . - private void HandleMaterialiseDiscovered(MaterialiseDiscoveredNodes msg) - { - if (_applier is null) - { - _log.Debug("OpcUaPublish: no applier wired — discarding MaterialiseDiscoveredNodes for {Equipment}", msg.EquipmentRootNodeId); - return; - } - var failedNodes = _applier.MaterialiseDiscoveredNodes(msg.EquipmentRootNodeId, msg.Folders, msg.Variables); - if (failedNodes > 0) - { - // archreview 01/S-1: a swallowed discovered-node injection failure surfaces at Error + the - // dedicated meter instead of vanishing into per-node Warnings. - OtOpcUaTelemetry.OpcUaApplyFailed.Add(1, new KeyValuePair("kind", "nodes")); - _log.Error( - "OpcUaPublish: discovered-node injection DEGRADED for {Equipment} (failedNodes={FailedNodes}) — some discovered nodes may be missing", - msg.EquipmentRootNodeId, failedNodes); - } - } - private void HandleServiceLevelChanged(ServiceLevelChanged msg) { // Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitConfigAdoptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitConfigAdoptionTests.cs new file mode 100644 index 00000000..4aaa3feb --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitConfigAdoptionTests.cs @@ -0,0 +1,49 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Gitea #516 — AbLegacyDriver.InitializeAsync used to serve the options its CONSTRUCTOR +/// captured and never look at the driverConfigJson it was handed, so an operator's config edit +/// was silently discarded while the deployment still sealed green. +/// Every pre-existing reinit test in this suite passes "{}" — the exact input the guarded +/// re-parser treats as "keep the constructor options" — so they were blind to the defect. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyReinitConfigAdoptionTests +{ + /// + /// The driver validates every device's HostAddress during init. Reinitializing with a config + /// whose device address is structurally invalid must therefore THROW — a driver still serving the + /// constructor's valid device list would validate that instead and succeed, which is precisely how + /// the discarded edit stayed invisible. + /// + [Fact] + public async Task Reinitialize_adopts_a_changed_device_list() + { + var driver = AbLegacyDriverFactoryExtensions.CreateInstance( + "ab1", + """{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""", + loggerFactory: null); + + await Should.ThrowAsync( + () => driver.ReinitializeAsync( + """{"devices":[{"hostAddress":"not-a-valid-ab-address","plcFamily":"Slc500"}]}""", + CancellationToken.None), + "AbLegacyDriver kept its constructor-supplied device list after a reinitialize with a changed config (#516)"); + } + + /// An empty/placeholder document must still keep the constructor options, so the many + /// lifecycle tests that pass "{}" keep meaning what they meant. + [Fact] + public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options() + { + var driver = AbLegacyDriverFactoryExtensions.CreateInstance( + "ab1", + """{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""", + loggerFactory: null); + + await Should.NotThrowAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitConfigAdoptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitConfigAdoptionTests.cs new file mode 100644 index 00000000..11057252 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitConfigAdoptionTests.cs @@ -0,0 +1,67 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Gitea #516 — FOCAS was excluded from the first pass because it derives MORE than options from config: +/// the Backend key selects its , injected at construction. Adopting +/// a new FocasDriverOptions alone would poll a NEW device/tag set through the OLD backend, which +/// looks like it worked and does not. +/// ParseBinding now returns options + backend together and InitializeAsync adopts both +/// or neither. These tests pin the BACKEND moving — the half that was missing — because a test that only +/// checked options would have passed against the broken version. +/// +[Trait("Category", "Unit")] +public sealed class FocasReinitConfigAdoptionTests +{ + /// + /// Reinitializing onto Backend: "unimplemented" must make the driver adopt that backend, whose + /// EnsureUsable() throws by design. A driver still holding the constructor's wire + /// backend would not throw — so the throw is a direct read of which backend is live. + /// + [Fact] + public async Task Reinitialize_adopts_a_changed_backend_not_just_the_options() + { + var driver = FocasDriverFactoryExtensions.CreateInstance( + "focas-1", """{"backend":"wire","devices":[]}"""); + + var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync( + """{"backend":"unimplemented","devices":[]}""", CancellationToken.None)); + + ex.ShouldBeOfType( + "FocasDriver adopted a reinitialized config but kept its ORIGINAL backend — the half-applied " + + "adoption (#516) that would poll a new device/tag set through the old backend"); + } + + /// The other direction: moving OFF the unimplemented backend must also take effect, so the + /// adoption is not a one-way latch. + [Fact] + public async Task Reinitialize_away_from_the_unimplemented_backend_also_takes_effect() + { + var driver = FocasDriverFactoryExtensions.CreateInstance( + "focas-1", """{"backend":"unimplemented","devices":[]}"""); + + // Sanity: the constructor-supplied backend is the throwing one. + (await Record.ExceptionAsync(() => driver.InitializeAsync("{}", CancellationToken.None))) + .ShouldBeOfType(); + + var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync( + """{"backend":"wire","devices":[]}""", CancellationToken.None)); + + ex.ShouldNotBeOfType( + "FocasDriver stayed on the unimplemented backend after a reinitialize that selected 'wire'"); + } + + /// An empty/placeholder document keeps the constructor-supplied pair, so the lifecycle tests + /// that pass "{}" keep meaning what they meant. + [Fact] + public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_backend() + { + var driver = FocasDriverFactoryExtensions.CreateInstance( + "focas-1", """{"backend":"unimplemented","devices":[]}"""); + + (await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None))) + .ShouldBeOfType(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusReinitConfigAdoptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusReinitConfigAdoptionTests.cs new file mode 100644 index 00000000..98481200 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusReinitConfigAdoptionTests.cs @@ -0,0 +1,72 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +/// +/// Gitea #516 — used to serve the options its CONSTRUCTOR +/// captured and never look at the driverConfigJson it was handed, so an operator's config edit +/// was silently discarded while the deployment still sealed green. +/// This test passes a CHANGED config on purpose. Every pre-existing reinit test in this +/// suite passes "{}" — exactly the input the guarded re-parser treats as "keep the constructor +/// options" — so those tests were blind to the defect by construction. +/// +[Trait("Category", "Unit")] +public sealed class ModbusReinitConfigAdoptionTests +{ + /// The transport factory receives the options the driver actually decided to use, so it is a + /// direct read of which config won — no log-string matching. + [Fact] + public async Task Reinitialize_with_a_changed_host_rebuilds_the_transport_against_the_new_host() + { + var seen = new List(); + var options = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"10.0.0.1","port":502}"""); + var driver = new ModbusDriver(options, "m1", opts => + { + seen.Add($"{opts.Host}:{opts.Port}"); + return new NeverConnectingTransport(); + }); + + try { await driver.InitializeAsync("""{"host":"10.0.0.1","port":502}""", CancellationToken.None); } + catch { /* connect failure is expected and irrelevant */ } + + try { await driver.ReinitializeAsync("""{"host":"10.0.0.99","port":1502}""", CancellationToken.None); } + catch { /* ditto */ } + + seen.Count.ShouldBeGreaterThanOrEqualTo(2); + seen[^1].ShouldBe("10.0.0.99:1502", + "ModbusDriver kept its constructor-supplied host after a reinitialize with a changed config (#516)"); + } + + /// A placeholder / empty document must still keep the constructor options — the guard exists so + /// the many lifecycle tests that pass "{}" keep meaning what they meant. + [Fact] + public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options() + { + var seen = new List(); + var options = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"10.0.0.1","port":502}"""); + var driver = new ModbusDriver(options, "m1", opts => + { + seen.Add($"{opts.Host}:{opts.Port}"); + return new NeverConnectingTransport(); + }); + + try { await driver.ReinitializeAsync("{}", CancellationToken.None); } + catch { /* connect failure is expected */ } + + seen.ShouldAllBe(s => s == "10.0.0.1:502"); + } + + private sealed class NeverConnectingTransport : IModbusTransport + { + public bool IsConnected => false; + public Task ConnectAsync(CancellationToken cancellationToken) + => throw new IOException("test transport never connects"); + public Task DisconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task SendAsync(byte unitId, byte[] pdu, CancellationToken cancellationToken) + => throw new IOException("test transport never connects"); + public void Dispose() { } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReinitConfigAdoptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReinitConfigAdoptionTests.cs new file mode 100644 index 00000000..5299fdb5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReinitConfigAdoptionTests.cs @@ -0,0 +1,50 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Gitea #516 — OpcUaClientDriver.InitializeAsync used to serve the options its CONSTRUCTOR +/// captured. Its own comment claimed "resolving on every InitializeAsync … picks up rotations", which +/// was true of SECRET rotation only: the endpoint, the security policy and the tag set were all frozen +/// at construction, so an operator's edit was discarded while the deployment still sealed green. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientReinitConfigAdoptionTests +{ + /// + /// ValidateNamespaceKind runs at the top of init and rejects TargetNamespaceKind=Equipment + /// with an empty UNS mapping table. Reinitializing INTO that shape must therefore throw — a driver + /// still serving the constructor's valid options would validate those and succeed, which is exactly + /// how the discarded edit stayed invisible. + /// + [Fact] + public async Task Reinitialize_adopts_a_changed_config() + { + var driver = OpcUaClientDriverFactoryExtensions.CreateInstance( + "opc1", + """{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}"""); + + await Should.ThrowAsync( + () => driver.ReinitializeAsync( + """{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{}}""", + CancellationToken.None), + "OpcUaClientDriver kept its constructor-supplied options after a reinitialize with a changed config (#516)"); + } + + /// An empty/placeholder document must still keep the constructor options. + [Fact] + public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options() + { + var driver = OpcUaClientDriverFactoryExtensions.CreateInstance( + "opc1", + """{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}"""); + + // "{}" keeps the (valid) constructor options, so ValidateNamespaceKind passes and the driver gets as + // far as the connect — which fails against an unreachable endpoint. Anything BUT the validation + // throw proves the constructor options survived. + var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None)); + (ex is InvalidOperationException ioe && ioe.Message.Contains("UnsMappingTable", StringComparison.OrdinalIgnoreCase)) + .ShouldBeFalse("an empty document must keep the constructor-supplied options, not re-validate an empty config"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlReinitConfigAdoptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlReinitConfigAdoptionTests.cs new file mode 100644 index 00000000..1181de1a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlReinitConfigAdoptionTests.cs @@ -0,0 +1,120 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Gitea #516 — Sql was excluded from the first pass because it derives MORE than options from config +/// (its and its resolved connection string are both config-derived), so +/// adopting a new SqlDriverOptions alone would poll a NEW tag set through the OLD database. +/// A half-applied config change is worse than a discarded one, because it looks like it worked. +/// The fix is ParseBinding + ApplyBinding: one parse produces all three, and they are +/// adopted together. These tests pin the ATOMICITY, not just that a re-parse happens — that is the +/// property that made this driver special. +/// +[Trait("Category", "Unit")] +public sealed class SqlReinitConfigAdoptionTests +{ + private const string RefA = "SqlReinitTestA"; + private const string RefB = "SqlReinitTestB"; + + /// + /// A reinitialize that changes connectionStringRef must move the driver onto the NEW database. + /// Endpoint is the credential-free rendering of the live connection string, so it is a direct + /// read of which connection the driver actually adopted — no log-string matching. + /// + [Fact] + public async Task Reinitialize_adopts_a_changed_connection_string_not_just_the_options() + { + using var _ = new ScopedEnvironmentVariable( + SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA, + "Server=old-server;Database=OldDb;Integrated Security=true"); + using var __ = new ScopedEnvironmentVariable( + SqlConnectionStringResolver.EnvironmentVariablePrefix + RefB, + "Server=new-server;Database=NewDb;Integrated Security=true"); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null); + driver.Endpoint.ShouldContain("old-server"); + + // The database is unreachable, so init faults — irrelevant here. The assertion is which database it + // tried, i.e. whether the connection string moved with the options. + try + { + await driver.ReinitializeAsync( + $$"""{"provider":"SqlServer","connectionStringRef":"{{RefB}}"}""", CancellationToken.None); + } + catch + { + // Liveness failure against a non-existent server is expected. + } + + driver.Endpoint.ShouldContain( + "new-server", + customMessage: + "SqlDriver adopted a reinitialized config but kept its ORIGINAL connection string — the exact " + + "half-applied adoption (#516) that would poll a new tag set against the old database"); + driver.Endpoint.ShouldNotContain("old-server"); + } + + /// + /// A config whose connectionStringRef names an unprovisioned environment variable must make + /// the whole reinitialize FAIL, leaving the previous binding intact — never a partial adoption where + /// new options are live against the old connection. + /// + [Fact] + public async Task A_reinitialize_that_cannot_resolve_its_connection_leaves_the_previous_binding_intact() + { + using var _ = new ScopedEnvironmentVariable( + SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA, + "Server=old-server;Database=OldDb;Integrated Security=true"); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null); + + await Should.ThrowAsync(() => driver.ReinitializeAsync( + """{"provider":"SqlServer","connectionStringRef":"SqlReinitTestNeverProvisioned"}""", + CancellationToken.None)); + + driver.Endpoint.ShouldContain( + "old-server", + customMessage: + "a reinitialize that could not resolve its new connection must leave the previous binding whole"); + } + + /// An empty/placeholder document keeps the constructor-supplied binding, so the many lifecycle + /// tests that pass "{}" keep meaning what they meant. + [Fact] + public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_binding() + { + using var _ = new ScopedEnvironmentVariable( + SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA, + "Server=old-server;Database=OldDb;Integrated Security=true"); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null); + + try { await driver.ReinitializeAsync("{}", CancellationToken.None); } + catch { /* liveness failure expected */ } + + driver.Endpoint.ShouldContain("old-server"); + } + + /// Sets an environment variable for the duration of a test and restores it after. The resolver + /// reads process-global state, so a leaked variable would race every sibling test. + private sealed class ScopedEnvironmentVariable : IDisposable + { + private readonly string _name; + private readonly string? _previous; + + public ScopedEnvironmentVariable(string name, string value) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/DriverFormMapParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/DriverFormMapParityTests.cs new file mode 100644 index 00000000..f9d6e8a5 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/DriverFormMapParityTests.cs @@ -0,0 +1,104 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers; + +/// +/// Closes deferment.md G-4. A guard already existed for DriverTypeNames ↔ the /raw +/// driver PICKER, but not for the two dispatch maps downstream of it, so a driver could be registered, +/// offered in the picker, and then have no config form — which is exactly what happened to +/// Calculation (G-1) and to Sql/MTConnect/Calculation on the device modal (G-2). Both survived +/// review because nothing could see them. +/// These are ordinary map lookups rather than reflection over a private field: the two modals were +/// converted from a Razor @switch — unreachable from a test, since it compiles into +/// BuildRenderTree's IL — to DriverConfigFormMap / DeviceFormMap. There is no bUnit +/// in this project, so a map is the only thing a test can hold on to. +/// +[Trait("Category", "Unit")] +public sealed class DriverFormMapParityTests +{ + /// Every declared driver type must have a typed config form. There is no exemption: a driver + /// with no configurable surface still needs a form to say so, or the operator meets a bare warning. + [Fact] + public void Every_declared_driver_type_has_a_typed_config_form() + { + var missing = DriverTypeNames.All + .Where(t => DriverConfigFormMap.Resolve(t) is null) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + missing.ShouldBeEmpty( + $"these driver types are registered and offered in the /raw picker but DriverConfigModal has no " + + $"form for them, so their config is unauthorable through the UI: {string.Join(", ", missing)}"); + } + + /// The reverse direction — a mapped type that is no longer a declared driver type is a stale + /// entry, and would keep a deleted driver's form reachable. + [Fact] + public void Every_config_form_maps_to_a_declared_driver_type() + { + var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase); + var stale = DriverConfigFormMap.MappedDriverTypes + .Where(t => !declared.Contains(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + stale.ShouldBeEmpty($"DriverConfigFormMap has entries for undeclared driver types: {string.Join(", ", stale)}"); + } + + /// + /// Every declared driver type must EITHER have a typed device form OR be declared single-connection. + /// The either/or matters: demanding a device form from every driver would be wrong (Sql has one + /// connection string, MTConnect one Agent URI, Calculation no connection at all), and a test that has + /// to be suppressed for five drivers stops being read. Forcing the choice to be explicit is what makes + /// this a guard — a new driver cannot silently fall through. + /// + [Fact] + public void Every_declared_driver_type_has_a_device_form_or_is_declared_single_connection() + { + var unaccounted = DriverTypeNames.All + .Where(t => DeviceFormMap.Resolve(t) is null && !DeviceFormMap.IsSingleConnection(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + unaccounted.ShouldBeEmpty( + $"these driver types have neither a typed device form nor a place in " + + $"DeviceFormMap.SingleConnectionDriverTypes, so DeviceModal falls through to its " + + $"\"no typed device form\" warning: {string.Join(", ", unaccounted)}"); + } + + /// The reverse direction for the device map. + [Fact] + public void Every_device_form_maps_to_a_declared_driver_type() + { + var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase); + var stale = DeviceFormMap.MappedDriverTypes + .Concat(DeviceFormMap.SingleConnectionDriverTypes) + .Where(t => !declared.Contains(t)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + stale.ShouldBeEmpty($"DeviceFormMap references undeclared driver types: {string.Join(", ", stale)}"); + } + + /// + /// A driver that authors its connection on the DRIVER form must have a driver form to author it on. + /// Without this, declaring a type single-connection would be a way to opt out of both maps at once + /// and leave the connection unauthorable anywhere. + /// + [Fact] + public void Every_single_connection_driver_has_a_driver_config_form() + { + var missing = DeviceFormMap.SingleConnectionDriverTypes + .Where(t => DriverConfigFormMap.Resolve(t) is null) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + missing.ShouldBeEmpty( + $"these types are declared single-connection but have no driver config form, so their connection " + + $"cannot be authored anywhere: {string.Join(", ", missing)}"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DriverDispatchMapParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DriverDispatchMapParityTests.cs new file mode 100644 index 00000000..0cb9f9ff --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DriverDispatchMapParityTests.cs @@ -0,0 +1,98 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Parity guards for the two remaining per-driver dispatch maps (deferment.md G-5, G-6). +/// Both were already testable structures — unlike the two Razor @switch blocks that needed +/// extracting first — so nothing but the tests was missing. +/// +[Trait("Category", "Unit")] +public sealed class DriverDispatchMapParityTests +{ + /// + /// Driver types with a typed tag editor but no CSV map, so import/export silently degrades them to + /// the raw TagConfigJson fallback. Empty — every editor-backed driver now has typed columns. + /// Deliberately expressed as "has a typed editor ⇒ has typed CSV columns" rather than + /// "every driver type", because a driver with no typed editor has nothing to project into columns. + /// Tying the two maps together is what makes this catch the next omission. + /// + [Fact] + public void Every_driver_with_a_typed_tag_editor_has_typed_csv_columns() + { + var missing = DriverTypeNames.All + .Where(t => TagConfigEditorMap.Resolve(t) is not null && CsvColumnMap.Resolve(t) is null) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + missing.ShouldBeEmpty( + $"these driver types have a typed tag editor but no CsvColumnMap entry, so CSV import/export " + + $"drops them to the raw TagConfigJson fallback: {string.Join(", ", missing)}"); + } + + /// A CSV map for a driver type that no longer exists is a stale entry. + [Fact] + public void Every_csv_map_targets_a_declared_driver_type() + { + var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase); + var stale = CsvColumnMap.All + .Select(m => m.DriverType) + .Where(t => !declared.Contains(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + stale.ShouldBeEmpty($"CsvColumnMap has entries for undeclared driver types: {string.Join(", ", stale)}"); + } + + /// + /// Every driver type either produces a TYPED blob from browse-commit, or is on the documented + /// non-browsable list. The generic {"address": …} fallback opens EMPTY in a typed editor and + /// blanks the field on save — the MTConnect branch was added for exactly that, and Sql had the same + /// bug unnoticed (deferment.md G-6) because the fallback's comment claimed "browsable drivers + /// are all handled above", which stopped being true the moment SqlDriverBrowser registered. + /// Asserted in both directions on purpose. A one-way check would let a newly-browsable + /// driver be quietly added to the exclusion list instead of getting a branch. Requiring the two sets + /// to match exactly means a driver gaining or losing a browser forces this list to be revisited. + /// + [Fact] + public void Browse_commit_falls_back_to_the_generic_address_key_for_exactly_the_non_browsable_drivers() + { + // Address fields as a browser supplies them; a driver whose browser states none must still produce + // something its typed editor can read from the leaf name alone. + var addressFields = new Dictionary(StringComparer.Ordinal) + { + ["schema"] = "dbo", + ["table"] = "Readings", + ["columnName"] = "Speed", + }; + + var fellBack = DriverTypeNames.All + .Where(t => RawBrowseCommitMapper.BuildTagConfig(t, "Speed", addressFields) == """{"address":"Speed"}""") + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + fellBack.ShouldBe( + NonBrowsableDriverTypes.OrderBy(t => t, StringComparer.Ordinal).ToList(), + $"the set of driver types committing to the generic \"address\" key drifted. Extra entries are " + + $"browsable drivers whose typed editor will open EMPTY and blank the field on save; missing " + + $"entries gained a typed branch and should leave this list. Got: {string.Join(", ", fellBack)}"); + } + + /// + /// Driver types with no browser, for which the generic address key is the correct commit. + /// + /// Modbus — register addresses are numeric coordinates, not a browsable namespace. + /// Calculation — a pseudo-driver over other tags' RawPaths; there is no device. + /// + /// Adding a type here is a claim that it cannot be browsed. If it can, give it a branch instead. + /// + private static readonly string[] NonBrowsableDriverTypes = + [ + DriverTypeNames.Modbus, + DriverTypeNames.Calculation, + ]; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs index cc581114..0219fc2e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs @@ -17,18 +17,43 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; public sealed class TagConfigDriverTypeNameGuardTests { // Every DriverTypeNames constant that has a typed editor registered. - public static TheoryData EditorMappedDriverTypes() => - [ - DriverTypeNames.Modbus, - DriverTypeNames.S7, - DriverTypeNames.AbCip, - DriverTypeNames.AbLegacy, - DriverTypeNames.TwinCAT, - DriverTypeNames.FOCAS, - DriverTypeNames.OpcUaClient, - DriverTypeNames.Calculation, - DriverTypeNames.MTConnect, - ]; + // + // Enumerated FROM the map rather than hand-written. The hand-written list this replaced guarded + // against RENAMES but not against GAPS: a newly-added DriverTypeNames constant simply never appeared + // here, so the guard stayed green while the map went unmapped — and it had already silently drifted, + // omitting Galaxy, Sql and Mqtt. Pairing it with the coverage test below makes both directions real. + public static TheoryData EditorMappedDriverTypes() + { + var data = new TheoryData(); + foreach (var t in DriverTypeNames.All.Where(t => TagConfigEditorMap.Resolve(t) is not null)) + { + data.Add(t); + } + + return data; + } + + /// + /// Coverage direction: every declared driver type has a typed tag editor, except those documented as + /// having none. Without this, enumerating the map above proves only that the map agrees with itself. + /// + [Fact] + public void Every_declared_driver_type_has_a_typed_tag_editor_or_is_documented_as_raw_json() + { + // Galaxy authors its tag reference as a dotted FullName through the Galaxy address picker rather + // than a typed field editor, so it uses the generic raw-TagConfig-JSON textarea by design. + string[] rawJsonByDesign = [DriverTypeNames.Galaxy]; + + var missing = DriverTypeNames.All + .Where(t => TagConfigEditorMap.Resolve(t) is null) + .Except(rawJsonByDesign, StringComparer.OrdinalIgnoreCase) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + missing.ShouldBeEmpty( + $"these driver types have no typed tag editor and are not documented as raw-JSON by design, so " + + $"the /uns TagModal falls back to a bare JSON textarea for them: {string.Join(", ", missing)}"); + } [Theory] [MemberData(nameof(EditorMappedDriverTypes))] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index 7bcfe1ee..b2b970e3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -94,11 +94,6 @@ public sealed class AddressSpaceApplierFailureSurfaceTests applier.MaterialiseEquipmentTags(tags).ShouldBe(0); applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0); applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0); - applier.MaterialiseDiscoveredNodes( - "eq-1", - Array.Empty(), - new[] { new DiscoveredVariable("eq-1/D", "eq-1", "D", "Float", Writable: false, IsArray: false, ArrayLength: null) }) - .ShouldBe(0); } // ---------------- fixtures ---------------- diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index ddbf81bd..a25562e0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -8,679 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; public sealed class AddressSpaceApplierTests { - /// Verifies that an empty plan does not call the sink or trigger a rebuild. - [Fact] - public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var outcome = applier.Apply(EmptyPlan); - outcome.RebuildCalled.ShouldBeFalse(); - outcome.AddedNodes.ShouldBe(0); - outcome.RemovedNodes.ShouldBe(0); - outcome.ChangedNodes.ShouldBe(0); - sink.RebuildCalls.ShouldBe(0); - sink.AlarmWrites.ShouldBeEmpty(); - } - /// R2-07 T11 — removed equipment is a PureRemove: the applier writes each id's terminal - /// "no-event" condition state (top-of-Apply block) then tears down each equipment SUBTREE in place via - /// RemoveEquipmentSubtree — NO full rebuild (other clients' subscriptions survive). (Supersedes the - /// pre-R2-07 "removed equipment ⇒ rebuild" pin.) - [Fact] - public void Removed_equipment_writes_terminal_state_and_removes_subtree_without_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = WithEquipmentRemoval("eq-1", "eq-2"); - var outcome = applier.Apply(plan); - - outcome.RemovedNodes.ShouldBe(2); - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - // Terminal "no-event" condition state written per id (inactive + acked + confirmed). - sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" }); - sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue(); - // Each equipment torn down as a subtree. - sink.RemoveCalls.OrderBy(x => x.NodeId).ShouldBe(new[] { ("equipment", "eq-1"), ("equipment", "eq-2") }); - } - - /// R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent - /// Materialise passes create the new folder; existing client subscriptions survive) and writes no alarm - /// state. (Supersedes the pre-R2-07 "added equipment ⇒ rebuild" pin.) - [Fact] - public void Added_equipment_is_pure_add_and_skips_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = new AddressSpacePlan( - AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") }, - RemovedEquipment: Array.Empty(), - ChangedEquipment: Array.Empty(), - AddedDrivers: Array.Empty(), - RemovedDrivers: Array.Empty(), - ChangedDrivers: Array.Empty(), - AddedAlarms: Array.Empty(), - RemovedAlarms: Array.Empty(), - ChangedAlarms: Array.Empty()); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved - outcome.AddedNodes.ShouldBe(1); - sink.AlarmWrites.ShouldBeEmpty(); - sink.RebuildCalls.ShouldBe(0); - } - - /// Verifies that driver-only changes do not trigger address space rebuild. - [Fact] - public void Driver_only_changes_do_not_trigger_address_space_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = new AddressSpacePlan( - AddedEquipment: Array.Empty(), - RemovedEquipment: Array.Empty(), - ChangedEquipment: Array.Empty(), - AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") }, - RemovedDrivers: Array.Empty(), - ChangedDrivers: new[] - { - new AddressSpacePlan.DriverDelta( - new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"), - new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")), - }, - AddedAlarms: Array.Empty(), - RemovedAlarms: Array.Empty(), - ChangedAlarms: Array.Empty()); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - } - - /// Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires. - [Fact] - public void Sink_exception_in_WriteAlarmCondition_does_not_propagate_and_rebuild_still_fires() - { - var sink = new ThrowingSink(throwOnAlarmWrite: true); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = WithEquipmentRemoval("eq-1"); - - var outcome = applier.Apply(plan); // should not throw - outcome.RemovedNodes.ShouldBe(1); - outcome.RebuildCalled.ShouldBeTrue(); - } - - /// Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly - /// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw - /// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment - /// folder (decision #4). - [Fact] - public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - UnsAreas: Array.Empty(), - UnsLines: Array.Empty(), - EquipmentNodes: Array.Empty(), - DriverInstancePlans: Array.Empty(), - ScriptedAlarmPlans: Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed - // A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite). - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true)); - // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath). - sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - } - - /// Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment - /// folder (not the namespace root), with the variable parented to that sub-folder and a - /// folder-scoped NodeId. - [Fact] - public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); - // A Read plan threads Writable: false (the node stays CurrentRead). - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false)); - // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath). - sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp")); - } - - /// Regression for the FullName-as-NodeId collision: two identical machines exposing the - /// SAME driver FullName (e.g. Modbus register 40001) must produce TWO distinct variables — one - /// under each equipment folder — because the NodeId is folder-scoped, not the raw FullName. - [Fact] - public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - sink.VariableCalls.Count.ShouldBe(2); - sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float", false)); - sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false)); - } - - /// Phase B WS-3 — an alarm-bearing equipment tag (Alarm is not null) materialises a - /// real OPC UA Part 9 condition node (via the same path scripted alarms use) instead of a value - /// variable; a plain tag (Alarm == null) stays a value variable. The alarm tag's condition - /// uses the tag's folder-scoped NodeId, the equipment folder as parent, and carries the tag's - /// AlarmType/Severity. Proves BOTH branches in one composition. - [Fact] - public void MaterialiseEquipmentTags_alarm_bearing_tag_becomes_condition_plain_tag_stays_variable() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-plain", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - // The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition. - var plainNodeId = V3NodeIds.Uns("eq-1", "Speed"); - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true)); - sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId); - - // The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent, - // matching display/type/severity) and did NOT drive EnsureVariable. - var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp"); - // A native equipment-tag alarm: the call-site threads isNative: true. - sink.AlarmConditionCalls.ShouldHaveSingleItem() - .ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true)); - sink.VariableCalls.ShouldNotContain(v => v.NodeId == alarmNodeId); - } - - /// Phase B WS-3 — an alarm-bearing equipment tag WITH a FolderPath still gets its - /// sub-folder created, and its condition is parented to that sub-folder (not the equipment folder), - /// using the folder-scoped NodeId. - [Fact] - public void MaterialiseEquipmentTags_alarm_bearing_tag_with_FolderPath_conditions_under_subfolder() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "Diagnostics", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500)), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - // The sub-folder is still created for an alarm tag with a FolderPath. - sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); - // Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable. - var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp"); - // A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true. - sink.AlarmConditionCalls.ShouldHaveSingleItem() - .ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true)); - sink.VariableCalls.ShouldBeEmpty(); - } - - /// Phase C Task 2 — the applier resolves the historian tagname per value tag and threads it - /// to EnsureVariable: a historized tag with NO override falls back to its FullName; a - /// historized tag WITH an override passes the override verbatim; a non-historized tag passes null. - [Fact] - public void MaterialiseEquipmentTags_resolves_historian_tagname_default_override_and_null() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - // Historized, no override ⇒ tagname defaults to FullName ("T.A"). - new EquipmentTagPlan("tag-def", "eq-1", "drv", FolderPath: "", Name: "ADefault", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null), - // Historized, override ⇒ tagname is the override ("WW.Override"), NOT FullName. - new EquipmentTagPlan("tag-ovr", "eq-1", "drv", FolderPath: "", Name: "BOverride", DataType: "Float", - FullName: "T.B", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Override"), - // Not historized ⇒ tagname is null. - new EquipmentTagPlan("tag-no", "eq-1", "drv", FolderPath: "", Name: "CPlain", DataType: "Float", - FullName: "T.C", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname); - byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName - byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim - byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null - } - - /// Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to - /// FullName (the resolve uses string.IsNullOrWhiteSpace, not just null). - [Fact] - public void MaterialiseEquipmentTags_blank_override_falls_back_to_full_name() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-blank", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "40001", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: " "), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var call = sink.HistorianCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.HistorianTagname.ShouldBe("40001"); - } - - /// Array-support Task 2 — an with IsArray: true, - /// ArrayLength: 16 flowing through must - /// forward BOTH flags verbatim to the sink's EnsureVariable. Guards against arg-order swaps or - /// accidental drops in the wire-through. - [Fact] - public void MaterialiseEquipmentTags_array_plan_forwards_isArray_and_arrayLength_to_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-arr", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var call = sink.ArrayCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer")); - call.IsArray.ShouldBeTrue(); - call.ArrayLength.ShouldBe(16u); - } - - /// Array-support Task 2 — a scalar (IsArray: false, - /// ArrayLength: null) must pass isArray == false through to the sink. Guards against a - /// default flip that would silently materialise scalar tags as 1-D arrays. - [Fact] - public void MaterialiseEquipmentTags_scalar_plan_forwards_isArray_false_to_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-scalar", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "40002", Writable: false, Alarm: null, IsArray: false, ArrayLength: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var call = sink.ArrayCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.IsArray.ShouldBeFalse(); - call.ArrayLength.ShouldBeNull(); - } - - /// Review M-1 — an array equipment tag authored with Writable: true must be - /// materialised as READ-ONLY (writable == false) because array writes are out of scope - /// (Phase 4c read-only surface). The driver write path does not handle arrays and would crash - /// (e.g. S7 BoxValueForWrite). Guards against a future refactor that accidentally enables the - /// writable path for arrays. - [Fact] - public void MaterialiseEquipmentTags_array_writable_true_is_forced_read_only() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - // Authored ReadWrite AND IsArray — the applier must clamp to read-only. - new EquipmentTagPlan("tag-arr-rw", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: true, Alarm: null, IsArray: true, ArrayLength: 8u), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - // writable must be false (array writes out of scope), isArray must be true (forwarded verbatim). - var varCall = sink.VariableCalls.ShouldHaveSingleItem(); - varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true - var arrCall = sink.ArrayCalls.ShouldHaveSingleItem(); - arrCall.IsArray.ShouldBeTrue(); - arrCall.ArrayLength.ShouldBe(8u); - } - - /// Review M-1 regression — a scalar tag authored with Writable: true must still - /// be materialised as read/write (writable == true). The array-clamp must NOT affect - /// scalar tags. - [Fact] - public void MaterialiseEquipmentTags_scalar_writable_true_stays_writable() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - // Authored ReadWrite, scalar — must pass through writable: true unchanged. - new EquipmentTagPlan("tag-scalar-rw", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "40002", Writable: true, Alarm: null, IsArray: false, ArrayLength: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var varCall = sink.VariableCalls.ShouldHaveSingleItem(); - varCall.Writable.ShouldBeTrue(); // scalar: writable unchanged - var arrCall = sink.ArrayCalls.ShouldHaveSingleItem(); - arrCall.IsArray.ShouldBeFalse(); - } - - /// Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly - /// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the - /// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create - /// the equipment folder (no sub-folder when FolderPath is empty). - [Fact] - public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentVirtualTags = new[] - { - new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64", - Expression: "ctx.GetTag(\"x\") * 60", DependencyRefs: new[] { "x" }), - }, - }; - - applier.MaterialiseEquipmentVirtualTags(composition); - - sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed - // VirtualTags are computed outputs — always read-only (Writable: false). - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false)); - // Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula. - sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm")); - } - - /// Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and - /// the equipment-VirtualTag pass is byte-identical to V3NodeIds.Uns — the - /// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty - /// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test - /// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the - /// shared helper fails here. - [Fact] - public void Materialised_variable_node_ids_match_shared_EquipmentNodeIds_formula() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, - EquipmentVirtualTags = new[] - { - new EquipmentVirtualTagPlan("vt-flat", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64", - Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), - new EquipmentVirtualTagPlan("vt-nested", "eq-2", FolderPath: "Calc", Name: "Avg", DataType: "Float64", - Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - applier.MaterialiseEquipmentVirtualTags(composition); - - var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList(); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed")); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp")); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency")); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg")); - } - - /// Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables - /// (one EnsureVariable each, no NodeId collision), parented to the equipment folder. - [Fact] - public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentVirtualTags = new[] - { - new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64", - Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), - new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "load-pct", DataType: "Float64", - Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }), - }, - }; - - applier.MaterialiseEquipmentVirtualTags(composition); - - sink.FolderCalls.ShouldBeEmpty(); - sink.VariableCalls.Count.ShouldBe(2); - sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false)); - sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64", false)); - } - - /// T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by - /// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS - /// disabled alarms. - [Fact] - public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentScriptedAlarms = new[] - { - new EquipmentScriptedAlarmPlan( - ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm", - Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;", - DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: true), - new EquipmentScriptedAlarmPlan( - ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition", - Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;", - DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: false), - }, - }; - - applier.MaterialiseScriptedAlarms(composition); - - // Only the enabled alarm is materialised; the disabled one is skipped entirely. - // A SCRIPTED alarm: the call-site threads isNative: false (guards against a native/scripted swap). - sink.AlarmConditionCalls.ShouldHaveSingleItem() - .ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, false)); - } - - /// Task 4 — MaterialiseDiscoveredNodes ensures the discovered folders PARENT-FIRST (ordered by - /// depth = '/' count) and the discovered variables at their folder-scoped NodeIds/parents, with variables - /// created READ-ONLY (writable == false), then raises EXACTLY ONE NodeAdded model-change under the - /// equipment root. Folders are passed in REVERSE (child-first) to prove the applier re-orders them - /// parent-first before ensuring (a child folder's parent must exist first). - [Fact] - public void MaterialiseDiscoveredNodes_ensures_folders_parent_first_read_only_variables_and_raises_model_change_once() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - // Child folder listed BEFORE its parent — the applier must re-order parent-first. - var folders = new[] - { - new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"), - new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"), - }; - var variables = new[] - { - new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", - "String", Writable: false, IsArray: false, ArrayLength: null), - }; - - applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables); - - // Folders ensured parent-first regardless of input order (shallowest depth first). - sink.FolderCalls.Select(f => f.NodeId).ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" }); - sink.FolderCalls.ShouldContain(("EQ-1/FOCAS", "EQ-1", "FOCAS")); - sink.FolderCalls.ShouldContain(("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity")); - - // Variable ensured at its folder-scoped NodeId, parented to its sub-folder, READ-ONLY. - sink.VariableCalls.ShouldHaveSingleItem() - .ShouldBe(("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", "String", false)); - - // Exactly one NodeAdded model-change, announced under the equipment root. - sink.ModelChangeCalls.ShouldHaveSingleItem().ShouldBe("EQ-1"); - } - - /// Task 4 — a discovered array variable (rare) authored Writable: true is forced - /// READ-ONLY (mirrors MaterialiseEquipmentTags: the driver write path can't handle arrays), while the - /// IsArray / ArrayLength flags are forwarded verbatim to the sink. - [Fact] - public void MaterialiseDiscoveredNodes_array_variable_is_forced_read_only() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var variables = new[] - { - new DiscoveredVariable("EQ-1/FOCAS/Buffer", "EQ-1", "Buffer", "Int16", - Writable: true, IsArray: true, ArrayLength: 8u), - }; - - applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty(), variables); - - var varCall = sink.VariableCalls.ShouldHaveSingleItem(); - varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true - var arrCall = sink.ArrayCalls.ShouldHaveSingleItem(); - arrCall.IsArray.ShouldBeTrue(); - arrCall.ArrayLength.ShouldBe(8u); - } - - /// Task 4 — re-applying the SAME discovered plan is idempotent-SAFE: it does not throw, the - /// distinct folder/variable set the applier issues per pass is stable (the real sink early-returns on - /// existing nodes), and a model-change is raised once PER call (twice across two calls). - [Fact] - public void MaterialiseDiscoveredNodes_is_idempotent_safe_on_repeated_application() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var folders = new[] - { - new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"), - new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"), - }; - var variables = new[] - { - new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", - "String", Writable: false, IsArray: false, ArrayLength: null), - }; - - applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables); - Should.NotThrow(() => applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables)); - - // Each pass re-issues the same parent-first ensures (the real sink dedups via early-return); the - // DISTINCT set the applier produces is stable across re-applies. - sink.FolderCalls.Select(f => f.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" }); - sink.VariableCalls.Select(v => v.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS/Identity/SeriesNumber" }); - // One model-change per call ⇒ two across two calls. - sink.ModelChangeCalls.ShouldBe(new[] { "EQ-1", "EQ-1" }); - } - - /// Task 4 — empty input (no folders, no variables) returns WITHOUT touching the sink: no - /// EnsureFolder/EnsureVariable and, crucially, NO NodeAdded model-change. - [Fact] - public void MaterialiseDiscoveredNodes_empty_input_does_not_touch_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty(), Array.Empty()); - - sink.FolderCalls.ShouldBeEmpty(); - sink.VariableCalls.ShouldBeEmpty(); - sink.ModelChangeCalls.ShouldBeEmpty(); - } /// R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier /// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs deleted file mode 100644 index f1da0dfd..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using Shouldly; -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -[Trait("Category", "Unit")] -public sealed class CapturingAddressSpaceBuilderTests -{ - [Fact] - public void Records_nested_path_segments_full_reference_and_metadata() - { - var b = new CapturingAddressSpaceBuilder(); - var focas = b.Folder("FOCAS", "FOCAS"); - var device = focas.Folder("10.0.0.5:8193", "cnc"); - var identity = device.Folder("Identity", "Identity"); - identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo( - FullName: "10.0.0.5:8193/Identity/SeriesNumber", - DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false)); - - b.Nodes.Count.ShouldBe(1); - var n = b.Nodes[0]; - n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" }); - n.BrowseName.ShouldBe("SeriesNumber"); - n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber"); - n.DataType.ShouldBe(DriverDataType.String); - n.Writable.ShouldBeFalse(); // ViewOnly -> read-only - } - - [Fact] - public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink() - { - var b = new CapturingAddressSpaceBuilder(); - var f = b.Folder("FOCAS", "FOCAS"); - f.AddProperty("Manufacturer", DriverDataType.String, "FANUC"); // ignored, no throw - var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null, - SecurityClassification.ViewOnly, false, IsAlarm: true)); - var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null)); - sink.ShouldNotBeNull(); // no-op sink, alarms out of scope - b.Nodes.Count.ShouldBe(1); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs index c8d5ca33..9632ae74 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs @@ -18,17 +18,11 @@ internal static class DarkAddressSpaceReasons "empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " + "intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests."; - /// Discovered-node INJECTION is dormant in v3 (Wave-B review M1). The v2 path grafted a driver's - /// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered - /// raw tags are authored explicitly via the Batch-2 /raw browse-commit flow. HandleDiscoveredNodes - /// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection - /// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by - /// DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3. - public const string DiscoveryInjectionDormantV3 = - "v3: discovered-node injection is DORMANT (Wave-B review M1) — equipment no longer binds a driver, so the " + - "v2 FixedTree-under-equipment graft has no live path. Discovered raw tags are authored via the /raw " + - "browse-commit flow. HandleDiscoveredNodes short-circuits (pinned by Discovered_nodes_are_ignored_dormant_in_v3); " + - "re-migrating injection onto the raw device subtree is a separate follow-up."; + // DiscoveryInjectionDormantV3 was removed with the injection path itself (§8.2, Gitea #507). The 18 + // tests it skipped were v2 characterization tests — they asserted an equipment-rooted graft + // (EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they were deleted rather than unskipped. + // Discovered raw tags are authored through the /raw browse-commit flow; IRediscoverable now surfaces a + // re-browse prompt instead (DriverInstanceActorRediscoverySignalTests). /// Equipment↔device host binding is architecturally retired in v3. public const string EquipmentDeviceBindingRetired = diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs deleted file mode 100644 index cdc2dca0..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using Shouldly; -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -[Trait("Category", "Unit")] -public sealed class DiscoveredNodeMapperTests -{ - private static DiscoveredNode Node(string[] path, string name, string fullRef, - DriverDataType dt = DriverDataType.Float64, bool writable = false) - => new(path, name, name, fullRef, dt, false, null, writable, false); - - [Fact] - public void Maps_under_equipment_collapsing_single_device_folder() - { - var nodes = new[] - { - Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String), - Node(["FOCAS", "10.0.0.5:8193", "Axes", "X"], "AbsolutePosition", "10.0.0.5:8193/Axes/X/AbsolutePosition"), - }; - - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); - - result.Variables.Select(v => v.NodeId).ShouldBe(new[] - { - "EQ-1/FOCAS/Identity/SeriesNumber", - "EQ-1/FOCAS/Axes/X/AbsolutePosition", - }, ignoreOrder: true); - result.Folders.Select(f => f.NodeId).ShouldContain("EQ-1/FOCAS/Axes/X"); - result.Folders.First(f => f.NodeId == "EQ-1/FOCAS/Axes/X").ParentNodeId.ShouldBe("EQ-1/FOCAS/Axes"); - result.RoutingByRef["10.0.0.5:8193/Identity/SeriesNumber"].ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber"); - result.Variables.First(v => v.NodeId.EndsWith("SeriesNumber")).Writable.ShouldBeFalse(); - } - - [Fact] - public void Dedups_authored_refs() - { - var nodes = new[] - { - Node(["FOCAS", "10.0.0.5:8193"], "parts-count", "parts-count"), - Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String), - }; - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet { "parts-count" }); - result.Variables.ShouldHaveSingleItem(); - result.Variables[0].NodeId.ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber"); - } - - [Fact] - public void Does_not_collapse_when_two_devices_present() - { - var nodes = new[] - { - Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "a", DriverDataType.String), - Node(["FOCAS", "10.0.0.6:8193", "Identity"], "SeriesNumber", "b", DriverDataType.String), - }; - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); - result.Variables.Select(v => v.NodeId).ShouldBe(new[] - { - "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber", - "EQ-1/FOCAS/10.0.0.6:8193/Identity/SeriesNumber", - }, ignoreOrder: true); - } - - [Fact] - public void Empty_input_yields_empty_plan() - { - var result = DiscoveredNodeMapper.Map("EQ-1", Array.Empty(), authoredRefs: new HashSet()); - result.Folders.ShouldBeEmpty(); - result.Variables.ShouldBeEmpty(); - result.RoutingByRef.ShouldBeEmpty(); - } - - [Fact] - public void Array_metadata_passes_through_unchanged() - { - var node = new DiscoveredNode( - FolderPathSegments: ["FOCAS", "10.0.0.5:8193", "Axes"], - BrowseName: "Positions", - DisplayName: "Positions", - FullReference: "10.0.0.5:8193/Axes/Positions", - DataType: DriverDataType.Float64, - IsArray: true, - ArrayDim: 8u, - Writable: false, - IsHistorized: false); - - var result = DiscoveredNodeMapper.Map("EQ-1", new[] { node }, authoredRefs: new HashSet()); - - result.Variables.ShouldHaveSingleItem(); - result.Variables[0].IsArray.ShouldBeTrue(); - result.Variables[0].ArrayLength.ShouldBe(8u); - } - - [Theory] - // Mirror OtOpcUaNodeManager.ResolveBuiltInDataType's accepted string set: Float32 -> "Float", - // Float64 -> "Double", Reference (Galaxy attr ref encoded as a string) -> "String". The pass-through - // members must keep their enum name so the node manager resolves them to the matching built-in type. - [InlineData(DriverDataType.Float64, "Double")] - [InlineData(DriverDataType.Float32, "Float")] - [InlineData(DriverDataType.Reference, "String")] - [InlineData(DriverDataType.Boolean, "Boolean")] - [InlineData(DriverDataType.Int16, "Int16")] - [InlineData(DriverDataType.Int32, "Int32")] - [InlineData(DriverDataType.Int64, "Int64")] - [InlineData(DriverDataType.UInt16, "UInt16")] - [InlineData(DriverDataType.UInt32, "UInt32")] - [InlineData(DriverDataType.UInt64, "UInt64")] - [InlineData(DriverDataType.String, "String")] - [InlineData(DriverDataType.DateTime, "DateTime")] - public void DataType_maps_to_node_manager_builtin_string(DriverDataType dt, string expected) - { - var nodes = new[] { Node(["FOCAS", "10.0.0.5:8193", "Identity"], "Value", "10.0.0.5:8193/Identity/Value", dt) }; - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); - result.Variables.ShouldHaveSingleItem(); - result.Variables[0].DataType.ShouldBe(expected); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs deleted file mode 100644 index b692f994..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs +++ /dev/null @@ -1,353 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using Akka.Actor; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; -using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; -using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.Configuration; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.OpcUaServer; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Task 9 — the focused END-TO-END proof that a driver-discovered FixedTree node is grafted into the -/// served Equipment OPC UA address space and a polled value reaches it. Unlike the Task-7/8 suites -/// (which wire the OPC UA publish side as a and assert on the -/// intercepted / -/// messages), this suite wires the FULL real chain: -/// -/// -/// a real (resolves equipment, maps via -/// , extends the live-value routing map, caches the plan); -/// a real as its opcUaPublishActor seam (so -/// MaterialiseDiscoveredNodes + AttributeValueUpdate are actually handled, not -/// intercepted); -/// a real over a recording -/// (so the materialise + value-write reach the sink). -/// -/// -/// -/// The assertions are therefore made on the SINK's recorded EnsureVariable / -/// RaiseNodesAddedModelChange / WriteValue calls — i.e. the discovered node was -/// materialised through the real applier AND a published value surfaces -/// at the mapped NodeId (in production this overwrites the BadWaitingForInitialData seed that -/// OtOpcUaNodeManager.EnsureVariable stamps on a freshly-materialised variable; the recording -/// sink does not model that seed, so the faithful assertion available here is that the live value -/// lands Good at the same NodeId the materialise created). -/// -/// -/// -/// Seam choices (faithful to the sibling suites). Discovery is driven by Telling the host -/// directly, and the polled value by Telling -/// directly — exactly the seams the Task-7/8 -/// tests use (there is no test seam to drive a real poll loop through a -/// child to Connected, and the spawned child is a ). The publish -/// actor is wired WITHOUT a dbFactory, so the host's apply-time -/// falls back to a raw sink.RebuildAddressSpace() (no EnsureVariable); this keeps the -/// ONLY EnsureVariable traffic on the sink the discovered-node materialise itself, so the -/// mapped NodeId is unambiguous. The discovery-injection chain (mapper → applier → sink + routing -/// map) is fully real. -/// -/// -[Trait("Category", "Unit")] -public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase -{ - private static readonly NodeId TestNode = NodeId.Parse("disc-e2e-node"); - private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); - private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc); - - // The FixedTree node the driver "discovers": FOCAS//Identity/SeriesNumber, a String value, - // whose FullReference differs from any authored tag so the mapper keeps it (does not shadow an authored - // node). The single device-host folder collapses, so it materialises at EQ-1/FOCAS/Identity/SeriesNumber. - private const string FixedTreeRef = "10.0.0.5:8193/Identity/SeriesNumber"; - private const string FixedTreeDisplayName = "SeriesNumber"; - - // The DETERMINISTIC NodeId the chain must place the FixedTree node at: EQ-1 (the bound equipment root) + - // the COLLAPSED folder path. The mapper's device-folder collapse drops the single shared device-host - // segment ("10.0.0.5:8193"), so FolderPathSegments ["FOCAS","10.0.0.5:8193","Identity"] + browse - // "SeriesNumber" → "EQ-1/FOCAS/Identity/SeriesNumber" (per EquipmentNodeIds.Variable). Asserting this - // EXACT NodeId closes the loop on the collapse rule — a prefix/StartsWith check would still pass if the - // collapse broke (e.g. "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber"). - private const string ExpectedFixedTreeNodeId = "EQ-1/FOCAS/Identity/SeriesNumber"; - - private static DiscoveredNode[] FixedTreeNodes() => new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "SeriesNumber", - DisplayName: FixedTreeDisplayName, - FullReference: FixedTreeRef, - DataType: DriverDataType.String, - IsArray: false, - ArrayDim: null, - Writable: false, - IsHistorized: false), - }; - - /// - /// End-to-end #1: the discovered FixedTree node appears at the equipment AND a polled value flows - /// Good. Drives the real host (deployment applied, real child spawned, discovery reported) wired to a - /// real publish actor + real applier + recording sink, then asserts: - /// (a) the sink recorded an EnsureVariable for the FixedTree node under EQ-1 (materialised - /// through the REAL applier — the node now exists in the served address space), with a - /// RaiseNodesAddedModelChange under EQ-1 so connected clients refresh; - /// (b) after an for the FixedTree ref, the - /// sink recorded a WriteValue at THAT SAME NodeId carrying the value with - /// — proving the live value routed end-to-end and (in production) - /// overwrote the BadWaitingForInitialData seed. - /// - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good() - { - var db = NewInMemoryDbFactory(); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (host, sink, _) = SpawnHostWithRealPublishActor(db, deploymentId); - - // Driver reports its captured FixedTree (the faithful Task-7/8 seam). - host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes())); - - // (a) The discovered variable was materialised through the REAL applier onto the sink, at the EXACT - // collapsed NodeId under the bound equipment root (proves the mapper's device-folder collapse). - AwaitAssert(() => - { - var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName); - v.NodeId.ShouldBe(ExpectedFixedTreeNodeId); // EnsureVariable at the exact collapsed NodeId under EQ-1 - v.DataType.ShouldBe("String"); // mapper carried the driver type through to the sink - v.Writable.ShouldBeFalse(); // discovered nodes are read-only - sink.ModelChanges.ShouldContain("EQ-1"); // NodeAdded announced under the equipment - }, duration: Timeout); - - // (b) A value published for the FixedTree ref routes to THAT exact NodeId and lands Good — the live - // value flowed end-to-end (host routing map → publish actor → applier-backing sink WriteValue). - host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-12345", OpcUaQuality.Good, Ts)); - - AwaitAssert(() => - { - var write = sink.Values.SingleOrDefault(x => x.NodeId == ExpectedFixedTreeNodeId); - write.NodeId.ShouldBe(ExpectedFixedTreeNodeId); - write.Value.ShouldBe("SN-12345"); - write.Quality.ShouldBe(OpcUaQuality.Good); - write.Ts.ShouldBe(Ts); - }, duration: Timeout); - } - - /// - /// End-to-end #2 (Task 8 survival): the injected FixedTree node + its live-value route SURVIVE a - /// redeploy. After the first injection materialises + a value flows Good, a SECOND deployment (new - /// revision, same d1 → EQ-1 binding) re-runs PushDesiredSubscriptions — which clears the - /// routing maps and re-pushes an authored-only subscription set; the Task-8 tail re-apply re-grafts - /// the cached discovered plan. Asserts the sink records the FixedTree EnsureVariable AGAIN - /// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL - /// WriteValues Good there (the routing map was rebuilt, not left empty by the Clear()). - /// - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_node_and_value_survive_a_redeploy() - { - var db = NewInMemoryDbFactory(); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (host, sink, coordinator) = SpawnHostWithRealPublishActor(db, deploymentId); - - host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes())); - - // First injection: the FixedTree node materialises at the EXACT collapsed NodeId under EQ-1. - AwaitAssert( - () => sink.Variables.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && x.DisplayName == FixedTreeDisplayName), - duration: Timeout); - - // First value flows Good (pre-redeploy baseline). - host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-AAA", OpcUaQuality.Good, Ts)); - AwaitAssert( - () => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-AAA") && x.Quality == OpcUaQuality.Good), - duration: Timeout); - - var ensureVarCountBefore = sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId); - - // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding) — re-runs PushDesiredSubscriptions - // (clears + rebuilds the routing maps) then the Task-8 tail re-applies the cached discovered plan. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - host.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // (a) The cached discovered plan was RE-MATERIALISED at the SAME exact NodeId after the redeploy rebuild. - AwaitAssert( - () => sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId).ShouldBeGreaterThan(ensureVarCountBefore), - duration: Timeout); - - // (b) A value published AFTER the redeploy STILL routes to the exact NodeId and lands Good — the - // live-value routing map was rebuilt by the re-apply (not lost when PushDesiredSubscriptions cleared it). - var tsAfter = Ts.AddSeconds(5); - host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-BBB", OpcUaQuality.Good, tsAfter)); - AwaitAssert( - () => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-BBB") && x.Quality == OpcUaQuality.Good), - duration: Timeout); - } - - /// Spawns the real chain — recording sink → real → real - /// (the host's opcUaPublishActor seam) → real - /// backed by a — dispatches the - /// deployment, and waits for the Applied ACK so _lastComposition + the live child + the initial - /// subscribe pass have completed before discovery is injected. The publish actor is wired with the - /// applier but NO dbFactory, so its apply-time RebuildAddressSpace is a raw sink rebuild (no EnsureVariable) - /// and the only EnsureVariable traffic is the discovered-node materialise itself. - private (IActorRef Host, RecordingSink Sink, Akka.TestKit.TestProbe Coordinator) SpawnHostWithRealPublishActor( - IDbContextFactory db, DeploymentId deploymentId) - { - var coordinator = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var publish = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); - - var host = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: new SubscribingDriverFactory("Modbus"), - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish, - virtualTagHostOverride: vtHost.Ref)); - - host.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - return (host, sink, coordinator); - } - - /// Seeds a Sealed deployment whose artifact carries the minimal arrays needed to project - /// equipment tags + a real (non-stubbed) child for each driver - /// (mirrors DriverHostActorDiscoveryTests.SeedDeploymentWithEquipmentTags). An authored value tag - /// both sets _lastComposition and binds the driver → equipment (the only way the host resolves the - /// equipment a discovered node grafts under). - private static DeploymentId SeedDeploymentWithEquipmentTags( - IDbContextFactory db, RevisionHash rev, - params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags) - { - var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray(); - - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = driverIds.Select(d => new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = d, - Name = d, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }).ToArray(), - Tags = tags.Select((t, i) => new - { - TagId = $"tag-{i}", - EquipmentId = t.Equip, - DriverInstanceId = t.Driver, - Name = t.Name, - FolderPath = t.Folder, - DataType = "Double", - TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }), - }).ToArray(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// Factory producing a single shared for the supported - /// type, so a real (non-stubbed) child is spawned for the driver and - /// the host's subscribe path is exercised (mirrors - /// DriverHostActorDiscoveryTests.SubscribingDriverFactory). - private sealed class SubscribingDriverFactory : IDriverFactory - { - private readonly string _supportedType; - private readonly SubscribableStubDriver _driver = new(); - public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } - - /// - public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => - string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null; - - /// - public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; - } - - /// Recording — captures the EnsureFolder / EnsureVariable / - /// WriteValue / RaiseNodesAddedModelChange calls the real applier + publish actor drive, so the test can - /// assert the discovered node was materialised and a value landed Good at its NodeId. Thread-safe (the - /// publish actor runs on an Akka dispatcher thread, the test asserts from the test thread). - private sealed class RecordingSink : IOpcUaAddressSpaceSink - { - private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName)> _folders = new(); - private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> _variables = new(); - private readonly ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> _values = new(); - private readonly ConcurrentQueue _modelChanges = new(); - - /// Gets a snapshot of the recorded EnsureFolder calls. - public List<(string NodeId, string? ParentNodeId, string DisplayName)> Folders => _folders.ToList(); - /// Gets a snapshot of the recorded EnsureVariable calls. - public List<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> Variables => _variables.ToList(); - /// Gets a snapshot of the recorded WriteValue calls. - public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values => _values.ToList(); - /// Gets a snapshot of the recorded RaiseNodesAddedModelChange announcements. - public List ModelChanges => _modelChanges.ToList(); - /// Gets the count of raw RebuildAddressSpace calls (apply-time rebuild fallback). - public int RebuildCalls; - - /// Records a live-value write. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) - => _values.Enqueue((nodeId, value, quality, sourceTimestampUtc)); - - /// No-op: alarm writes are not exercised by this suite. - public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - - /// No-op: alarm materialise is not exercised by this suite. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } - - /// Records an EnsureFolder call. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) - => _folders.Enqueue((folderNodeId, parentNodeId, displayName)); - - /// Records an EnsureVariable call. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) - => _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); - - /// Records a raw rebuild (the apply-time fallback when no dbFactory is wired). - public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); - - /// Records a NodeAdded model-change announcement. - public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId); - public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } - public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs deleted file mode 100644 index 2054eb51..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +++ /dev/null @@ -1,1327 +0,0 @@ -using System.Text.Json; -using Akka.Actor; -using Microsoft.EntityFrameworkCore; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; -using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; -using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.Configuration; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Verifies the discovered-node injection wired into (Task 7): when a -/// driver child reports a captured FixedTree via , -/// the host resolves the bound equipment from the authored composition, maps the nodes under it via -/// , materialises them on the OPC UA publish side -/// (), extends the live-value routing map -/// (_nodeIdByDriverRef), and merges the FixedTree refs into the driver's desired subscription set -/// (). -/// -/// -/// Drives a real apply through the existing harness (same artifact shape as -/// DriverHostActorLiveValueTests / DriverHostActorWriteRoutingTests) so -/// _lastComposition is set and a real (non-stubbed) child -/// is spawned for d1. The child is backed by the shared -/// (records LastSubscribedRefs/SubscribeCount, exactly as -/// DriverInstanceActorTests asserts) so the merged subscription is observable; the OPC UA -/// publish actor is a (as in -/// DriverHostActorLiveValueTests) so the materialise + the post-injection value route are -/// observable. There is no test seam to inject a probe AS a driver child, so this is the faithful -/// end-to-end approach the harness allows. -/// -/// -[Trait("Category", "Unit")] -public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase -{ - private static readonly NodeId TestNode = NodeId.Parse("driver-disc-test"); - private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); - private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); - private static readonly RevisionHash RevC = RevisionHash.Parse(new string('c', 64)); - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc); - - /// v3 Batch 4 (review M1) — the ONE live dormancy pin: discovered-node injection is short-circuited. - /// A driver reports a FixedTree via , but the host must - /// NOT materialise anything (no reaches the publish - /// side) — discovered raw tags are authored via the /raw browse-commit flow, not injected at runtime. - /// The skipped v2 injection scenarios below are the counterpart of this guard. - [Fact] - public void Discovered_nodes_are_ignored_dormant_in_v3() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // Dormant: no discovered nodes are materialised (the guard short-circuits before any route/materialise). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the - /// bound equipment: (a) the publish side receives - /// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the - /// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the - /// live-value routing map was extended). - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // One authored value tag: equipment EQ-1, driver d1, FullName "40001" — this both sets - // _lastComposition AND binds d1 → EQ-1 (the only way the equipment is resolved, since EquipmentNode - // carries no DriverInstanceId). - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - // A FixedTree discovered node whose FullReference DIFFERS from the authored tag's FullName, so the - // mapper keeps it (it does not shadow an authored ref). - var discovered = new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", - DisplayName: "Model", - FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - Writable: false, - IsHistorized: false), - }; - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)); - - // (a) The publish side materialises the discovered folders + variables UNDER the equipment root "EQ-1". - var materialise = publish.ExpectMsg(Timeout); - materialise.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise.Variables.Count.ShouldBe(1); - materialise.Folders.Count.ShouldBeGreaterThan(0); - var fixedTreeNodeId = materialise.Variables[0].NodeId; - - // (b) The driver re-subscribed the UNION of the authored value ref AND the FixedTree ref. The union - // push is the LAST SetDesiredSubscriptions, so the most recent subscribe carries both. - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - - // (c) A value published for the FixedTree ref now routes to the mapped FixedTree NodeId — proving the - // _nodeIdByDriverRef live-value map was extended by the injection. - actor.Tell(new DriverInstanceActor.AttributeValuePublished( - "d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); - - var update = publish.ExpectMsg(Timeout); - update.NodeId.ShouldBe(fixedTreeNodeId); - update.Value.ShouldBe(42.0); - update.Quality.ShouldBe(OpcUaQuality.Good); - update.TimestampUtc.ShouldBe(Ts); - } - - /// NEW capability (follow-up E): a driver bound to an equipment via - /// with ZERO authored equipment tags can still graft its - /// discovered FixedTree. The equipment is resolved from the composition's EquipmentNodes (not just - /// the authored EquipmentTags), so a tag-less equipment receives - /// rooted at its NodeId and the driver - /// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with - /// "no equipment/authored tags". - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // EQ-1 bound to driver d1 via EquipmentNode.DriverInstanceId, with NO authored equipment tags for d1. - var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1"); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // (a) The discovered nodes materialise UNDER EQ-1 even though no authored tag binds d1 → EQ-1. - var materialise = publish.ExpectMsg(Timeout); - materialise.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise.Variables.Count.ShouldBe(1); - var fixedTreeNodeId = materialise.Variables[0].NodeId; - - // (b) The driver subscribes the discovered ref (the union is just the FixedTree ref — no authored ref). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - }, duration: Timeout); - - // (c) A value published for the FixedTree ref routes to its mapped NodeId (routing map was extended). - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); - var update = publish.ExpectMsg(Timeout); - update.NodeId.ShouldBe(fixedTreeNodeId); - update.Value.ShouldBe(42.0); - } - - /// DEGENERATE multi-equipment case (follow-up E, part 2): a driver that resolves to MORE THAN ONE - /// equipment but where NONE of the candidates carries a has nothing - /// to partition the FixedTree on, so the whole driver is warn-skipped (nothing grafted, no crash). Here d1 - /// is bound to two equipments via two AUTHORED tags only (no Device rows ⇒ no DeviceHost), which is exactly - /// the degenerate shape: no is told. - [Fact] - public void Driver_mapping_to_more_than_one_equipment_with_no_device_host_warn_skips() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // d1 is bound to TWO equipments via two authored tags (no devices ⇒ no DeviceHost) ⇒ degenerate ⇒ warn-skip. - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"), - (Equip: "EQ-2", Driver: "d1", FullName: "40002", Folder: (string?)null, Name: "speed2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // Nothing is grafted — no candidate has a DeviceHost, so there's nothing to partition on (degenerate). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Multi-device split (follow-up E, part 2): a driver that resolves to >1 equipment, each bound to - /// a DEVICE with a distinct , partitions its discovered FixedTree by - /// the (normalized) device-host folder segment and grafts each device's subset under the equipment whose - /// DeviceHost matches. Asserts (a) TWO (one per - /// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref - /// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1" - /// vs stored "h1" wrinkle proves the SHARED DeviceConfigIntent.NormalizeHost match. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // d1 fans out to EQ-A (device host "h1") + EQ-B (device host "h2"), tag-less, bound via EquipmentNode. - var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", - (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), - (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - // Discovered nodes split across two device-host folder segments. "H1" is UPPERCASE to prove the shared - // NormalizeDeviceHost lower-cases the segment to match the stored lower-cased "h1" DeviceHost. - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "H1", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - })); - - // (a) Each device's subset materialises under its OWN equipment — two messages, one per equipment. - var m1 = publish.ExpectMsg(Timeout); - var m2 = publish.ExpectMsg(Timeout); - var byEquipment = new[] { m1, m2 }.ToDictionary(m => m.EquipmentRootNodeId, m => m); - byEquipment.Keys.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true); - byEquipment["EQ-A"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Model"); - byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run"); - var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId; - var eqBNodeId = byEquipment["EQ-B"].Variables[0].NodeId; - // Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries NO host - // segment and reads EQ-n/FOCAS//. Assert the EXACT collapsed path (depth + leaf) so a - // collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) — - // fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY - // (a lowercase-only "h1" check would miss a leaked raw "H1"). - eqANodeId.ShouldBe(V3NodeIds.Uns("EQ-A", "FOCAS", "Identity", "Model")); - eqANodeId.ShouldNotContain("H1"); - eqANodeId.ShouldNotContain("h1"); - eqBNodeId.ShouldBe(V3NodeIds.Uns("EQ-B", "FOCAS", "Status", "Run")); - eqBNodeId.ShouldNotContain("h2"); - - // (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-h1-1"); - refs.ShouldContain("ft-h2-1"); - }, duration: Timeout); - - // (c) Routing is keyed per device: h1's ref lands on EQ-A's node, h2's on EQ-B's node. - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h1-1", 1.0, OpcUaQuality.Good, Ts)); - publish.ExpectMsg(Timeout).NodeId.ShouldBe(eqANodeId); - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h2-1", 2.0, OpcUaQuality.Good, Ts)); - publish.ExpectMsg(Timeout).NodeId.ShouldBe(eqBNodeId); - } - - /// Multi-device unmatched warn-skip (follow-up E, part 2): a discovered device-host partition with - /// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here - /// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only - /// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash). - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", - (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), - (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" }, - BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - })); - - // Exactly TWO materialise (EQ-A, EQ-B); the unmatched "h3" grafts nowhere ⇒ no third materialise. - var m1 = publish.ExpectMsg(Timeout); - var m2 = publish.ExpectMsg(Timeout); - new[] { m1.EquipmentRootNodeId, m2.EquipmentRootNodeId }.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - - // The ghost ref was never materialised, so a value for it routes nowhere — dropped, not a crash. - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h3-1", 9.0, OpcUaQuality.Good, Ts)); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - } - - /// Warn-spam taming (follow-up E, part 2): an unmatched device-host partition WARNS exactly ONCE, - /// then the identical repeated re-discovery passes (the driver re-discovers ~15×/connect, re-sending the - /// same set) are quiet — proving ShouldWarnPartition's per-driver signature dedup. The repeat is - /// logged at Debug, which the suite's loglevel = WARNING HOCON suppresses at source, so EventFilter - /// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on - /// pass 1; pass 2's matched routing is short-circuited by PlansRoutingEqual.) - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", - (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), - (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - var discovered = new[] - { - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" }, - BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - }; - - // Pass 1: the unmatched "h3" partition warns EXACTLY once. - EventFilter.Warning(contains: "discovered device-host partition(s) skipped") - .Expect(1, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered))); - - // Drain the matched EQ-A + EQ-B grafts from pass 1 so the assertion below is unambiguous. - publish.ExpectMsg(Timeout); - publish.ExpectMsg(Timeout); - - // Pass 2: the IDENTICAL set (same revision) does NOT warn again — the repeat is Debug (suppressed by the - // suite's WARNING loglevel), so EventFilter sees ZERO further matching warnings. - EventFilter.Warning(contains: "discovered device-host partition(s) skipped") - .Expect(0, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered))); - } - - /// Guard: a arriving BEFORE any deployment - /// is applied (_lastComposition still null) is ignored — nothing is materialised on the publish - /// side (the equipment can't be resolved without a composition). - [Fact] - public void DiscoveredNodes_before_any_apply_are_ignored() - { - var db = NewInMemoryDbFactory(); - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - // No deployment dispatched ⇒ Bootstrap enters Steady with no composition ⇒ _lastComposition is null. - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: new SubscribingDriverFactory("Modbus"), - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // No composition ⇒ no materialise (and no RebuildAddressSpace either, since nothing was applied). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Dedup: a discovered node whose FullReference equals an authored equipment tag's - /// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs - /// are materialised. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_node_shadowing_an_authored_ref_is_not_injected() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - // Two captured nodes: one SHADOWS the authored ref "40001" (must be dropped), one is genuinely new. - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Registers" }, - BrowseName: "speed", DisplayName: "Speed", FullReference: "40001", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // Exactly ONE variable materialised — the new "Model", not the authored-shadow "Speed". - var materialise = publish.ExpectMsg(Timeout); - materialise.Variables.Count.ShouldBe(1); - materialise.Variables[0].DisplayName.ShouldBe("Model"); - } - - /// Idempotency / the unchanged-plan short-circuit: re-sending the SAME discovered set (the driver - /// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to - /// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising - /// FixedTree still converges. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - var node1 = new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false); - var node2 = new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-ref-2", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false); - - // Pass 1: a new set ⇒ one materialise + a union re-subscribe. - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 })); - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - }, duration: Timeout); - var subscribeCountAfterFirst = factory.SubscribeCount; - - // Pass 2: the IDENTICAL set ⇒ short-circuited (no materialise, no re-subscribe). - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 })); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - factory.SubscribeCount.ShouldBe(subscribeCountAfterFirst); - - // Pass 3: a GROWN set (superset) ⇒ re-applies (materialise again + re-subscribe with both refs). - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1, node2 })); - publish.ExpectMsg(Timeout).Variables.Count.ShouldBe(2); - AwaitAssert(() => - { - factory.SubscribeCount.ShouldBeGreaterThan(subscribeCountAfterFirst); - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - refs.ShouldContain("ft-ref-2"); - }, duration: Timeout); - } - - /// Task 8 survival: discovered (FixedTree) nodes injected after the first apply must SURVIVE a - /// redeploy. A second deployment re-runs PushDesiredSubscriptions, which clears the live-value - /// routing maps and re-pushes an authored-only subscription set; without the tail re-apply the injected - /// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached - /// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's - /// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_nodes_survive_a_redeploy_rebuild() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - var discovered = new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - }; - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)); - - // First injection: materialise #1 under EQ-1 — capture the mapped NodeId for the survival asserts. - var materialise1 = publish.ExpectMsg(Timeout); - materialise1.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise1.Variables.Count.ShouldBe(1); - var fixedTreeNodeId = materialise1.Variables[0].NodeId; - - // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding so HandleDispatchFromSteady doesn't - // short-circuit on an identical rev). This re-runs PushDesiredSubscriptions, which clears + rebuilds - // the routing maps and re-pushes the authored-only subscription set — the exact path Task 8 self-heals. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // The redeploy fires a fresh RebuildAddressSpace first (drain it) ... - publish.ExpectMsg(Timeout); - - // (a) ... then the cached discovered plan is RE-MATERIALISED under EQ-1 (the Task-8 tail re-apply), - // at the SAME NodeId the first injection placed it. - var materialise2 = publish.ExpectMsg(Timeout); - materialise2.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise2.Variables.Count.ShouldBe(1); - materialise2.Variables[0].NodeId.ShouldBe(fixedTreeNodeId); - - // (b) The child's post-redeploy subscription STILL carries the FixedTree ref — it was re-merged onto - // the freshly-cleared authored-only set, not dropped by the _nodeIdByDriverRef.Clear(). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - - // (c) A value published for the FixedTree ref STILL routes to its mapped NodeId — proving the - // live-value routing map was rebuilt by the re-apply (not left empty after the Clear()). - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); - var update = publish.ExpectMsg(Timeout); - update.NodeId.ShouldBe(fixedTreeNodeId); - update.Value.ShouldBe(42.0); - update.Quality.ShouldBe(OpcUaQuality.Good); - } - - /// Task 8 cache-drop: a redeploy whose composition no longer binds the driver to any equipment - /// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a - /// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell - /// for the now-unresolved driver. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_nodes_dropped_when_equipment_no_longer_resolves() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection materialises under EQ-1. - publish.ExpectMsg(Timeout); - - // Apply a SECOND deployment that binds a DIFFERENT driver (d2 → EQ-2) and carries NO authored tags for - // d1, so d1's equipment can no longer be resolved (equipmentIds.Count == 0) — the cache entry is dropped. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d2", FullName: "40002", Folder: (string?)null, Name: "speed2")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // The redeploy fires a fresh RebuildAddressSpace; after draining it, NO MaterialiseDiscoveredNodes is - // re-told (the cached d1 plan was dropped because its equipment no longer resolves). - publish.ExpectMsg(Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Task 8 rebind-guard: a redeploy that REBINDS the driver to a DIFFERENT equipment must DROP the - /// cached discovered plan rather than re-graft EQ-1-scoped nodes under EQ-2. d1 still resolves to exactly - /// one equipment (so the Count==0 drop does NOT fire), but the cached plan's NodeIds are scoped to the OLD - /// equipment (EQ-1), so the StartsWith(equipmentId + "/") guard sees they no longer match EQ-2 and - /// drops the entry. After the redeploy NO is - /// re-told. (Complements , which - /// covers the Count==0 branch; this covers the rebind/StartsWith branch.) - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection materialises under EQ-1 (the cached plan's NodeIds are scoped to EQ-1). - publish.ExpectMsg(Timeout); - - // Apply a SECOND deployment where d1 is REBOUND to a DIFFERENT equipment EQ-2 (d1 still present + still - // resolves to exactly one equipment, but the cached plan is scoped to EQ-1). The DriverConfig is - // unchanged ("{}") so ReconcileDrivers does NOT restart d1 — exactly the config-unchanged rebind the - // guard's known-limitation comment describes. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // After draining the fresh RebuildAddressSpace, NO MaterialiseDiscoveredNodes is re-told — the cached - // EQ-1-scoped plan was dropped by the rebind guard (its NodeId no longer starts with "EQ-2/"). - publish.ExpectMsg(Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Follow-up C, part 2: a CONFIG-UNCHANGED rebind must RE-TRIGGER discovery on the driver's child - /// so the dropped FixedTree re-grafts under the NEW equipment on the next pass (rather than staying absent - /// until the driver's next natural reconnect). Wires a REAL child (policy - /// , ever-growing set) so the connect-time pass populates the - /// per-equipment cache under EQ-1, then redeploys to rebind d1 → EQ-2 with the SAME (unchanged) DriverConfig - /// (so ReconcileDrivers does NOT restart the child — exactly the config-unchanged rebind). The - /// re-inject tail drops the stale EQ-1 entry and must send - /// to d1's child. The trigger reaching the child is observed at the host level (the only faithful seam — there - /// is no probe-as-driver-child seam): the child re-runs discovery (its DiscoverCount advances past the - /// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks - /// discovery) AND a fresh re-grafts the FixedTree - /// under the new equipment EQ-2. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Config_unchanged_rebind_re_triggers_discovery_on_the_child() - { - var db = NewInMemoryDbFactory(); - // A REAL ITagDiscovery child (Once policy) — so the connect-time pass populates the cache and a - // TriggerRediscovery re-runs discovery, making the trigger observable at the host level. - var factory = new DiscoverableSubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: factory, - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - - // The child connects and runs its single (Once) post-connect discovery pass, which the host grafts - // under EQ-1 — this POPULATES the per-equipment cache (_discoveredByDriver[d1] = { EQ-1: plan }). - publish.FishForMessage( - m => m.EquipmentRootNodeId == "EQ-1", Timeout); - AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout); - - // Redeploy: REBIND d1 from EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged so ReconcileDrivers - // does NOT restart the child). The re-inject tail drops the stale EQ-1-scoped cache entry. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // The drop must SEND DriverInstanceActor.TriggerRediscovery to d1's (still-Connected) child, which - // re-runs discovery: DiscoverCount advances past the single connect pass — the observable proof the - // trigger reached the child. (Pre-task: no trigger ⇒ Once already settled, child never reconnects ⇒ - // DiscoverCount stays 1 ⇒ this fails.) - AwaitAssert(() => factory.DiscoverCount.ShouldBeGreaterThan(1), duration: TimeSpan.FromSeconds(10)); - - // ... and the re-triggered pass re-grafts the FixedTree under the NEW equipment EQ-2 (the host resolves - // d1 → EQ-2 against the new _lastComposition). The re-discovery re-populates the cache as - // { EQ-2: plan-scoped-to-EQ-2 }. - publish.FishForMessage( - m => m.EquipmentRootNodeId == "EQ-2", Timeout); - var discoverCountAfterRebind = factory.DiscoverCount; // 2 (connect pass + the rebind re-trigger pass) - - // Convergence: a FURTHER unchanged redeploy (SAME EQ-2 composition) must NOT drop again, NOT re-trigger, - // and NOT advance discovery — proving the re-trigger CONVERGES and doesn't loop. The cached EQ-2 plan now - // SURVIVES the re-inject tail (candidate + NodeIds still scoped to EQ-2), so EQ-2 simply re-materialises - // (the Task-8 survivor re-apply) — but droppedAny stays false, so no TriggerRediscovery is sent and no - // fresh discovery pass runs. - var deploymentId3 = SeedDeploymentWithEquipmentTags(db, RevC, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId3, RevC, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // The survivor re-apply re-materialises EQ-2 (NOT a drop+re-trigger). - publish.ExpectMsg(Timeout) - .EquipmentRootNodeId.ShouldBe("EQ-2"); - // No further publish traffic and NO further discovery pass — the re-trigger converged. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - factory.DiscoverCount.ShouldBe(discoverCountAfterRebind); - } - - /// Negative / regression guard: a routine redeploy of a discovery-capable driver that does NOT - /// rebind (SAME composition, new revision) must NOT spuriously re-trigger discovery. The cached EQ-1 plan - /// SURVIVES the re-inject tail (its equipment still resolves and its NodeIds are still EQ-1-scoped), so it is - /// re-applied (EQ-1 re-materialises — the Task-8 survival path) — but NOTHING is dropped, so droppedAny - /// stays false and no is sent. Uses the REAL - /// child (the existing ExpectNoMsg drop tests use a NON-discovery driver, - /// whose child would no-op a TriggerRediscovery in StartDiscovery's is not ITagDiscovery - /// guard — so they would NOT catch a future regression that sets droppedAny on a non-drop path). The - /// regression is observable here: a spurious trigger would advance DiscoverCount past the single Once - /// pass. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void No_drop_redeploy_does_not_re_trigger_discovery() - { - var db = NewInMemoryDbFactory(); - var factory = new DiscoverableSubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: factory, - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - - // Connect-time discovery grafts under EQ-1 and populates the cache (DiscoverCount == 1). - publish.FishForMessage( - m => m.EquipmentRootNodeId == "EQ-1", Timeout); - AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout); - - // Redeploy with the SAME composition (new revision so it applies; NO rebind ⇒ NO drop). - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // The cached EQ-1 plan SURVIVES the re-inject tail and is re-applied — EQ-1 re-materialises. - publish.ExpectMsg(Timeout) - .EquipmentRootNodeId.ShouldBe("EQ-1"); - // But NOTHING was dropped, so NO TriggerRediscovery was sent: no fresh discovery pass runs and no - // further publish traffic arrives. DiscoverCount stays 1. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - factory.DiscoverCount.ShouldBe(1); - } - - /// Follow-up D (one-send invariant — SURVIVOR path): a CACHED (FixedTree-discovered) driver whose - /// plan SURVIVES a redeploy must receive EXACTLY ONE - /// for that pass — the authored∪discovered UNION the re-inject tail sends — NOT the old TWO (a bulk - /// authored-only send that dropped the whole handle, then the tail union that re-subscribed it). Observed via - /// the shared driver's SubscribeCount (each non-empty SetDesiredSubscriptions ⇒ exactly one - /// SubscribeAsync — no de-dup in ): the count rises by EXACTLY 1 across the - /// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only - /// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".) - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection: the union subscribe (authored "40001" + discovered "ft-ref-1") lands and the cache is - // populated (_discoveredByDriver[d1] = { EQ-1: plan }). - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - // Let the first-injection traffic settle, then snapshot the subscribe count as the redeploy baseline. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy the SAME composition (new revision so it applies; d1 → EQ-1 unchanged ⇒ the cached plan - // SURVIVES the re-inject tail ⇒ re-applied as a single union send). - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - publish.ExpectMsg(Timeout); // tail survivor re-materialise - - // EXACTLY ONE SetDesiredSubscriptions this redeploy: the count rises by 1 and the set is the union (the - // combined condition is UNSATISFIABLE under the old double-send — at count+1 the set was authored-only - // (no "ft-ref-1"), at count+2 the count overshoots — so this fails RED before the fix). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - }, duration: Timeout); - // Settle + re-confirm the count did NOT creep to +2 (the retired bulk authored-only + tail union double-send). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - } - - /// Follow-up D (one-send invariant — DROPPED path): a CACHED driver whose plan is FULLY DROPPED by a - /// config-unchanged rebind (the inner map empties ⇒ the driver is removed from _discoveredByDriver) - /// must still receive EXACTLY ONE — the AUTHORED-ONLY - /// fallback — so its authored subscriptions are not lost now that the bulk loop SKIPS cached drivers. The - /// re-inject tail no longer re-applies a (now-empty) plan for it, so the fallback is the only send. Observed - /// via SubscribeCount (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also - /// gets a — a different message type the non-discovery - /// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy REBINDING d1 EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged ⇒ child NOT restarted). - // The cached EQ-1-scoped plan is dropped by the rebind guard ⇒ the inner map empties ⇒ d1 is removed from - // _discoveredByDriver ⇒ NO survivor re-apply. The fallback must send the authored-only set so "40001" - // stays subscribed this pass. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // No MaterialiseDiscoveredNodes — the plan was dropped, not re-grafted — so no further publish traffic. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - - // EXACTLY ONE SetDesiredSubscriptions this redeploy: the authored-only fallback. The count rises by 1 and - // the set is "40001" only (the dropped FixedTree ref is gone). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldNotContain("ft-ref-1"); - refs.Count.ShouldBe(1); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - } - - /// Follow-up D (one-send invariant — NON-CACHED path): a driver that was NEVER cached (no FixedTree - /// discovered) is unaffected by the cached-driver bulk-loop skip — it still gets EXACTLY ONE bulk - /// authored-only per redeploy (the re-inject tail - /// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's - /// send. Observed via SubscribeCount (+1) and the subscribed set ("40001" only). - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - // No DiscoveredNodesReady ⇒ d1 is never cached. Wait for the initial bulk subscribe to settle, then - // snapshot the count as the redeploy baseline. - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy SAME composition (new rev). d1 is NOT in _discoveredByDriver ⇒ the bulk loop sends it once and - // the re-inject tail skips it. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // no materialise — d1 was never cached - - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.Count.ShouldBe(1); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - } - - /// Follow-up D (one-send invariant — EMPTY-authored DROPPED path, a capability THIS branch newly - /// enabled): a CACHED driver with ZERO authored tags (bound to its equipment only via - /// ) whose FixedTree plan is FULLY DROPPED by a rebind redeploy - /// receives an EMPTY authored-only fallback , which - /// the Connected handler routes to Unsubscribe (dropping the stale FixedTree handle) — NOT a subscribe. - /// Proven by SubscribeCount staying FLAT across the redeploy (no spurious subscribe), closing the - /// SubscribeCount-proxy blind spot for the empty-set fallback. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // d1 bound to EQ-1 via EquipmentNode.DriverInstanceId, with NO authored tags. - var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1"); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection: the discovered FixedTree materialises under EQ-1 and the child subscribes the - // discovered-only set (no authored ref to union) — this populates _discoveredByDriver[d1] = { EQ-1: plan }. - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy REBINDING the tag-less d1 EQ-1 → EQ-2 (still tag-less; DriverConfig "{}" unchanged ⇒ child NOT - // restarted). The cached EQ-1 plan is no longer a candidate ⇒ dropped ⇒ inner map empties ⇒ d1 removed - // from _discoveredByDriver. With ZERO authored tags the fallback set is EMPTY ⇒ the child Unsubscribes. - var deploymentId2 = SeedDeploymentWithTagLessEquipment(db, RevB, equipmentId: "EQ-2", driverId: "d1"); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // No re-materialise — the cached plan was dropped (not re-grafted). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - - // The empty fallback routes to Unsubscribe, NOT SubscribeAsync ⇒ the count does NOT rise. (A non-empty or - // spurious subscribe — the bug this guards against — would increment it.) - factory.SubscribeCount.ShouldBe(countBeforeRedeploy); - } - - /// Spawns the host with the subscribing driver factory + a publish probe, dispatches the - /// deployment, and waits for the Applied ACK so the apply (and thus _lastComposition + the live - /// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A - /// VirtualTag-host probe is injected so the real host isn't spawned. The - /// that lands on the publish probe during apply is drained so the test's materialise / value-update - /// assertions see only post-apply traffic. - private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply( - IDbContextFactory db, DeploymentId deploymentId, IDriverFactory factory) - { - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: factory, - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - publish.ExpectMsg(Timeout); - - return (actor, publish, coordinator); - } - - /// - /// Seeds a Sealed deployment whose artifact carries the minimal arrays - /// DeploymentArtifact.BuildEquipmentTagPlans needs to project equipment tags, plus a - /// DriverInstances row with a non-Windows-only DriverType ("Modbus") + Enabled flag so - /// a REAL (non-stubbed) child is spawned (mirrors - /// DriverHostActorWriteRoutingTests.SeedDeploymentWithEquipmentTags). - /// - private static DeploymentId SeedDeploymentWithEquipmentTags( - IDbContextFactory db, RevisionHash rev, - params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags) - { - var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray(); - - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = driverIds.Select(d => new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = d, - Name = d, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }).ToArray(), - Tags = tags.Select((t, i) => new - { - TagId = $"tag-{i}", - EquipmentId = t.Equip, - DriverInstanceId = t.Driver, - Name = t.Name, - FolderPath = t.Folder, - DataType = "Double", - TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }), - }).ToArray(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// - /// Seeds a Sealed deployment whose artifact binds an equipment to a driver via the - /// Equipment row's DriverInstanceId (the - /// projection) but carries NO authored equipment tags — so the equipment can only be resolved from - /// EquipmentNodes, not EquipmentTags. A DriverInstances row (non-Windows-only - /// "Modbus", Enabled) is included so a REAL (non-stubbed) child is - /// spawned for the driver even though it has zero tags. - /// - private static DeploymentId SeedDeploymentWithTagLessEquipment( - IDbContextFactory db, RevisionHash rev, string equipmentId, string driverId) - { - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = new[] - { - new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = driverId, - Name = driverId, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }, - }, - // The equipment binds to the driver via DriverInstanceId — the NEW resolution path — with no tags. - Equipment = new[] - { - new - { - EquipmentId = equipmentId, - Name = equipmentId, - UnsLineId = (string?)null, - DriverInstanceId = driverId, - DeviceId = (string?)null, - }, - }, - Tags = Array.Empty(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// - /// Seeds a Sealed deployment whose artifact binds ONE driver to MULTIPLE equipments, each via a - /// distinct Device row whose DeviceConfig carries a HostAddress — so each - /// resolves a distinct (the shape - /// the multi-device FixedTree partition keys on). No authored tags (tag-less): the equipments are - /// resolved purely from EquipmentNodes. The driver row is non-Windows-only ("Modbus", Enabled) - /// so a REAL (non-stubbed) child is spawned. - /// - private static DeploymentId SeedDeploymentWithMultiDeviceEquipments( - IDbContextFactory db, RevisionHash rev, string driverId, - params (string Equip, string DeviceId, string Host)[] equipments) - { - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = new[] - { - new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = driverId, - Name = driverId, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }, - }, - // Each device carries a HostAddress so EquipmentNode.DeviceHost resolves (via the shared extractor). - Devices = equipments.Select(e => new - { - DeviceId = e.DeviceId, - DriverInstanceId = driverId, - Name = e.DeviceId, - DeviceConfig = JsonSerializer.Serialize(new { HostAddress = e.Host }), - }).ToArray(), - // Each equipment binds to the driver AND a device — the multi-device resolution path. - Equipment = equipments.Select(e => new - { - EquipmentId = e.Equip, - Name = e.Equip, - UnsLineId = (string?)null, - DriverInstanceId = driverId, - DeviceId = e.DeviceId, - }).ToArray(), - Tags = Array.Empty(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// Factory producing a single shared for the supported - /// type, exposing its most-recent subscribed reference set for assertions (mirrors - /// DriverHostActorWriteRoutingTests.RecordingDriverFactory, but the driver is - /// so the merged subscription is observable). - private sealed class SubscribingDriverFactory : IDriverFactory - { - private readonly string _supportedType; - private readonly SubscribableStubDriver _driver = new(); - public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } - - /// The reference set passed to the driver's most recent SubscribeAsync call. - public IReadOnlyList? LastSubscribedRefs => _driver.LastSubscribedRefs; - - /// Number of SubscribeAsync calls so far — lets a test prove a redundant re-apply did - /// NOT force a (drop-then-)re-subscribe of the whole handle. - public int SubscribeCount => _driver.SubscribeCount; - - /// - public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => - string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null; - - /// - public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; - } - - /// Factory producing a single shared for the supported - /// type — a real (non-stubbed) child that exposes , - /// so the host's post-connect discovery loop populates the discovered-node cache AND a - /// re-runs discovery (the seam the rebind re-trigger - /// test asserts through). Exposes the driver's pass count so a test can observe the trigger landing. - private sealed class DiscoverableSubscribingDriverFactory : IDriverFactory - { - private readonly string _supportedType; - private DiscoverableSubscribingDriver? _driver; - public DiscoverableSubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } - - /// Number of DiscoverAsync passes the child has driven (advances on every - /// connect-time pass and every -driven pass). - public int DiscoverCount => _driver?.DiscoverCount ?? 0; - - /// - public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => - string.Equals(driverType, _supportedType, StringComparison.Ordinal) - ? _driver ??= new DiscoverableSubscribingDriver(driverInstanceId) - : null; - - /// - public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; - } - - /// A that is BOTH (so the host's subscribe - /// path is exercised) and with policy - /// — exactly one post-connect pass, re-runnable by . Each - /// pass streams an ever-growing FixedTree (pass N → N nodes, refs "ft-ref-1".."ft-ref-N", none shadowing the - /// authored "40001"), so a re-triggered pass yields a grown set the host re-applies — and the public - /// makes the trigger observable at the host level. Re-implements - /// (the base hardcodes "stub-driver-1") so the - /// spawned child reports the SPEC's id — otherwise its auto-sent - /// would carry the wrong driver id and resolve no equipment. - private sealed class DiscoverableSubscribingDriver : StubDriver, IDriver, ISubscribable, ITagDiscovery - { - private int _passCount; - private readonly string _driverInstanceId; - private readonly StubHandle _handle = new(); - - public DiscoverableSubscribingDriver(string driverInstanceId) => _driverInstanceId = driverInstanceId; - - /// The spec's driver instance id (re-mapped from the base "stub-driver-1"). - public new string DriverInstanceId => _driverInstanceId; - - /// Single post-connect pass per (re)kick — re-runnable by TriggerRediscovery. - public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; - - /// Number of DiscoverAsync passes driven so far. - public int DiscoverCount => Volatile.Read(ref _passCount); - - /// Never raised (the test asserts on discovery + materialise, not data changes); explicit - /// empty accessors satisfy the interface without a never-used backing field (no CS0067). - public event EventHandler? OnDataChange { add { } remove { } } - - /// - public Task SubscribeAsync( - IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) - => Task.FromResult(_handle); - - /// - public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) - => Task.CompletedTask; - - /// Streams an ever-growing FixedTree (pass N → N nodes). - public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var pass = Interlocked.Increment(ref _passCount); - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < pass; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"ft-ref-{i + 1}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - return Task.CompletedTask; - } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorUnreadableArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorUnreadableArtifactTests.cs index 1036d4a3..a3f5f1bc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorUnreadableArtifactTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorUnreadableArtifactTests.cs @@ -78,10 +78,17 @@ public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBas factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef); } - /// Positive control for the subscription half: a READABLE artifact that keeps the driver but - /// drops its last tag genuinely does clear the live subscription — the child receives an empty desired - /// set and unsubscribes. This proves both that the unsubscribe is observable through this factory and - /// that it lands well within , so the absence asserted above is real. + /// + /// Positive control for the subscription half: a READABLE artifact that drops the driver's last tag + /// genuinely does tear the live subscription down, and does so well within + /// — without this, the absence asserted above would pass on a race. + /// The observable changed with #516. Dropping a tag changes the driver's + /// DriverConfig, which used to be an in-place delta: the child received an empty desired set + /// and called UnsubscribeAsync. A config change is now a stop + respawn, so the old child is + /// STOPPED (taking its subscription with it via ShutdownAsync) and a fresh one spawns with + /// nothing to subscribe to. The teardown is therefore observed as a shutdown, not an unsubscribe — + /// the same event, reached by a different route. + /// [Fact] public void Dropping_a_drivers_last_tag_does_clear_its_subscription() { @@ -90,13 +97,13 @@ public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBas var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory); AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout); - factory.UnsubscribeCount.ShouldBe(0); + factory.ShutdownCount.ShouldBe(0); actor.Tell(new DispatchDeployment(SeedTaglessDriverDeployment(db, RevB), RevB, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout); - AwaitAssert(() => factory.UnsubscribeCount.ShouldBe(1), duration: SettleWindow); - AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // the driver itself stayed + AwaitAssert(() => factory.ShutdownCount.ShouldBeGreaterThanOrEqualTo(1), duration: SettleWindow); + AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // respawned under the same id } /// Positive control: the guard keys on "the read gave us nothing", not on "the new config has @@ -318,6 +325,9 @@ public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBas /// Number of UnsubscribeAsync calls — non-zero means a live handle was torn down. public int UnsubscribeCount => Volatile.Read(ref _driver.UnsubscribeCount); + /// Number of ShutdownAsync calls — non-zero means a driver child was stopped. + public int ShutdownCount => Volatile.Read(ref _driver.ShutdownCount); + /// public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => string.Equals(driverType, supportedType, StringComparison.Ordinal) ? _driver : null; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs deleted file mode 100644 index 3ae48247..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs +++ /dev/null @@ -1,583 +0,0 @@ -using Akka.Actor; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.Resilience; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Covers the bounded post-connect re-discovery loop: when an driver -/// reaches Connected, runs repeated discovery passes (FOCAS-style: -/// the FixedTree is suppressed until the driver's cache populates ~0–2s after connect) and ships each -/// pass's captured nodes to its parent as . The -/// loop STOPS once the non-empty discovered set stabilises (or the attempt cap is hit) — it must not -/// spin forever. A driver that does not implement produces no passes at all. -/// -[Trait("Category", "Unit")] -public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase -{ - /// - /// A discoverable driver whose first two passes yield nothing (cache still warming) and whose third - /// pass onward yields a stable 3-node set: the actor ships every pass, then STOPS once the non-empty - /// set repeats. The final carries the 3 nodes - /// and no further passes arrive — proving the loop is bounded. - /// - [Fact] - public void Discovery_retries_until_set_stabilises_then_stops() - { - var driver = new DiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - // Tiny interval so the bounded retry runs in well under a second (no real-time waits). - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - // Drive Connecting → Connected; the Connected entry kicks discovery. - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Each discovery pass publishes one DiscoveredNodesReady. The fake stabilises after pass 4 - // (passes: 0,0,3,3), so exactly 4 messages arrive, then the stream stops. - var msgs = new List(); - for (var i = 0; i < 4; i++) - msgs.Add(parent.ExpectMsg(TimeSpan.FromSeconds(2))); - - // The loop must STOP once the non-empty set has stabilised — no fifth pass. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - - // Early passes were empty (FixedTree cache still populating). - msgs[0].Nodes.Count.ShouldBe(0); - msgs[1].Nodes.Count.ShouldBe(0); - // The set then appears and stabilises at 3 nodes. - msgs[2].Nodes.Count.ShouldBe(3); - var final = msgs[^1]; - final.Nodes.Count.ShouldBe(3); - final.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - final.Nodes.Select(n => n.FullReference).ShouldBe(new[] { "m.fixed.v0", "m.fixed.v1", "m.fixed.v2" }); - - // The driver was asked exactly as many times as messages published — no extra zombie pass. - driver.DiscoverCount.ShouldBe(4); - } - - /// A driver that does not implement produces no discovery passes — - /// the Connected entry's discovery kick is a no-op, so the parent receives no - /// . - [Fact] - public void Driver_without_ITagDiscovery_produces_no_discovery() - { - var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); - - // No discovery capability ⇒ never any DiscoveredNodesReady to the parent. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - } - - /// - /// Discovery RE-RUNS on every return to Connected: after the initial discovery settles, a - /// drives the actor through Reconnecting and - /// back to Connected (via the auto-retry timer, the same path the existing reconnect tests use), - /// and a fresh bounded discovery loop fires — keeping the injected tree current if the backend's - /// capabilities changed across the reconnect. The new init bumps the generation, so any - /// pre-reconnect tick is discarded by the generation guard (the initial loop has already settled - /// here, so none are in flight). - /// - [Fact] - public void Discovery_reruns_after_reconnect() - { - var driver = new DiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - // Tiny reconnect + rediscover intervals so the whole reconnect-then-rediscover cycle runs fast. - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - reconnectInterval: TimeSpan.FromMilliseconds(50), - rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Drain the initial settling passes (0,0,3,3) and confirm the first loop stopped. - for (var i = 0; i < 4; i++) - parent.ExpectMsg(TimeSpan.FromSeconds(2)); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - var passesBeforeReconnect = driver.DiscoverCount; // 4 - - // Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again. - actor.Tell(new DriverInstanceActor.ForceReconnect()); - - // A fresh discovery pass must arrive after the reconnect — the cache is warm now, so it sees - // the stable 3-node set immediately. - var afterReconnect = parent.ExpectMsg(TimeSpan.FromSeconds(3)); - afterReconnect.Nodes.Count.ShouldBe(3); - afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // The driver was discovered again — proves a fresh loop ran, not a replay of the old one. - driver.DiscoverCount.ShouldBeGreaterThan(passesBeforeReconnect); - } - - /// - /// Regression for the Critical: a driver whose DiscoverAsync completes ASYNCHRONOUSLY (off the - /// actor thread) must still ship . The handler - /// touches Context.Parent + Timers AFTER awaiting discovery; if it awaited with - /// ConfigureAwait(false) the continuation would resume off the actor context and those calls - /// would throw NotSupportedException("no active ActorContext") — the handler would fault and no - /// message would arrive. Synchronous (Task.CompletedTask) stubs mask the bug; this one forces a - /// genuine off-context resume (modelled on SubscribableStubDriver.UnsubscribeYields). - /// - [Fact] - public void Async_completing_discovery_resumes_on_actor_context_and_publishes() - { - var driver = new YieldingDiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // With the fix the handler resumes on the actor context, so the publish succeeds and the parent gets - // a non-empty set. Without it the handler faults at Context.Parent.Tell and this times out. - var published = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - published.Nodes.Count.ShouldBe(3); - published.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - } - - /// - /// Arch-review #10: the SAME async-discovery context-preservation guarantee, but driven through the - /// REAL — whose Polly pipeline is awaited with - /// ConfigureAwait(false) internally. This is the one path the pass-through - /// NullDriverCapabilityInvoker (used by the sibling test) cannot cover: it returns the - /// call-site task directly, so it never exercises the invoker's internal off-context await. If that - /// internal ConfigureAwait(false) leaked to the actor's own await _invoker.ExecuteAsync(…), - /// the post-await Context.Parent.Tell in HandleRediscoverAsync would fault with - /// "no active ActorContext" and no message would arrive. Its arrival proves the actor context survives - /// the real resilience pipeline. - /// - [Fact] - public void Async_discovery_through_real_CapabilityInvoker_preserves_actor_context() - { - var pipelineBuilder = new DriverResiliencePipelineBuilder(); // real Polly pipeline, tier-A defaults - var invoker = new CapabilityInvoker( - pipelineBuilder, - driverInstanceId: "yielding-discoverable", - optionsAccessor: () => new DriverResilienceOptions { Tier = DriverTier.A }, - driverType: "Stub"); - var driver = new YieldingDiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker)); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - var published = parent.ExpectMsg(TimeSpan.FromSeconds(5)); - published.Nodes.Count.ShouldBe(3); - published.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - } - - /// - /// The attempt cap bounds a discovered set that never stabilises: a driver whose set keeps GROWING - /// (1,2,3,…) never repeats its signature, so the loop is stopped only by - /// rediscoverMaxAttempts. With a cap of 3, exactly 3 passes are published, then the stream stops. - /// - [Fact] - public void Never_stabilising_discovery_is_bounded_by_the_attempt_cap() - { - var driver = new GrowingDiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3)); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - var msgs = new List(); - for (var i = 0; i < 3; i++) - msgs.Add(parent.ExpectMsg(TimeSpan.FromSeconds(2))); - - // Cap reached — no fourth pass even though the set never stabilised. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - - // The set genuinely kept growing across the capped passes (1,2,3 nodes). - msgs.Select(m => m.Nodes.Count).ShouldBe(new[] { 1, 2, 3 }); - driver.DiscoverCount.ShouldBe(3); - } - - /// - /// A driver whose is - /// opts out of post-connect discovery entirely: the - /// Connected entry's discovery kick returns before scheduling the first tick, so the driver is never - /// asked to discover and the parent receives no . - /// - [Fact] - public void Discovery_policy_Never_runs_no_passes_and_publishes_nothing() - { - var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - // Connect happened (the discovery decision is made on the Connected entry)... - AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); - - // ...but policy=Never ⇒ no discovery pass is ever run and nothing is published. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(0); - } - - /// - /// A driver whose is - /// runs EXACTLY one post-connect pass even when its - /// discovered set would keep growing forever — under UntilStable the never-repeating signature - /// would retry to the attempt cap. Exactly one - /// is published and no further RediscoverTick is scheduled. - /// - [Fact] - public void Discovery_policy_Once_publishes_exactly_one_pass_even_when_set_keeps_growing() - { - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Exactly one pass is published (the first, growing set → 1 node)... - var only = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - only.Nodes.Count.ShouldBe(1); - only.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // ...and NO second tick is scheduled, even though the set would keep growing under UntilStable. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(1); - } - - /// - /// means one pass PER (re)connect cycle — not one pass - /// ever. After the initial single pass settles, a - /// drives the actor through Reconnecting and back to Connected (via the auto retry-connect timer), and - /// StartDiscovery re-kicks discovery — which must run EXACTLY ONE more pass, not the full attempt - /// cap. Uses the ever-growing fake with a small cap (3): under a (wrong) policy-ignoring loop the - /// never-stabilising set would publish 3 passes per connect, so a single post-reconnect pass proves - /// Once is honoured on the reconnect path too. Guards the exact StartDiscovery-on-reconnect path - /// the follow-on TriggerRediscovery task touches. - /// - [Fact] - public void Discovery_policy_Once_reruns_one_pass_on_reconnect() - { - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - // Small reconnect + rediscover intervals so the cycle runs fast; cap 3 so a (wrong) full loop is - // visibly more than the one pass Once must run per (re)connect. - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - reconnectInterval: TimeSpan.FromMilliseconds(50), - rediscoverInterval: TimeSpan.FromMilliseconds(20), - rediscoverMaxAttempts: 3)); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then no more. - var first = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - first.Nodes.Count.ShouldBe(1); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - driver.DiscoverCount.ShouldBe(1); - - // Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again. - actor.Tell(new DriverInstanceActor.ForceReconnect()); - - // Once = one pass PER (re)connect: exactly ONE additional pass after the reconnect, NOT the full cap. - // The set keeps growing across the reconnect (same driver instance), so this pass yields 2 nodes. - var afterReconnect = parent.ExpectMsg(TimeSpan.FromSeconds(3)); - afterReconnect.Nodes.Count.ShouldBe(2); - afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // No further passes — Once did NOT run the attempt cap on reconnect; one pass per connect cycle. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(2); - } - - /// - /// The per-pass discovery timeout is injectable via so tests - /// can control it without real-time delays. The default constant must be 30 seconds (behaviour-preserving). - /// Wiring is verified by constructing via Props with a custom value and confirming the actor starts - /// and begins discovery normally. - /// - [Fact] - public void Discovery_timeout_default_constant_is_30s_and_Props_accepts_custom_value() - { - // The constant must exist and preserve the pre-refactor 30 s literal. - DriverInstanceActor.DefaultRediscoverDiscoverTimeout.ShouldBe(TimeSpan.FromSeconds(30)); - - // Props must accept the new optional parameter — no throw and actor starts normally. - var driver = new DiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - rediscoverInterval: TimeSpan.FromMilliseconds(20), - rediscoverDiscoverTimeout: TimeSpan.FromSeconds(5))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Actor starts and discovery publishes — confirms the custom timeout was wired without error. - parent.ExpectMsg(TimeSpan.FromSeconds(2)); - } - - /// - /// received while Connected re-kicks the - /// post-connect discovery loop: after the initial discovery has settled, sending the message drives a - /// FRESH discovery pass — the driver's DiscoverCount advances and a new - /// is published. This is the message - /// uses to re-run discovery after rebinding the driver to a new equipment. - /// - [Fact] - public void TriggerRediscovery_when_Connected_reruns_discovery() - { - // Once-policy growing stub: exactly ONE pass per (re)kick, so each StartDiscovery publishes precisely - // one DiscoveredNodesReady — the trigger's effect is asserted with a single ExpectMsg + ExpectNoMsg - // (no second settling pass to drain, and no stale-tick double pass alongside the fresh one). - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then it settles. - parent.ExpectMsg(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - var passesBeforeTrigger = driver.DiscoverCount; // 1 - - // Re-kick discovery via the new message — Once ⇒ exactly one fresh pass (growing set → 2 nodes). - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - var afterTrigger = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - afterTrigger.Nodes.Count.ShouldBe(2); - afterTrigger.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // Exactly one fresh pass ran — DiscoverCount advanced by one and no extra pass arrived. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(passesBeforeTrigger + 1); - } - - /// - /// on a driver whose - /// is does - /// NOT re-discover: the handler calls StartDiscovery, which returns early for Never, so - /// no pass runs and nothing is published — mirroring the Connected-entry Never opt-out. - /// - [Fact] - public void TriggerRediscovery_with_policy_Never_does_not_rediscover() - { - var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); - - // Connected, but policy=Never — the trigger is honoured by StartDiscovery's early return. - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(0); - } - - /// - /// received while NOT Connected is a clean silent - /// no-op in EVERY non-Connected state: no discovery pass runs, nothing is published, and the actor - /// neither crashes nor dies (its eventual (re)connect re-discovers anyway). Covers both Connecting - /// (before init completes) and Reconnecting (after a , - /// parked there by a long reconnect interval), with an intervening connect proving the actor is unharmed. - /// - [Fact] - public void TriggerRediscovery_when_not_Connected_is_a_silent_noop() - { - // Once-growing stub so a successful connect publishes exactly one pass (clean confirmation of state); - // a long reconnect interval so the actor parks in Reconnecting deterministically within the test window. - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - reconnectInterval: TimeSpan.FromSeconds(30), - rediscoverInterval: TimeSpan.FromMilliseconds(20))); - Watch(actor); - - // (1) Connecting: the actor boots into Connecting; send the trigger BEFORE InitializeRequested so it - // is handled in that non-Connected state. - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - // No discovery resulted, and the actor is unharmed (no Terminated arrives at the watching test actor). - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - ExpectNoMsg(TimeSpan.FromMilliseconds(100)); - driver.DiscoverCount.ShouldBe(0); - - // Drive to Connected (proves the Connecting-state trigger left the actor working); Once ⇒ one pass. - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - parent.ExpectMsg(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - var passesAfterConnect = driver.DiscoverCount; // 1 - - // (2) Reconnecting: ForceReconnect parks the actor in Reconnecting (30s retry interval ⇒ no auto - // reconnect within the window). A TriggerRediscovery here must ALSO be a clean silent no-op. Both - // messages are processed in order, so the trigger is handled while Reconnecting. - actor.Tell(new DriverInstanceActor.ForceReconnect()); - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - ExpectNoMsg(TimeSpan.FromMilliseconds(100)); // still alive — no Terminated - driver.DiscoverCount.ShouldBe(passesAfterConnect); // no fresh pass while Reconnecting - } - - /// - /// A that also exposes . Each DiscoverAsync - /// pass is counted; passes 1–2 yield nothing (cache warming), passes 3+ yield a stable 3-node set — - /// modelling FOCAS, whose FixedTree appears once a few seconds after connect and then stays put. - /// - private sealed class DiscoverableStubDriver : StubDriver, ITagDiscovery - { - private int _passCount; - - /// Constructs the fake reporting the given ; - /// defaults to (the interface default) so the - /// existing UntilStable tests are unaffected. - public DiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable) - => RediscoverPolicy = policy; - - /// The post-connect re-discovery policy this fake reports to the actor. - public DiscoveryRediscoverPolicy RediscoverPolicy { get; } - - /// Number of passes the actor has driven. - public int DiscoverCount => Volatile.Read(ref _passCount); - - /// Streams a growing-then-stable node set into the builder (0,0,3,3,…). - public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var pass = Interlocked.Increment(ref _passCount); // 1-based pass number - var count = pass >= 3 ? 3 : 0; - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < count; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"m.fixed.v{i}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - return Task.CompletedTask; - } - } - - /// - /// A discoverable driver whose DiscoverAsync genuinely SUSPENDS and resumes on a fresh - /// thread-pool thread that carries NO Akka actor cell — modelled on - /// SubscribableStubDriver.UnsubscribeYields. This forces the actor's await DiscoverAsync(...) - /// continuation to resume off-context unless the handler omits ConfigureAwait(false), so it is a - /// deterministic repro of the no-ActorContext race. Returns a stable 3-node set on every pass. - /// - private sealed class YieldingDiscoverableStubDriver : StubDriver, ITagDiscovery - { - /// Suspends on a TCS completed from a background thread, then streams 3 nodes. - public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _ = Task.Run(() => tcs.SetResult(), cancellationToken); - await tcs.Task.ConfigureAwait(false); // resume on a clean thread-pool thread (no actor cell) - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < 3; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"m.fixed.v{i}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - } - } - - /// - /// A discoverable driver whose set NEVER stabilises: pass N yields N nodes (1,2,3,…), so the - /// full-reference signature differs every pass and the loop can only be bounded by the attempt cap. - /// - private sealed class GrowingDiscoverableStubDriver : StubDriver, ITagDiscovery - { - private int _passCount; - - /// Constructs the fake reporting the given ; - /// defaults to (the interface default) so the - /// existing attempt-cap test is unaffected. With the - /// ever-growing set proves the actor stops after a single pass (UntilStable would keep retrying). - public GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable) - => RediscoverPolicy = policy; - - /// The post-connect re-discovery policy this fake reports to the actor. - public DiscoveryRediscoverPolicy RediscoverPolicy { get; } - - /// Number of passes the actor has driven. - public int DiscoverCount => Volatile.Read(ref _passCount); - - /// Streams an ever-growing node set (pass N → N nodes). - public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var pass = Interlocked.Increment(ref _passCount); // 1-based pass number - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < pass; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"m.fixed.v{i}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - return Task.CompletedTask; - } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs new file mode 100644 index 00000000..f49e25b6 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs @@ -0,0 +1,268 @@ +using Akka.Actor; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// The tag-set drift check: periodically re-browse a genuinely-browsable driver and compare what the +/// remote offers against what an operator authored. +/// Why this is gated rather than run for every driver. Modbus, S7, AbLegacy, Sql and Mqtt +/// all stream their AUTHORED tags back out of DiscoverAsync rather than enumerating the device, so +/// diffing the two sets for them is a tautology that reports "no drift" forever. A check that cannot fail +/// is worse than no check, because it reads as coverage — so the actor runs this only for drivers that +/// declare SupportsOnlineDiscovery AND report AuthoredDiscoveryRefs. +/// The signal is advisory and shares the Stage-2 surfacing: it raises the same /hosts +/// re-browse prompt an raise does. The served address space is never +/// rebuilt — raw tags are authored through the /raw browse-commit flow. +/// +[Trait("Category", "Unit")] +public sealed class DriverInstanceActorDriftCheckTests : RuntimeActorTestBase +{ + private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(60); + private static readonly TimeSpan Budget = TimeSpan.FromSeconds(3); + + /// A device offering a tag nothing binds raises the operator prompt, naming what appeared. + [Fact] + public void A_new_ref_on_the_device_raises_the_re_browse_prompt() + { + var driver = new BrowsableStubDriver(discovered: ["a", "b", "c"], authored: ["a", "b"]); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + AwaitAssert( + () => + { + var signalled = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null); + signalled.ShouldNotBeNull(); + signalled!.RediscoveryReason.ShouldNotBeNull(); + signalled.RediscoveryReason!.ShouldContain("1 new on device"); + signalled.RediscoveryReason!.ShouldContain("c"); + }, + Budget); + + Sys.Stop(actor); + } + + /// + /// The gate that makes this feature honest. A driver whose DiscoverAsync replays + /// authored config rather than browsing the device declares SupportsOnlineDiscovery == false, + /// and must never be drift-checked — for it the comparison could only ever say "no drift". + /// Positive control: the driver is handed a set that WOULD read as drift, and the assertion is + /// that discovery is never even invoked. Asserting only "no prompt raised" would pass for the wrong + /// reason (e.g. if the timer never fired at all). + /// + [Fact] + public void A_driver_that_replays_authored_config_is_never_drift_checked() + { + var driver = new BrowsableStubDriver( + discovered: ["a", "b", "c"], authored: ["a"], supportsOnlineDiscovery: false); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + // Long enough for several ticks had the timer been scheduled. + ExpectNoMsg(TimeSpan.FromMilliseconds(400)); + + driver.DiscoverCount.ShouldBe( + 0, + "a driver that replays authored config was browsed for drift — the comparison is a tautology " + + "for it and would report 'no drift' forever, which reads as coverage"); + publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null); + + Sys.Stop(actor); + } + + /// A driver that browses but does NOT report its authored refs opts out — the default. An empty + /// collection would mean "nothing authored, everything is new", so null must not be read that way. + [Fact] + public void A_driver_that_reports_no_authored_refs_is_never_drift_checked() + { + var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: null); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + ExpectNoMsg(TimeSpan.FromMilliseconds(400)); + + driver.DiscoverCount.ShouldBe(0); + publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null); + + Sys.Stop(actor); + } + + /// + /// An UNCHANGED drift is reported once, not re-stamped on every check — a prompt whose timestamp + /// keeps moving reads as "it just happened again" rather than "it is still true". + /// + [Fact] + public void An_unchanged_drift_is_reported_once() + { + var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + AwaitAssert( + () => publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(1), + Budget); + + // Several more ticks pass with the same drift. + AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(3), Budget); + + publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe( + 1, + "an unchanged drift was re-announced; the operator prompt would keep re-stamping its timestamp"); + + Sys.Stop(actor); + } + + /// A failed browse is not drift. A device that is merely unreachable already shows on the health + /// surface; reporting drift would turn every comms blip into "all your tags vanished". + [Fact] + public void A_failed_browse_is_not_reported_as_drift() + { + var driver = new BrowsableStubDriver(discovered: ["a"], authored: ["a", "b"]) { DiscoverThrows = true }; + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(1), Budget); + + publisher.Published.ShouldAllBe( + p => p.RediscoveryNeededUtc == null, + "a browse that threw was treated as drift — an unreachable device would report every authored " + + "tag as vanished"); + + Sys.Stop(actor); + } + + /// Drift that resolves — the operator re-browsed and committed — clears the prompt, so the next + /// genuine drift re-raises with a fresh timestamp instead of being deduped against the stale one. + [Fact] + public void Drift_that_resolves_clears_the_prompt() + { + var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + AwaitAssert( + () => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null), + Budget); + + // The operator commits the new tag: authored now matches the device. + driver.SetAuthored(["a", "b"]); + + AwaitAssert( + () => publisher.Published[^1].RediscoveryNeededUtc.ShouldBeNull(), + Budget); + + Sys.Stop(actor); + } + + private IActorRef SpawnConnected(IDriver driver, IDriverHealthPublisher publisher) + { + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props( + driver, healthPublisher: publisher, driftCheckInterval: Tick)); + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + return actor; + } + + /// Captured health publishes, so a test can read the rediscovery fields. + private sealed record HealthPublish(DateTime? RediscoveryNeededUtc, string? RediscoveryReason); + + private sealed class RecordingHealthPublisher : IDriverHealthPublisher + { + private readonly List _published = []; + + public IReadOnlyList Published + { + get { lock (_published) return _published.ToArray(); } + } + + /// + public void Publish( + string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, string? rediscoveryReason = null) + { + lock (_published) _published.Add(new HealthPublish(rediscoveryNeededUtc, rediscoveryReason)); + } + } + + /// + /// A driver that browses a fake "device". Health is STABLE so the publish dedup genuinely engages — + /// the shared StubDriver returns DateTime.UtcNow, which would make every publish look + /// changed and let a dedup-related bug pass unnoticed. + /// + private sealed class BrowsableStubDriver( + string[] discovered, + string[]? authored, + bool supportsOnlineDiscovery = true) : IDriver, ITagDiscovery + { + private static readonly DateTime FixedLastRead = new(2026, 7, 28, 9, 0, 0, DateTimeKind.Utc); + private volatile string[]? _authored = authored; + private int _discoverCount; + + /// Number of discovery passes the actor has driven — the direct read of whether the drift + /// check ran at all. + public int DiscoverCount => Volatile.Read(ref _discoverCount); + + /// When true, every browse throws — models an unreachable device. + public bool DiscoverThrows { get; init; } + + /// + public string DriverInstanceId => "browsable-stub-1"; + + /// + public string DriverType => "Stub"; + + /// + public bool SupportsOnlineDiscovery => supportsOnlineDiscovery; + + /// + public IReadOnlyCollection? AuthoredDiscoveryRefs => _authored; + + /// Models the operator committing (or removing) tags between checks. + public void SetAuthored(string[] refs) => _authored = refs; + + /// + public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _discoverCount); + if (DiscoverThrows) throw new IOException("device unreachable"); + + var folder = builder.Folder("Stub", "Stub"); + foreach (var r in discovered) + { + folder.Variable(r, r, new DriverAttributeInfo( + FullName: r, + DriverDataType: DriverDataType.Float64, + IsArray: false, + ArrayDim: null, + SecurityClass: SecurityClassification.ViewOnly, + IsHistorized: false)); + } + + return Task.CompletedTask; + } + + /// + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null); + + /// + public long GetMemoryFootprint() => 0; + + /// + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs new file mode 100644 index 00000000..4027bf68 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs @@ -0,0 +1,249 @@ +using Akka.Actor; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Covers the consumer (Gitea #518). Nine drivers raise +/// when they observe that a remote's tag set may have +/// changed — a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect agent restart, a Sparkplug +/// rebirth. Before this wiring NOTHING in src/ subscribed, so every one of those raises went into +/// the void. +/// The signal is advisory. It does not rebuild the served address space: v3 authors raw tags +/// explicitly through the /raw browse-commit flow, so a runtime graft would create nodes no +/// operator approved. It rides the driver-health snapshot to the AdminUI as a re-browse prompt. +/// +[Trait("Category", "Unit")] +public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTestBase +{ + /// + /// The load-bearing case: a driver raises rediscovery while otherwise perfectly healthy, and the + /// signal reaches the health publisher carrying the driver's reason. + /// + [Fact] + public void Rediscovery_raise_reaches_the_health_publisher_with_its_reason() + { + var driver = new RediscoverableStubDriver(); + var publisher = new RecordingHealthPublisher(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + + driver.RaiseRediscovery("deploy-time-changed"); + + AwaitAssert( + () => + { + var withSignal = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null); + withSignal.ShouldNotBeNull(); + withSignal!.RediscoveryReason.ShouldBe("deploy-time-changed"); + }, + TimeSpan.FromSeconds(3)); + } + + /// + /// Regression guard for the dedup trap. PublishHealthSnapshot suppresses a publish whose + /// (state, lastSuccessfulRead, lastError, errorCount) tuple repeats. A rediscovery raise on an + /// otherwise-unchanged Healthy driver leaves all four identical, so unless the rediscovery timestamp + /// is part of the fingerprint the dedup swallows the very publish carrying the signal and the operator + /// never sees the prompt. + /// Positive control: the assertion is that the count STRICTLY INCREASES across the raise. An + /// "eventually non-null" assertion alone would pass on the warm-up publish and prove nothing. Revert + /// _rediscoveryNeededUtc out of the fingerprint tuple and this test must fail. + /// + [Fact] + public void Rediscovery_raise_is_not_swallowed_by_the_unchanged_health_dedup() + { + var driver = new RediscoverableStubDriver(); + var publisher = new RecordingHealthPublisher(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + + // Let the actor settle so nothing else is in flight, then capture the baseline. Everything about + // the driver's health is now stable — only the rediscovery flag will change. + ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + var before = publisher.Published.Count; + + driver.RaiseRediscovery("symbol-version-changed"); + + AwaitAssert( + () => publisher.Published.Count.ShouldBeGreaterThan(before), + TimeSpan.FromSeconds(3)); + publisher.Published[^1].RediscoveryNeededUtc.ShouldNotBeNull(); + } + + /// + /// The signal is sticky: a later health publish still carries it. The remote's tag set stayed + /// changed, and only an operator re-browsing resolves that — which this actor cannot observe. A + /// flag that cleared itself on the next heartbeat would be a prompt the operator could miss entirely. + /// + [Fact] + public void Rediscovery_flag_persists_on_subsequent_health_publishes() + { + var driver = new RediscoverableStubDriver(); + var publisher = new RecordingHealthPublisher(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + driver.RaiseRediscovery("agent-instance-changed"); + AwaitAssert( + () => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null), + TimeSpan.FromSeconds(3)); + + // Force a further publish by changing something else about the driver's health. + driver.SetLastError("transient read failure"); + AwaitAssert( + () => publisher.Published.Any(p => p.LastError == "transient read failure"), + TimeSpan.FromSeconds(3)); + + var latest = publisher.Published[^1]; + latest.RediscoveryNeededUtc.ShouldNotBeNull(); + latest.RediscoveryReason.ShouldBe("agent-instance-changed"); + } + + /// + /// Leak guard. The instance can OUTLIVE the actor — the host stops a + /// child and respawns another around the same driver object — so a missing -= in + /// PostStop accumulates one handler per respawn, each holding a dead Self. Asserts the + /// driver's invocation list is empty again after the actor stops. + /// + [Fact] + public void Stopping_the_actor_detaches_the_rediscovery_handler() + { + var driver = new RediscoverableStubDriver(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => driver.SubscriberCount.ShouldBe(1), TimeSpan.FromSeconds(3)); + + Watch(actor); + actor.Tell(PoisonPill.Instance); + ExpectTerminated(actor, TimeSpan.FromSeconds(3)); + + driver.SubscriberCount.ShouldBe(0); + } + + /// A driver that does NOT implement must publish a null flag — + /// the field is absent, not defaulted to "needs re-browse". + [Fact] + public void A_non_rediscoverable_driver_never_sets_the_flag() + { + var publisher = new RecordingHealthPublisher(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(new StubDriver(), healthPublisher: publisher)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + + publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null); + } + + /// Captures every health publish so a test can assert on the rediscovery fields. + private sealed record HealthPublish( + string ClusterId, + string DriverInstanceId, + DriverHealth Health, + int ErrorCount5Min, + DateTime? RediscoveryNeededUtc, + string? RediscoveryReason) + { + public string? LastError => Health.LastError; + } + + private sealed class RecordingHealthPublisher : IDriverHealthPublisher + { + private readonly List _published = []; + + /// Thread-safe snapshot — Publish is called from the actor thread while the test + /// asserts from its own. + public IReadOnlyList Published + { + get { lock (_published) return _published.ToArray(); } + } + + /// + public void Publish( + string clusterId, + string driverInstanceId, + DriverHealth health, + int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null) + { + lock (_published) + { + _published.Add(new HealthPublish( + clusterId, driverInstanceId, health, errorCount5Min, rediscoveryNeededUtc, rediscoveryReason)); + } + } + } + + /// + /// A stub driver exposing , a hook to raise it, and the live subscriber + /// count (for the detach guard). + /// Implemented from scratch rather than derived from the shared StubDriver on + /// purpose. That one returns GetHealth() => new(Healthy, DateTime.UtcNow, null), so its + /// health fingerprint differs on EVERY call and the dedup under test never engages — which would make + /// pass vacuously + /// whether or not the fix is present. This stub's health is STABLE until a test changes it. + /// + private sealed class RediscoverableStubDriver : IDriver, IRediscoverable + { + private static readonly DateTime FixedLastRead = new(2026, 7, 27, 12, 0, 0, DateTimeKind.Utc); + private volatile string? _lastError; + + /// + public event EventHandler? OnRediscoveryNeeded; + + /// + public string DriverInstanceId => "rediscoverable-stub-1"; + + /// + public string DriverType => "Stub"; + + /// Number of live subscribers on . + public int SubscriberCount => OnRediscoveryNeeded?.GetInvocationList().Length ?? 0; + + /// Raises the event exactly as a real driver's watcher does. + public void RaiseRediscovery(string reason) + => OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, ScopeHint: null)); + + /// Changes the reported health so a test can force a publish that is NOT the rediscovery one. + public void SetLastError(string error) => _lastError = error; + + /// + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, _lastError); + + /// + public long GetMemoryFootprint() => 0; + + /// + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs index 1a901650..d331ed3a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs @@ -116,7 +116,13 @@ public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActor public int Count => Volatile.Read(ref _count); /// - public void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min) + public void Publish( + string clusterId, + string driverInstanceId, + DriverHealth health, + int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null) => Interlocked.Increment(ref _count); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs index f9e18d4a..541d5a1f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs @@ -40,9 +40,18 @@ public sealed class DriverSpawnPlannerTests plan.ToStop.ShouldBeEmpty(); } - /// Verifies that different configuration is routed to ApplyDelta. + /// + /// A changed DriverConfig forces a stop + respawn (#516), NOT an in-place delta. + /// This test previously asserted the opposite. The in-place path silently discarded the edit + /// on five drivers whose InitializeAsync served constructor-captured options and never read + /// the driverConfigJson they were handed — and the deployment still sealed green. Routing + /// through the factory makes it the single parse authority, which matters most for the drivers that + /// build MORE than options from config (Sql's dialect + connection string, FOCAS's client-factory + /// backend) where re-parsing options alone would apply a new tag set against an old connection. + /// The accepted cost is a reconnect on every config edit. + /// [Fact] - public void Different_config_routes_to_ApplyDelta() + public void Different_config_routes_to_stop_and_respawn() { var current = new Dictionary { @@ -52,9 +61,11 @@ public sealed class DriverSpawnPlannerTests var plan = DriverSpawnPlanner.Compute(current, target); - plan.ToApplyDelta.Single().DriverInstanceId.ShouldBe("a"); - plan.ToSpawn.ShouldBeEmpty(); - plan.ToStop.ShouldBeEmpty(); + plan.ToStop.Single().ShouldBe("a"); + plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); + plan.ToSpawn.Single().DriverConfig.ShouldBe("{\"host\":\"new\"}"); + // The whole point: nothing is left on the in-place path that could discard the edit. + plan.ToApplyDelta.ShouldBeEmpty(); } /// Verifies that removed drivers are routed to ToStop. @@ -166,11 +177,14 @@ public sealed class DriverSpawnPlannerTests } /// - /// A pure DriverConfig change with an UNCHANGED ResilienceConfig stays an in-place delta (no - /// reconnect) — the resilience pipeline is untouched, so there's no reason to respawn. + /// A pure DriverConfig change respawns even when the ResilienceConfig is UNCHANGED. + /// This asserted the opposite until #516. The reasoning then was "the resilience pipeline is + /// untouched, so there's no reason to respawn" — correct about resilience, wrong about the driver: + /// five drivers ignored the config the in-place delta handed them, and the deployment sealed green + /// anyway. The reconnect is the accepted price of the edit actually taking effect. /// [Fact] - public void DriverConfig_change_with_unchanged_ResilienceConfig_stays_ApplyDelta() + public void DriverConfig_change_respawns_even_when_ResilienceConfig_is_unchanged() { var current = new Dictionary { @@ -180,8 +194,8 @@ public sealed class DriverSpawnPlannerTests var plan = DriverSpawnPlanner.Compute(current, target); - plan.ToApplyDelta.Single().DriverInstanceId.ShouldBe("a"); - plan.ToSpawn.ShouldBeEmpty(); - plan.ToStop.ShouldBeEmpty(); + plan.ToStop.Single().ShouldBe("a"); + plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); + plan.ToApplyDelta.ShouldBeEmpty(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs index 2ab4213d..b9780be4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs @@ -50,9 +50,17 @@ internal class StubDriver : IDriver return Task.CompletedTask; } + /// Number of calls — a child actor stopping raises it, so a test + /// can observe a driver child being torn down (e.g. by the #516 config-change respawn). + public int ShutdownCount; + /// Shuts down the driver. /// Cancellation token for the operation. - public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref ShutdownCount); + return Task.CompletedTask; + } /// Gets the health status of the driver. public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); /// Gets the memory footprint of the driver. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs new file mode 100644 index 00000000..b48f8d22 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs @@ -0,0 +1,113 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// The pure half of tag-set drift detection: what the remote offers vs. what an operator authored. +/// Kept out of the actor so the comparison rules are testable without an actor system. +/// +[Trait("Category", "Unit")] +public sealed class TagSetDriftDetectorTests +{ + /// Matching sets are not drift, and must not raise an operator prompt. + [Fact] + public void Identical_sets_are_not_drift() + { + var drift = TagSetDriftDetector.Compare(["a", "b"], ["b", "a"]); + + drift.HasDrift.ShouldBeFalse(); + drift.Describe().ShouldBe("no drift"); + } + + /// A tag the device now offers that nothing binds — the common case after a PLC re-download. + [Fact] + public void A_ref_on_the_device_that_is_not_authored_is_reported_as_appeared() + { + var drift = TagSetDriftDetector.Compare(["a", "b", "c"], ["a", "b"]); + + drift.Appeared.ShouldBe(["c"]); + drift.Vanished.ShouldBeEmpty(); + drift.HasDrift.ShouldBeTrue(); + } + + /// An authored tag the device no longer offers — detectable only for a driver whose discovery + /// stream is purely device-derived. + [Fact] + public void An_authored_ref_missing_from_the_device_is_reported_as_vanished() + { + var drift = TagSetDriftDetector.Compare(["a"], ["a", "b"]); + + drift.Vanished.ShouldBe(["b"]); + drift.Appeared.ShouldBeEmpty(); + } + + /// + /// The honesty guard. When a driver re-emits its authored tags into the discovery stream — + /// FOCAS, TwinCAT and AbCip all do — the discovered set always contains the authored set, so a + /// vanished tag is structurally invisible. The detector must not compute a misleadingly-empty + /// Vanished list; it must flag that the direction is undetectable, and say so in the description. + /// + [Fact] + public void When_vanished_is_undetectable_it_is_declared_rather_than_reported_as_empty() + { + // "b" is authored and absent from the device — but this driver would have re-emitted it, so its + // absence here cannot occur in practice and must not be presented as a clean result either way. + var drift = TagSetDriftDetector.Compare(["a", "c"], ["a", "b"], detectVanished: false); + + drift.VanishedDetectable.ShouldBeFalse(); + drift.Vanished.ShouldBeEmpty(); + drift.Appeared.ShouldBe(["c"]); + drift.Describe().ShouldContain( + "missing-tag detection unavailable", + customMessage: + "a driver that cannot see vanished tags must SAY so — otherwise the absence of a 'missing' " + + "clause reads as 'nothing is missing', which is the reassurance this whole exercise removes"); + } + + /// Comparison is ordinal: a device legitimately exposing both Speed and speed + /// must not have them collapsed, which would hide a genuinely new tag. + [Fact] + public void Comparison_is_case_sensitive() + { + var drift = TagSetDriftDetector.Compare(["Speed", "speed"], ["Speed"]); + + drift.Appeared.ShouldBe(["speed"]); + } + + /// The signature identifies a drift so an UNCHANGED one is not re-announced every check. + /// Enumeration order must not make an identical drift look new. + [Fact] + public void Signature_is_stable_across_enumeration_order() + { + var a = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]); + var b = TagSetDriftDetector.Compare(["z", "y", "x"], ["x"]); + + a.Signature.ShouldBe(b.Signature); + } + + /// A different drift must get a different signature, or the second one is never reported. + [Fact] + public void Signature_changes_when_the_drift_changes() + { + var a = TagSetDriftDetector.Compare(["x", "y"], ["x"]); + var b = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]); + + a.Signature.ShouldNotBe(b.Signature); + } + + /// The description names a couple of refs and then counts, so swapping a whole PLC program + /// yields a readable message rather than a wall of tag names. + [Fact] + public void Describe_samples_rather_than_listing_everything() + { + var discovered = Enumerable.Range(0, 50).Select(i => $"tag{i:D2}").ToArray(); + var drift = TagSetDriftDetector.Compare(discovered, []); + + var text = drift.Describe(); + text.ShouldContain("50 new on device"); + text.ShouldContain("+48 more"); + text.Length.ShouldBeLessThan(120); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index a6f10ef3..3190713e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -120,35 +120,6 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); } - /// Verifies that forwards to the - /// applier, which drives the sink to ensure the discovered folder + (read-only) variable and announce a - /// NodeAdded model-change under the equipment root — proving the message → handler → applier → sink path - /// end to end (mirrors the real-applier-over-recording-sink harness in - /// OpcUaPublishActorRebuildTests). - [Fact] - public void MaterialiseDiscoveredNodes_routes_through_applier_to_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); - - var folders = new[] { new DiscoveredFolder("EQ-1/Axes", "EQ-1", "Axes") }; - var variables = new[] - { - new DiscoveredVariable("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", - Writable: false, IsArray: false, ArrayLength: null), - }; - - actor.Tell(new OpcUaPublishActor.MaterialiseDiscoveredNodes("EQ-1", folders, variables)); - - AwaitAssert(() => - { - sink.Folders.ShouldContain(("EQ-1/Axes", "EQ-1", "Axes")); - sink.Variables.ShouldContain(("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", false)); - sink.ModelChanges.ShouldContain("EQ-1"); - }, duration: PresenceBudget); - } - /// Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level. [Fact] public void ServiceLevelChanged_publishes_to_IServiceLevelPublisher_once_per_unique_level()