diff --git a/docs/plans/2026-06-07-equipment-namespace-live-values.md b/docs/plans/2026-06-07-equipment-namespace-live-values.md new file mode 100644 index 00000000..a4aa4d20 --- /dev/null +++ b/docs/plans/2026-06-07-equipment-namespace-live-values.md @@ -0,0 +1,403 @@ +# Equipment-Namespace Live Values (VirtualTag route) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task. + +**Goal:** Make Equipment-kind namespace signals carry live `Good` OPC UA values by representing each as a `VirtualTag` whose script mirrors a live SystemPlatform (Galaxy-mirror) tag in-process — completing WS-3a of the equipment-namespace materialization scope. + +**Architecture:** The structure milestone (`febe462..9a67ebc`) already materialises the Area/Line/Equipment/Signal *folders+variables* for Equipment **Tag** rows (folder-scoped NodeId, `BadWaitingForInitialData`). This milestone adds the **VirtualTag** path: carry `VirtualTag`(+`Script`) rows through the composition/artifact, materialise their variables, and stand up the production VirtualTag actor wiring — a per-deployment `VirtualTagHostActor` that spawns one already-built `VirtualTagActor` per VirtualTag, subscribes each to the `DependencyMuxActor` for its dependency ref(s), and bridges each `EvaluationResult` into `OpcUaPublishActor.AttributeValueUpdate` under the VirtualTag variable's **folder-scoped NodeId**. Cross-namespace reads work because the mux keys subscriptions on a flat `FullReference` string with no namespace scoping. Live Galaxy values already reach the mux via `DriverHostActor.ForwardToMux` (commit `b1b3f3f`). + +**Tech Stack:** .NET 10, Akka.NET actors, Roslyn-scripted VirtualTags, OPC UA (sink-based address space), MSSQL config DB, Python loader (pymssql + asyncua). + +**Repos touched:** `~/Desktop/OtOpcUa` (the engine work, Tasks 0–6, 8) and `~/Desktop/scadaproj/otopcua-uns-loader` (the loader + verify, Task 7). Do the OtOpcUa work on a feature branch `feat/equipment-namespace-live-values` off `master` (9a67ebc) — **do not commit on `master`**. + +--- + +## Background: the load-bearing facts (verified in code 2026-06-07) + +These were confirmed by reading the actually-wired code, and some **contradict** the convenience summaries that cite `EquipmentNodeWalker` (which is built but **unwired** — ignore it; the live path is the sink-based `Phase7Applier`): + +1. **Materialised Equipment variable NodeId is FOLDER-SCOPED, not `FullName` and not `VirtualTagId`.** + `Phase7Applier.MaterialiseEquipmentTags` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:215-228`) sets `nodeId = $"{parent}/{tag.Name}"` where `parent` is the equipment folder (or a per-tag sub-folder). The published value must therefore carry **that** NodeId. +2. **`VirtualTagActor` is fully built** (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs`): `Props(virtualTagId, expression, evaluator, scriptId, publisherFactory, dependencyRefs, mux)`. It self-registers with the mux in `PreStart` (`RegisterInterest(_dependencyRefs, Self)`), evaluates on `DependencyValueChanged`, dedups, and `Context.Parent.Tell(new EvaluationResult(VirtualTagId, value, ts, corr))` (line 147). It is **never spawned in production**. +3. **`DependencyMuxActor`** (`.../VirtualTags/DependencyMuxActor.cs`): `RegisterInterest(IReadOnlyList TagRefs, IActorRef Subscriber)`; `_byRef` is `Dictionary>` keyed by the flat `FullReference` — **no namespace scoping** (this is what makes cross-namespace mirroring work). On `AttributeValuePublished(FullReference,…)` it fans out `DependencyValueChanged(FullReference, value, ts)` to subscribers. +4. **`RoslynVirtualTagEvaluator`** (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`): `Evaluate(virtualTagId, expression, IReadOnlyDictionary dependencies)`. Scripts read deps via `ctx.GetTag("ref").Value`; the dictionary key is the **ref string** (e.g. `TestMachine_001.TestChangingInt`). It is already registered in the Host (`Host/Program.cs`). +5. **VirtualTag declares no explicit dependency list** — deps are the set of `ctx.GetTag("literal")` string literals in the script source. +6. **The artifact snapshot already includes `Scripts` and `VirtualTags`** (`ConfigComposer.SnapshotAndFlattenAsync`, `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs:44-45`). **No seal-side change needed.** The gap is only on the *derive* side: + - `Phase7CompositionResult` has no VirtualTag list (`Phase7Composer.cs`). + - `DeploymentArtifact.ParseComposition` builds Galaxy + Equipment tag plans only (`BuildGalaxyTagPlans`, `BuildEquipmentTagPlans`) — no VirtualTag builder. +7. **Spawn/restore hook points** (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`): the fresh-apply path calls `PushDesiredSubscriptions(deploymentId)`; the restart path `RestoreApplied()` calls `ReconcileDrivers` → `RebuildAddressSpace` → `PushDesiredSubscriptions`. VirtualTag spawning must run on **both** (immediately after `PushDesiredSubscriptions` in each). +8. **Sink resolves the published string NodeId** to the variable created with that same NodeId (`OpcUaPublishActor.HandleAttributeUpdate` → `_sink.WriteValue(msg.NodeId, …)`). So if the VirtualTag variable is materialised with NodeId `eq/Name` and we publish `AttributeValueUpdate(NodeId="eq/Name")`, it lands. + +**The crux:** `VirtualTagActor` emits `EvaluationResult` keyed by `VirtualTagId`, but the variable's NodeId is folder-scoped (`eq/Name`). The new `VirtualTagHostActor` (Task 5) holds the `VirtualTagId → folder-scoped-NodeId` map and translates at the bridge. + +--- + +## Task graph / parallelism + +``` +T0 (record + composition member) + ├─ T1 (composer populate) ┐ parallelizable with each other + ├─ T2 (artifact parse) │ (disjoint files) + ├─ T3 (Phase7Plan diff) ┘ + └─ T5 (VirtualTagHostActor, new file) (parallelizable with T1/T2/T3) +T4 (applier + HandleRebuild) ← T1, T2, T3 +T6 (DriverHostActor wiring + evaluator inject) ← T4, T5 +T7 (loader VirtualTag rows + verify Good) — scadaproj repo, parallelizable with T1–T6 +T8 (docker-dev integration verify) ← T6, T7 +T9 (docs + memory) ← T8 +``` + +--- + +### Task 0: `EquipmentVirtualTagPlan` record + carry on `Phase7CompositionResult` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** none (T1, T2, T3, T5 all depend on this) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (add record + init-only member, mirroring `EquipmentTagPlan`/`EquipmentTags` at lines 58, 94-101) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerTests.cs` (or the existing composer test file — find it) + +**Step 1 — Write the failing test.** Assert the composition default-constructs `EquipmentVirtualTags` to empty and that the record carries the expected fields: +```csharp +[Fact] +public void Composition_carries_empty_equipment_virtualtags_by_default() +{ + var r = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()); + Assert.Empty(r.EquipmentVirtualTags); +} + +[Fact] +public void EquipmentVirtualTagPlan_holds_id_equipment_name_datatype_expression_and_deps() +{ + var p = new EquipmentVirtualTagPlan("vt-1", "eq-1", "", "speed-rpm", "Float64", + "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;", + new[] { "TestMachine_001.TestDouble" }); + Assert.Equal("vt-1", p.VirtualTagId); + Assert.Equal("eq-1", p.EquipmentId); + Assert.Equal("speed-rpm", p.Name); + Assert.Single(p.DependencyRefs); +} +``` + +**Step 2 — Run, verify it fails to compile** (`EquipmentVirtualTagPlan` undefined). +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ --filter "FullyQualifiedName~Phase7Composer"` + +**Step 3 — Add the record + member.** In `Phase7Composer.cs`, after `EquipmentTagPlan` (line ~101) add: +```csharp +/// +/// One Equipment-namespace VirtualTag from a row (joined to its +/// for the expression). The VirtualTag value analogue of +/// : Phase7Applier.MaterialiseEquipmentVirtualTags +/// materialises each as a Variable under its equipment folder with a folder-scoped NodeId +/// (EquipmentId/Name, or EquipmentId/FolderPath/Name when a sub-folder is set), +/// and VirtualTagHostActor spawns a VirtualTagActor per plan that evaluates +/// over and publishes the value back to +/// that NodeId. = the distinct ctx.GetTag("…") literals in +/// the script source. +/// +public sealed record EquipmentVirtualTagPlan( + string VirtualTagId, + string EquipmentId, + string FolderPath, + string Name, + string DataType, + string Expression, + IReadOnlyList DependencyRefs); +``` +And on `Phase7CompositionResult` (after the `EquipmentTags` member, line 58): +```csharp +/// Equipment-namespace VirtualTags. See . Init-only, +/// defaults empty so every existing constructor + call site keeps compiling. +public IReadOnlyList EquipmentVirtualTags { get; init; } = Array.Empty(); +``` + +**Step 4 — Run the test, verify PASS.** + +**Step 5 — Commit.** `git commit -m "feat(opcua): add EquipmentVirtualTagPlan to Phase7 composition"` + +--- + +### Task 1: Populate `EquipmentVirtualTags` in `Phase7Composer.Compose` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 2, Task 3, Task 5 + +**Files:** +- Read first: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs` and `.../Entities/Script.cs` (confirm exact field names: `VirtualTagId`, `EquipmentId`, `Name`, `DataType`, `ScriptId`; `Script.ScriptId`, `Script.Source`; note whether `VirtualTag` has a `FolderPath` — if not, pass `""`). +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (the 7-arg `Compose` overload at lines 155-241; it already receives `scriptedAlarms` — **add a `virtualTags` + `scripts` parameter** to that overload and the convenience overloads, defaulting to empty so existing callers compile; thread it from the snapshot. Check the call site in `DeploymentArtifact`/driver-host build path consumes the composition from the artifact, not this overload — so the production producer is Task 2; this overload is used by tests + any direct composer caller). +- Test: the composer test file. + +**Design note — dependency extraction.** Replicate the local-helper pattern already used for `ExtractTagFullName` (lines 253-268): add a private `static IReadOnlyList ExtractDependencyRefs(string scriptSource)` that regex-matches `ctx.GetTag("")` and returns the distinct literals in source order. Do **not** add a project reference to `Core.VirtualTags` (OpcUaServer deliberately doesn't reference the driver/engine assemblies — see the `ExtractTagFullName` comment). Regex: `ctx\s*\.\s*GetTag\s*\(\s*"([^"]+)"\s*\)`. + +**Step 1 — Write failing tests.** Given one `VirtualTag{VirtualTagId="vt-1", EquipmentId="eq-1", Name="speed-rpm", DataType="Float64", ScriptId="s-1"}` + `Script{ScriptId="s-1", Source="return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;"}`, `Compose(...)` emits one `EquipmentVirtualTagPlan` with `Expression` = the source and `DependencyRefs` = `["TestMachine_001.TestDouble"]`. Add a second test: a script with two distinct `ctx.GetTag` calls yields two deps, de-duplicated. + +**Step 2 — Run, verify fail.** + +**Step 3 — Implement.** After the `equipmentTags` block (line ~235) add: +```csharp +var scriptsById = scripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal); +var equipmentVirtualTags = virtualTags + .OrderBy(v => v.EquipmentId, StringComparer.Ordinal) + .ThenBy(v => v.Name, StringComparer.Ordinal) + .Select(v => + { + var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.Source : string.Empty; + return new EquipmentVirtualTagPlan( + VirtualTagId: v.VirtualTagId, + EquipmentId: v.EquipmentId, + FolderPath: string.Empty, // VirtualTags hang directly under the equipment folder + Name: v.Name, + DataType: v.DataType, + Expression: src, + DependencyRefs: ExtractDependencyRefs(src)); + }) + .ToList(); +``` +Add `equipmentVirtualTags` to the returned object initializer (alongside `EquipmentTags`). Add the `ExtractDependencyRefs` helper. + +**Step 4 — Run tests, verify PASS.** + +**Step 5 — Commit.** `git commit -m "feat(opcua): compose Equipment VirtualTag plans from VirtualTag+Script rows"` + +--- + +### Task 2: Parse `EquipmentVirtualTags` in `DeploymentArtifact` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 3, Task 5 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` — add `BuildEquipmentVirtualTagPlans(JsonElement root)` mirroring `BuildEquipmentTagPlans` (lines 383-450); call it in **both** `ParseComposition` overloads (set `EquipmentVirtualTags = …` on the result at line ~199, and add the cluster-filter projection at line ~229). The snapshot arrays are `root.GetProperty("VirtualTags")` and `root.GetProperty("Scripts")` (PascalCase — `ConfigComposer` serialises entity property names; verify against the existing `"ScriptedAlarms"`/`"Tags"` reads). Re-derive `DependencyRefs` from `Script.Source` with the **same** regex as Task 1 (keep a single source of truth — put the extractor in a shared internal static helper if both assemblies can see it; otherwise replicate, matching the existing `ExtractFullName` replication pattern, and note the duplication in a comment). +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` artifact round-trip test file (find the existing `DeploymentArtifact`/`ParseComposition` tests). + +**Cluster-filter note:** VirtualTags have no `DriverInstanceId`. Filter the cluster-scoped overload by `EquipmentId` against `sets.EquipmentIds` (mirroring how `ScriptedAlarmPlans` is filtered at line 226: `.Where(a => sets.EquipmentIds.Contains(a.EquipmentId))`). + +**Step 1 — Write a failing round-trip test.** Build a snapshot JSON (or use the existing test's snapshot builder) containing a `VirtualTags` array + a matching `Scripts` array; assert `ParseComposition(blob).EquipmentVirtualTags` has the expected single plan with the right `VirtualTagId`, `EquipmentId`, `Name`, `Expression`, and `DependencyRefs`. + +**Step 2 — Run, verify fail.** + +**Step 3 — Implement** `BuildEquipmentVirtualTagPlans` + wire into both overloads. + +**Step 4 — Run tests, verify PASS.** Also run the full `Runtime.Tests` artifact suite to confirm no regression in existing parse tests. + +**Step 5 — Commit.** `git commit -m "feat(opcua): parse Equipment VirtualTag plans from the deployment artifact"` + +--- + +### Task 3: `Phase7Plan` diff dimension for Equipment VirtualTags + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 2, Task 5 + +**Files:** +- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs` (the diff record + the differ that produces `Added*/Removed*/Changed*` lists — see how `AddedEquipmentTags`/`RemovedEquipmentTags`/`ChangedEquipmentTags` are computed; `Phase7Applier.Apply` reads them at lines 73-85). +- Modify: `Phase7Plan.cs` — add `AddedEquipmentVirtualTags`/`RemovedEquipmentVirtualTags`/`ChangedEquipmentVirtualTags` (keyed by `VirtualTagId`), populated by the same diff routine that handles `EquipmentTags`. +- Modify: `Phase7Applier.cs` `Apply` — include the new lists in `addedCount`/`changedCount`/`removedCount` and in the `needsRebuild` predicate (lines 71-85), exactly like `EquipmentTags`. +- Test: `Phase7Plan` differ test file — add cases for added/removed/changed VirtualTags driving `needsRebuild = true`. + +**Step 1 — Failing test:** a plan with one `AddedEquipmentVirtualTags` entry → `Phase7Applier.Apply` returns `RebuildCalled == true` and `AddedNodes >= 1`. + +**Step 2 — Run, verify fail.** + +**Step 3 — Implement** the diff dimension + applier accounting. + +**Step 4 — Run tests, verify PASS.** + +**Step 5 — Commit.** `git commit -m "feat(opcua): diff Equipment VirtualTags in Phase7Plan + rebuild trigger"` + +--- + +### Task 4: Materialise Equipment VirtualTag variables + call in `HandleRebuild` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on T1, T2, T3) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — add `MaterialiseEquipmentVirtualTags(Phase7CompositionResult composition)` mirroring `MaterialiseEquipmentTags` (lines 197-234) but reading `composition.EquipmentVirtualTags`. **NodeId must be folder-scoped exactly like the tag pass:** `parent = string.IsNullOrWhiteSpace(v.FolderPath) ? v.EquipmentId : EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath); nodeId = $"{parent}/{v.Name}"`; `SafeEnsureVariable(nodeId, parent, v.Name, v.DataType)`. Log `equipment virtualtags materialised (vtags=…, equipment=…)`. +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs` — in `HandleRebuild`, after the `MaterialiseEquipmentTags(composition)` call, add `applier.MaterialiseEquipmentVirtualTags(composition);` (same applier instance/order). +- Test: `Phase7ApplierTests` — a composition with one `EquipmentVirtualTagPlan` ensures exactly one Variable at the folder-scoped NodeId with `BadWaitingForInitialData` (use the capturing/fake `IOpcUaAddressSpaceSink`). + +**Step 1 — Failing test** asserting the captured sink got `EnsureVariable("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64")`. + +**Step 2 — Run, verify fail.** + +**Step 3 — Implement** `MaterialiseEquipmentVirtualTags` + the `HandleRebuild` call. + +**Step 4 — Run tests, verify PASS.** Run the full `OpcUaServer.Tests` to confirm no materialiser regression. + +**Step 5 — Commit.** `git commit -m "feat(opcua): materialise Equipment VirtualTag variables on rebuild"` + +--- + +### Task 5: `VirtualTagHostActor` — spawn, subscribe, bridge results + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 2, Task 3 + +**Files:** +- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` (Props + `EvaluationResult` shape) and `.../OpcUa/OpcUaPublishActor.cs` (the `AttributeValueUpdate` record shape + its fully-qualified name, used in `DriverHostActor.ForwardToMux`). +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs` +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs` (Akka.TestKit — find the existing TestKit base used by the runtime tests). + +**Behaviour.** A supervisor that owns the live set of VirtualTag child actors and bridges their results to the publish actor: +```csharp +public sealed class VirtualTagHostActor : ReceiveActor +{ + public sealed record ApplyVirtualTags(IReadOnlyList Plans); + + private readonly IActorRef _publishActor; + private readonly IActorRef? _mux; + private readonly IVirtualTagEvaluator _evaluator; + private readonly Dictionary _children = new(StringComparer.Ordinal); // vtagId -> child + private readonly Dictionary _nodeIdByVtag = new(StringComparer.Ordinal); // vtagId -> folder-scoped NodeId + + public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator) => + Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator)); + + public VirtualTagHostActor(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator) + { + _publishActor = publishActor; _mux = mux; _evaluator = evaluator; + Receive(OnApply); + Receive(OnResult); + } + + private void OnApply(ApplyVirtualTags msg) + { + var desired = msg.Plans.ToDictionary(p => p.VirtualTagId, StringComparer.Ordinal); + // Stop children no longer present. + foreach (var id in _children.Keys.Where(k => !desired.ContainsKey(k)).ToList()) + { + Context.Stop(_children[id]); + _children.Remove(id); _nodeIdByVtag.Remove(id); + } + // Spawn newly-added children; rebuild the NodeId map for all. + foreach (var p in msg.Plans) + { + var parent = string.IsNullOrWhiteSpace(p.FolderPath) ? p.EquipmentId : $"{p.EquipmentId}/{p.FolderPath}"; + _nodeIdByVtag[p.VirtualTagId] = $"{parent}/{p.Name}"; + if (_children.ContainsKey(p.VirtualTagId)) continue; + var child = Context.ActorOf( + VirtualTagActor.Props(p.VirtualTagId, p.Expression, _evaluator, + scriptId: p.VirtualTagId, publisherFactory: null, + dependencyRefs: p.DependencyRefs, mux: _mux), + name: Akka.Util.Internal.ActorNameUtils... /* sanitise vtagId to a legal actor name; see note */); + _children[p.VirtualTagId] = child; + } + } + + private void OnResult(VirtualTagActor.EvaluationResult r) + { + if (!_nodeIdByVtag.TryGetValue(r.VirtualTagId, out var nodeId)) return; + _publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate( + nodeId, r.Value, OpcUaQuality.Good, r.TimestampUtc)); + } +} +``` +**Notes for the implementer:** +- Child actor **name** must be a legal Akka name — sanitise the VirtualTagId (replace `/`,`.`,`:` etc.) or use `Context.ActorOf(props)` (auto-named) and key `_children` by vtagId only. Auto-naming is simplest and avoids collisions; prefer it. +- `OpcUaQuality.Good` — confirm the enum/namespace (`Commons.OpcUa`?) used by `AttributeValueUpdate`. Match what `DriverHostActor.ForwardToMux` passes. +- `AttributeValueUpdate` is a nested record on `OpcUaPublishActor` — use its real fully-qualified name as in `DriverHostActor`. +- Because the child self-registers with the mux in `PreStart`, **no explicit `RegisterInterest` send is needed here** — just spawn with the right `mux` + `dependencyRefs`. + +**Step 1 — Failing tests (TestKit):** +1. `ApplyVirtualTags` with one plan spawns one child (assert `Context.Child` count / a probe). +2. When the host receives a `VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, corr)` and it has a plan mapping `vt-1 → eq-1/speed-rpm`, the **publishActor probe** receives `AttributeValueUpdate("eq-1/speed-rpm", 42.0, Good, ts)`. +3. A second `ApplyVirtualTags` without `vt-1` stops the child (watch + `ExpectTerminated`). + +**Step 2 — Run, verify fail.** + +**Step 3 — Implement** `VirtualTagHostActor`. + +**Step 4 — Run tests, verify PASS.** + +**Step 5 — Commit.** `git commit -m "feat(runtime): VirtualTagHostActor spawns VTag actors + bridges results to OPC UA"` + +--- + +### Task 6: Wire `VirtualTagHostActor` into `DriverHostActor` (apply + restore) + inject the evaluator + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on T4, T5) + +**Files:** +- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (confirm: `_dependencyMux` is non-null in production — i.e. the mux **is** spawned; `_opcUaPublishActor` ref; the `PushDesiredSubscriptions(deploymentId)` call sites in the apply path and in `RestoreApplied`; how the composition is loaded there — reuse the same `DeploymentArtifact.ParseComposition` result already loaded for `PushDesiredSubscriptions` so we don't parse twice). **If `_dependencyMux` is null in prod, that's a blocker — stop and report** (the value-streaming fix `b1b3f3f` added `ForwardToMux`, so it should be live; verify). +- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` and wherever `DriverHostActor.Props` is constructed (the Host driver-role startup) to thread an `IVirtualTagEvaluator` into `DriverHostActor` (the Roslyn evaluator is already DI-registered in the Host). +- Modify: `DriverHostActor.cs` — accept `IVirtualTagEvaluator` in `Props`/ctor; spawn one `VirtualTagHostActor` child in `PreStart` (or lazily on first apply) with `(_opcUaPublishActor, _dependencyMux, evaluator)`; after **each** `PushDesiredSubscriptions(deploymentId)` (apply path **and** `RestoreApplied`), load the composition and `_virtualTagHost.Tell(new VirtualTagHostActor.ApplyVirtualTags(composition.EquipmentVirtualTags))`. +- Modify: the Host startup that builds `DriverHostActor.Props` to pass the resolved `IVirtualTagEvaluator`. +- Test: extend the `DriverHostActor` apply/restore tests (the ones that assert `SetDesiredSubscriptions` is pushed) to also assert an `ApplyVirtualTags` is sent to the spawned host with the composition's VirtualTags on **both** apply and restore. Use the existing TestKit harness + a probe evaluator/publish. + +**Step 1 — Failing test:** on apply of a deployment whose artifact has one Equipment VirtualTag, the driver host sends `ApplyVirtualTags([that plan])` to its VirtualTag host child; repeat for the restore path. + +**Step 2 — Run, verify fail.** + +**Step 3 — Implement** the wiring (ctor param, child spawn, two `ApplyVirtualTags` sends, Host Props threading). + +**Step 4 — Run tests, verify PASS.** Run the full `Runtime.Tests` driver-host suite — this path is load-bearing for the live galaxy mirror; **no regression** in `SetDesiredSubscriptions`/restore behaviour is acceptable. + +**Step 5 — Commit.** `git commit -m "feat(runtime): spawn+apply VirtualTagHostActor on deploy apply and restore"` + +--- + +### Task 7: Loader emits VirtualTag+Script rows; `verify-equipment` asserts live values + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1–6 (different repo) + +**Repo:** `~/Desktop/scadaproj/otopcua-uns-loader` (commit on `scadaproj` `main` per existing convention; this dir is not a nested repo). + +**Files:** +- Modify: `otopcua_uns.py` — change `cmd_populate_equipment` so each company signal is loaded as a **VirtualTag** (a `VirtualTag` row + a `Script` row), **not** a driver `Tag`. The script source mirrors the live SystemPlatform mirror tag for that signal: `return ctx.GetTag(".").Value;` where `.` is the galaxy-mirror MXAccess ref (the same `source.fullTagReference` already in `company-uns.json`). Reuse the existing `nweq-…` id scheme for `VirtualTagId`/`ScriptId`. Keep the namespace `nw-uns` (Equipment-kind), the UnsArea/UnsLine/Equipment rows, and the friendly DisplayNames unchanged. +- Modify: `cmd_verify_equipment` — after browsing the company tree (still `nw-area-*` scoped, `--expect 1036`), additionally read each leaf's value and assert a `--require-good N` count of `Good` values (default 0 to stay back-compat; pass `--require-good 1036` post-deploy once values settle). Galaxy mirror tags change over time, so allow a `--wait` poll (reuse the `verify --wait` polling helper) before asserting Good. +- Read first: confirm the config-DB schema for `VirtualTag` + `Script` (table/column names, NOT-NULL columns, `SET QUOTED_IDENTIFIER ON` need like `Tag`) by inspecting the live DB (`otopcua-dev-sql-1`, port 14330, `OtOpcUa!Dev123`) — `SELECT TOP 0 * FROM VirtualTag; SELECT TOP 0 * FROM Script;`. Confirm an Equipment namespace with **only** VirtualTags (no driver Tags) passes the deploy `DraftValidator` (the namespace-kind↔driver rule). If a driver is still required for the namespace, keep the existing `nw-uns-modbus` stub driver but bind **no** Tags to it. + +**Step 1 — Dry-run the SQL shape** against the live DB (read-only `SELECT TOP 0`), confirm columns. **Step 2 — Implement** the VirtualTag/Script upsert (mirror the existing idempotent upsert-by-natural-key + `nweq-` prefix so `clean` still removes exactly what it created). **Step 3 — `populate-equipment`** then headless deploy then `verify-equipment --expect 1036` (structure) — should still pass (variables now come from VirtualTags). **Step 4** — extend `verify-equipment` to optionally assert Good. **Step 5 — Commit** on `scadaproj` `main`: `git commit -m "feat(loader): company overlay as VirtualTags mirroring the galaxy mirror + verify live values"` (do NOT commit the `.venv`). + +--- + +### Task 8: docker-dev end-to-end — deploy and verify live `Good` values + +**Classification:** standard +**Estimated implement time:** ~5 min (plus deploy/settle wait) +**Parallelizable with:** none (depends on T6, T7) + +**Steps (no new code — this is the integration gate):** +1. Build the docker-dev image with the Task 0–6 changes: `cd ~/Desktop/OtOpcUa/docker-dev && docker compose build admin-a && docker compose up -d` (all 4 host nodes share `otopcua-host:dev`). Confirm the galaxy mirror restores live (`b1b3f3f` RestoreApplied): 396 `Good` on `:4840`. +2. From the loader: `populate-equipment` → `curl -s -X POST http://localhost:9200/api/deployments -H 'X-Api-Key: docker-dev-deploy-key'` → wait for the `equipment virtualtags materialised (vtags=1036…)` + a driver-host `ApplyVirtualTags` log line. +3. `verify-equipment --expect 1036 --require-good 1036 --wait` — assert the company tree browses **and** every leaf reaches `Good` (allow the poll for the first change-triggered evaluation per VirtualTag; note in output any that stay `BadWaitingForInitialData` because their upstream galaxy tag hasn't changed yet — those are expected for genuinely-static signals, not a failure of the wiring). +4. **Restart safety:** `docker restart otopcua-dev-driver-a-1 otopcua-dev-driver-b-1`; without re-deploying, confirm the company VirtualTags return to `Good` (the `RestoreApplied` → `ApplyVirtualTags` path). + +**Acceptance:** company-shape leaves carry live `Good` values, and survive a driver-node restart with no re-deploy. Record the deploy id + any static-signal exceptions. + +**If integration fails:** prefer an inline fix in the most-likely file (the bridge NodeId in Task 5, or the apply/restore wiring in Task 6); only dispatch the debugger subagent (timeboxed ~10 min) if the cause isn't obvious from logs. + +--- + +### Task 9: Docs + memory update + +**Classification:** trivial +**Estimated implement time:** ~3 min +**Parallelizable with:** none (depends on T8) + +**Files:** +- Modify: `~/Desktop/scadaproj/otopcua-uns-loader/README.md` — flip the "Company-shape overlay" section from "structure-only / BadWaitingForInitialData" to "live values via VirtualTags"; document `--require-good`. +- Modify: `OtOpcUa/docs/plans/2026-06-06-equipment-namespace-materialization-scope.md` — mark WS-3a **done**. +- Update memory: `galaxy-uns-project-state.md` + `otopcua-uns-deploy-and-value-streaming.md` (company shape now carries live values; route taken = VirtualTag; commits + deploy id). Refresh `MEMORY.md` hooks. +- **Do not** auto-merge to `master`/push — the finishing-a-development-branch step presents merge/PR options to the user. + +**Commit:** `git commit -m "docs: company-shape UNS now carries live values (WS-3a done)"` + +--- + +## After all tasks + +Use **superpowers-extended-cc:finishing-a-development-branch**: verify the OtOpcUa test suite is green (note known pre-existing reds — live-infra integration tests), then present merge/PR/keep/discard options for `feat/equipment-namespace-live-values` (OtOpcUa) and the `scadaproj` loader commit. Merge/push only on the user's explicit go (per the project's standing rule). diff --git a/docs/plans/2026-06-07-equipment-namespace-live-values.md.tasks.json b/docs/plans/2026-06-07-equipment-namespace-live-values.md.tasks.json new file mode 100644 index 00000000..601437d3 --- /dev/null +++ b/docs/plans/2026-06-07-equipment-namespace-live-values.md.tasks.json @@ -0,0 +1,16 @@ +{ + "planPath": "docs/plans/2026-06-07-equipment-namespace-live-values.md", + "tasks": [ + {"id": 0, "subject": "Task 0: EquipmentVirtualTagPlan record + composition member", "status": "pending", "classification": "small"}, + {"id": 1, "subject": "Task 1: Populate EquipmentVirtualTags in Phase7Composer.Compose", "status": "pending", "classification": "standard", "blockedBy": [0], "parallelizableWith": [2, 3, 5]}, + {"id": 2, "subject": "Task 2: Parse EquipmentVirtualTags in DeploymentArtifact", "status": "pending", "classification": "standard", "blockedBy": [0], "parallelizableWith": [1, 3, 5]}, + {"id": 3, "subject": "Task 3: Phase7Plan diff dimension for Equipment VirtualTags", "status": "pending", "classification": "standard", "blockedBy": [0], "parallelizableWith": [1, 2, 5]}, + {"id": 4, "subject": "Task 4: Materialise Equipment VirtualTag variables + HandleRebuild call", "status": "pending", "classification": "standard", "blockedBy": [1, 2, 3]}, + {"id": 5, "subject": "Task 5: VirtualTagHostActor (spawn, subscribe, bridge results)", "status": "pending", "classification": "high-risk", "blockedBy": [0], "parallelizableWith": [1, 2, 3]}, + {"id": 6, "subject": "Task 6: Wire VirtualTagHostActor into DriverHostActor (apply+restore) + evaluator inject", "status": "pending", "classification": "high-risk", "blockedBy": [4, 5]}, + {"id": 7, "subject": "Task 7: Loader emits VirtualTag+Script rows; verify-equipment asserts live values", "status": "pending", "classification": "standard", "parallelizableWith": [1, 2, 3, 4, 5, 6]}, + {"id": 8, "subject": "Task 8: docker-dev end-to-end deploy + verify live Good values", "status": "pending", "classification": "standard", "blockedBy": [6, 7]}, + {"id": 9, "subject": "Task 9: Docs + memory update", "status": "pending", "classification": "trivial", "blockedBy": [8]} + ], + "lastUpdated": "2026-06-07" +}