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.
This commit is contained in:
@@ -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<string> 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<string /*EquipmentId*/, string? /*base*/>` 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.
|
||||
Reference in New Issue
Block a user