From 504466437919daade1154b6c31f918bbb2a737cf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 07:27:44 -0400 Subject: [PATCH] docs(scripting): design for equipment-relative tag paths ({{equip}}) Approved brainstorm: a reserved {{equip}} token in ctx.GetTag/SetVirtualTag path literals is substituted at the compose seams with the owning equipment's tag base prefix (derived from child-tag FullNames). Lets one virtual-tag script be reused across machines. No schema migration, runtime untouched. --- ...-10-equipment-relative-tag-paths-design.md | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/plans/2026-06-10-equipment-relative-tag-paths-design.md diff --git a/docs/plans/2026-06-10-equipment-relative-tag-paths-design.md b/docs/plans/2026-06-10-equipment-relative-tag-paths-design.md new file mode 100644 index 00000000..104e9415 --- /dev/null +++ b/docs/plans/2026-06-10-equipment-relative-tag-paths-design.md @@ -0,0 +1,248 @@ +# Equipment-Relative Tag Paths (`{{equip}}`) — Design + +**Date:** 2026-06-10 +**Status:** Approved (brainstorming → ready for writing-plans) +**Author:** brainstormed with Claude Code + +## Goal + +Let one virtual-tag C# script be authored **once** and reused across every +equipment instance, by giving scripts an **equipment-relative** way to reference +sibling tags. Concretely: a reserved token **`{{equip}}`** inside a +`ctx.GetTag(...)` / `ctx.SetVirtualTag(...)` path literal is replaced at deploy +time with the owning equipment's tag base prefix. + +```csharp +// authored ONCE, bound to many VirtualTags (one per machine): +return System.Convert.ToInt32(ctx.GetTag("{{equip}}.Source").Value) > 50; +// → "TestMachine_001.Source" for the VirtualTag under equipment TestMachine_001 +// → "TestMachine_002.Source" for the VirtualTag under equipment TestMachine_002 +``` + +This kills the current pain: the rig has roughly **one Script row per VirtualTag** +(~1036) because each hard-codes its own machine's absolute tag references. With +`{{equip}}`, many VirtualTags can share a single template Script. + +## Background / why this shape + +### The literal-only constraint (the hard rule that shapes everything) + +Virtual-tag input paths **must be string literals**. +`DependencyExtractor` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs`) +rejects variables, concatenation, interpolation, and method-returned strings at +publish, because the change-trigger dependency graph is built **statically** from +the literals — the engine must know every upstream tag a script reads at load +time to wire its subscriptions. + +Therefore we **cannot** "pass a base-path string the script concatenates" +(`ctx.GetTag(base + ".Source")` would be rejected). Instead, the substitution +happens at the **compose seam**, before dependency extraction: the script text is +rewritten per-VirtualTag so the path is a normal concrete literal by the time the +extractor and the runtime see it. The literal-only invariant is preserved and the +**runtime is completely untouched**. + +### What the runtime resolves against (verified) + +`ctx.GetTag("X")` resolves against the driver **`FullName`** — the wire address +stored in `Tag.TagConfig` (for Galaxy/MXAccess this is the dotted ref +`object.attribute`, e.g. `TestMachine_001.Source`). The resolution chain: +`Phase7Composer.ExtractDependencyRefs` harvests the `ctx.GetTag("…")` literals → +`EquipmentVirtualTagPlan.DependencyRefs` → `VirtualTagActor._dependencyRefs` → +registered with `DependencyMuxActor`, whose `_byRef` map is keyed by +`AttributeValuePublished.FullReference` = the `FullName` from `Tag.TagConfig`. +The UNS-path engine (`Core.VirtualTags.VirtualTagEngine`, keyed by a slash-joined +browse path) is **dormant** — not wired into the host — so UNS browse paths never +resolve at runtime. (Source of truth: `AdminUI/ScriptAnalysis/IScriptTagCatalog.cs` +remarks.) + +So the per-machine varying part of a reference is the **object prefix** +(`TestMachine_001`), and the base for an equipment is the substring **before the +first `.`** of its tags' FullNames. The operator writes the separator and the +relative tail (`{{equip}}.Source`), so the base value itself is just the prefix +string `TestMachine_001` (no trailing dot). + +### Why derive instead of store + +The `Equipment` entity has **no field** that equals the Galaxy object prefix +(`Name` is a lowercase UNS segment, `MachineCode` is "machine_001", etc.). The +base only lives in the **child tags' FullNames**, which both compose seams already +have. Deriving it means **no schema migration and nothing new to serialize** — the +two seams independently derive the same base from data already present in both the +DB snapshot and the deployment artifact. + +## Decisions (from brainstorming) + +1. **Scope: equipment-relative only.** Not a general named/value parameter system. + The single implicit "parameter" is the equipment base prefix. +2. **Token: `{{equip}}`** (double brace), reserved/fixed name. +3. **Model: prefix token** — `ctx.GetTag("{{equip}}.Source")`, composer prepends + the base. (Chosen over sibling-by-Name resolution.) +4. **Base source: derived from child tags** — common prefix-before-first-dot of the + equipment's configured tag FullNames. **No migration, no new column.** +5. **Editor polish included in v1** — `{{equip}}`-aware hover + `{{equip}}.` leaf + completion (low effort). + +## Architecture + +### Components + +**A. `Commons` — pure helpers (no Roslyn).** New file(s) under +`src/Core/ZB.MOM.WW.OtOpcUa.Commons/` (the assembly both compose seams reference): + +- `DeriveEquipmentBase(IEnumerable childFullNames) -> string?` + - For each FullName, take the substring before the first `.` (whole string if no + dot). + - Distinct prefixes: exactly **1** → that prefix; **0** (no tags) or **>1** + (divergent / multi-object) → `null` (caller treats as "no derivable base"). +- `SubstituteEquipmentToken(string source, string equipBase) -> string` + - Replaces the literal token `{{equip}}` **only inside `ctx.GetTag(...)` and + `ctx.SetVirtualTag(...)` path-argument string literals** (so `{{equip}}` in a + comment, `Logger` string, or other code is left untouched — keeps it strictly + path-scoped). + - Implemented with the same regex shape as the existing `GetTagRefRegex`, + extended to also match `SetVirtualTag`. This single shared helper **absorbs the + two duplicated `GetTagRefRegex` copies** that exist today in + `Phase7Composer` and `DeploymentArtifact` (collapse the dependency-ref + extraction + the new substitution into one tested place). + - Identity when `{{equip}}` is absent (back-compat). + +> Note: `{{equip}}` substitution is purely textual prefixing; it does **not** need +> Roslyn, which is why it lives in `Commons` and not `Core.Scripting`. `OpcUaServer` +> deliberately does not reference `Core.Scripting`. + +**B. Compose seam 1 — `Phase7Composer.Compose`** +(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs`, the VirtualTag +producer at ~lines 291–310): + +- Build `Dictionary` from the + `equipmentTags` it already computes (group FullNames by `EquipmentId` → + `DeriveEquipmentBase`). +- Per VirtualTag: + - `var expanded = SubstituteEquipmentToken(src, baseByEquip.GetValueOrDefault(v.EquipmentId));` + (when base is null, leave the token in place — see Validation/degradation). + - `Expression: expanded` + - `DependencyRefs: ExtractDependencyRefs(expanded)` (now concrete). + +**C. Compose seam 2 — `DeploymentArtifact.BuildEquipmentVirtualTagPlans`** +(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs`): + +- `ParseComposition` already computes `equipmentTags = BuildEquipmentTagPlans(root)` + **before** `BuildEquipmentVirtualTagPlans(root)`. Thread the derived + `EquipmentId → base` map (or the equipment tags) into the vtag-plan builder and + substitute **identically** to seam 1. **No new artifact field** — derivation uses + the Tags already in the artifact. +- The existing duplicated `ExtractDependencyRefs`/`GetTagRefRegex` here is replaced + by the shared `Commons` helper so the two seams cannot drift. + +**D. AdminUI validation** (`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/`): + +- When a bound script's source contains `{{equip}}`, validate at VirtualTag + save/publish that the VirtualTag's equipment yields a derivable base (≥1 + configured tag under the equipment, single shared prefix). Surface a clear error + ("equipment X has no single tag base; `{{equip}}` can't resolve — configure tags + or split the equipment"). AdminUI references `Configuration` (DB) and the new + `Commons` helper. +- This is the real gate. The composer **degrades gracefully**: an unresolved + `{{equip}}` leaves a literal that matches no FullName → `BadNodeIdUnknown` + quality at runtime (observable in script logs), since the composer is a pure + no-logging function. + +**E. Editor (Monaco / ScriptAnalysis)** — light touch +(`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs`): + +- A literal containing `{{equip}}` is a valid C# string literal → **diagnostics + already don't flag it** (no work needed to avoid false errors). +- **Hover:** when the hovered tag-path literal contains `{{equip}}`, show + "Equipment-relative path — `{{equip}}` is resolved to the owning equipment's tag + base at deploy" instead of "not a known configured tag path". +- **Completion:** after `{{equip}}.`, offer the **attribute leaf names** seen across + the catalog (the substring after the first dot of FullNames, e.g. `Source`) — + equipment-relative completion that needs no specific equipment context. (Reuses + `IScriptTagCatalog`.) + +### Data flow + +``` +Author (global Script): ctx.GetTag("{{equip}}.Source") + │ + ▼ (publish / deploy) +Compose seam (per VirtualTag, by EquipmentId): + base = DeriveEquipmentBase(equipment's child-tag FullNames) // "TestMachine_001" + expanded = SubstituteEquipmentToken(src, base) // ctx.GetTag("TestMachine_001.Source") + DependencyRefs = ExtractDependencyRefs(expanded) // ["TestMachine_001.Source"] + │ + ▼ (unchanged from here on) +VirtualTagActor → DependencyMuxActor (keyed by FullName) → RoslynVirtualTagEvaluator + ctx.GetTag("TestMachine_001.Source") → readCache["TestMachine_001.Source"] ✓ +``` + +The two seams produce **identical** `EquipmentVirtualTagPlan`s (the existing parity +invariant between live-edit compose and artifact-decode is preserved). + +## Error handling / edge cases + +- **No derivable base** (equipment has no configured tags, or tags span multiple + objects with divergent prefixes): AdminUI blocks save/publish with a clear + message; composer leaves the token unsubstituted → runtime `BadNodeIdUnknown`. +- **FullName with no dot:** prefix = the whole string; the single-prefix check still + applies. (`{{equip}}` is meant for dotted Galaxy/MXAccess refs; non-dotted drivers + simply won't have a meaningful relative tail.) +- **`{{equip}}` outside a tag-path literal** (comment, `Logger` string, code): + untouched by `SubstituteEquipmentToken` (scoped to GetTag/SetVirtualTag literals). + Note `{{` is not valid C# outside a string, so it can only appear in strings. +- **Back-compat:** scripts without `{{equip}}` are unchanged (substitution is + identity); existing ~1036 scripts keep working. No data migration. + +## Reuse migration (no schema change required) + +Sharing a `Script` across VirtualTags already works (`VirtualTag.ScriptId` is just +an FK; nothing enforces 1:1). Today they don't share only because the paths are +hard-coded. With `{{equip}}` the operator: rewrites one template script with +`{{equip}}`, re-points many VirtualTags' `ScriptId` at it, deletes the duplicate +scripts — all ordinary AdminUI data editing. + +## Testing (no bUnit) + +Unit (xUnit + Shouldly): +- `DeriveEquipmentBase`: single prefix; divergent prefixes → null; empty → null; + no-dot FullNames; mixed. +- `SubstituteEquipmentToken`: replaces in `GetTag` and `SetVirtualTag` literals; + leaves comments / `Logger` strings / code alone; multiple occurrences; identity + when token absent; raw-string literals. +- `Phase7Composer`: a VirtualTag with `{{equip}}` over an equipment's tags → + Expression + DependencyRefs concrete; two equipments sharing one Script → + different resolved refs. +- `DeploymentArtifact`: encode tags + vtags → decode → substitute → concrete refs; + **parity** with `Phase7Composer` for the same input. +- AdminUI: save-validation when binding a `{{equip}}` script to an equipment with / + without a derivable base. + +Live (docker-dev `/run`, user drives — agent does not sign in): author a +`{{equip}}` script, bind it to two machines' VirtualTags, deploy, confirm each +resolves its own machine's tag and produces live values; confirm the editor hover + +`{{equip}}.` completion. + +## Out of scope (YAGNI) + +- Arbitrary named/value parameters (`ctx.Parameters[...]`), typed accessors. +- Explicit per-equipment base column / operator-set override. +- Multi-object-equipment support (divergent prefixes intentionally error). +- Sibling-by-Name resolution (resolve a relative leaf to a configured Tag's + FullName) — a possible future enhancement if read targets aren't all configured + as Tags. + +## Touched code (no Configuration entity / migration change) + +- **New:** `Commons/…` — `DeriveEquipmentBase` + `SubstituteEquipmentToken` (+ tests + in the Commons test project). +- **Modify:** `OpcUaServer/Phase7Composer.cs` (substitute + derive; use shared + helper). +- **Modify:** `Runtime/Drivers/DeploymentArtifact.cs` (substitute + derive; use + shared helper). +- **Modify:** `AdminUI` — VirtualTag save/publish validation; `ScriptAnalysis` + hover + `{{equip}}.` completion. +- **Docs:** `docs/ScriptEditor.md` (+ a note in CLAUDE.md scripting section). + +Hard rules carried from prior work: stage by path (never `git add .`), never stage +`sql_login.txt` / `pki/`, no `--no-verify`, no force-push, no Configuration +entity/migration change, agent does not sign in to the AdminUI.