fix(scripteditor): complete + hover absolute RawPaths (#490)
v2-ci / build (push) Successful in 4m25s
v2-ci / unit-tests (push) Failing after 14m58s

The issue reports a missing feature. The cause is a stale projection, and the
consequence was worse than the gap described.

`ScriptTagCatalog` still emitted `Tag.TagConfig.FullName` — the pre-v3 driver wire
address. Since v3 the mux key is the **RawPath**: `AddressSpaceComposer` harvests the
`ctx.GetTag("…")` literals into `DependencyRefs`, those become `VirtualTagActor`
dependency refs registered with `DependencyMuxActor`, whose `_byRef` is keyed Ordinal
by `AttributeValuePublished.FullReference` — and a driver's wire ref IS the RawPath
(`DriverHostActor._nodeIdByDriverRef` is keyed `(DriverInstanceId, RawPath)`). The
`{{equip}}/<RefName>` form already substitutes to RawPaths at both compose seams.

So the catalog was wrong in BOTH directions, not merely incomplete: completion
offered paths that could never resolve at runtime, while a CORRECT absolute RawPath
got no completion and hovered as "not a known configured tag path". The file's own
comment admitted the raw/UNS catalog was left for "Batch 2/3" and it never came.

Now projected through the shared `RawPathResolver` — the same byte-parity authority
`DraftValidator` and `AddressSpaceComposer` use — so a suggested path is identical to
the one the deploy gate and the runtime compute. A tag with a broken ancestry chain
resolves to null and is omitted: the runtime could not route it either, so offering
it would be a lie. Hover additionally gains the owning `DriverInstanceId`, which the
topology now makes available (it was always null before).

Editor-accepts <=> publish-accepts is preserved: no diagnostic keys off this catalog
(`OTSCRIPT_EQUIPREF` only marks `{{equip}}` paths), so this changes completion and
hover only — it cannot newly reject a script.

The existing tests pinned the v2 contract and were re-authored: a RawPath only exists
if the RawFolder -> DriverInstance -> Device -> TagGroup -> Tag chain does, so the
seed now builds one. Added coverage for group-nested paths, a driver at the cluster
root (no folder segment), a broken chain being omitted rather than guessed, and the
pre-v3 FullName no longer resolving.

Live-gated on docker-dev (rig rebuilt, both central nodes):
- completion, empty literal -> every RawPath incl. group-nested
  `opcua1/plc/OpcPlc/Telemetry/Basic/AlternatingBoolean`
- completion, prefix `pymod` -> `pymodbus/plc/HR200X`, `pymodbus/plc/ImportedTag`
- hover `pymodbus/plc/HR200X` -> "Tag · Type UInt16 · Driver MAIN-modbus"
- hover an unknown path -> "Not a known configured tag path"

AdminUI 742/742; script-analysis 55/55; solution builds 0 errors.
This commit is contained in:
Joseph Doherty
2026-07-26 11:08:11 -04:00
parent 0f38f48679
commit 123ddc3f40
2 changed files with 196 additions and 146 deletions
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
@@ -40,41 +41,42 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
/// <summary>
/// Default <see cref="IScriptTagCatalog"/>. Returns ONLY the resolvable keys a script may pass to
/// <c>ctx.GetTag</c> / <c>ctx.SetVirtualTag</c> — the driver <c>FullName</c> for equipment tags,
/// the MXAccess dot-ref for FolderPath-scoped tags (<c>EquipmentId == null</c>), and the leaf
/// <c>Name</c> (best-effort) for virtual tags.
/// <c>ctx.GetTag</c> / <c>ctx.SetVirtualTag</c> — in v3 that is a raw tag's <b>RawPath</b>, plus the
/// leaf <c>Name</c> (best-effort) for virtual tags.
/// </summary>
/// <remarks>
/// <para>
/// <b>Fidelity over breadth.</b> Verified: the live runtime resolves a <c>ctx.GetTag("X")</c>
/// literal against the driver <c>FullName</c> — the resolution chain is
/// <c>AddressSpaceComposer</c> (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) harvesting the <c>ctx.GetTag("…")</c> literals
/// into <c>EquipmentVirtualTagPlan.DependencyRefs</c>
/// (<c>src/Server/…OpcUaServer/AddressSpaceComposer.cs</c>); those become
/// <c>VirtualTagActor._dependencyRefs</c>, registered with the
/// <c>DependencyMuxActor</c>, whose <c>_byRef</c> map is keyed by
/// <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
/// (<c>src/Server/…Runtime/VirtualTags/DependencyMuxActor.cs:97</c>) — and that
/// <c>FullReference</c> is the <c>FullName</c> field extracted from <c>Tag.TagConfig</c>
/// (see <c>Commons.Types.TagConfigIntent.Parse</c>, the shared byte-parity FullName authority).
/// The UNS-path engine (<c>Core.VirtualTags.VirtualTagEngine</c>, keyed by a slash-joined
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c>) is dormant — it is NOT wired into the
/// host — so UNS browse paths never resolve at runtime and are intentionally NOT suggested.
/// <b>Fidelity over breadth.</b> This projects exactly what the live runtime resolves, and nothing
/// else. Verified chain: <c>AddressSpaceComposer</c> harvests the <c>ctx.GetTag("…")</c> literals
/// (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) into <c>DependencyRefs</c>; those become
/// <c>VirtualTagActor._dependencyRefs</c>, registered with <c>DependencyMuxActor</c>, whose
/// <c>_byRef</c> map is keyed (Ordinal) by <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
/// — and in v3 a driver's wire reference <b>is the RawPath</b>
/// (<c>DriverHostActor._nodeIdByDriverRef</c> is keyed <c>(DriverInstanceId, RawPath)</c>).
/// </para>
/// <para>
/// The per-category resolvable key:
/// <list type="bullet">
/// <item>Equipment driver tag (<c>EquipmentId != null</c>) → the driver <c>FullName</c>
/// extracted from <c>Tag.TagConfig</c> (the verified <c>DependencyMux</c> key).
/// GalaxyMxGateway is a standard Equipment-kind driver, so Galaxy points resolve
/// by this same <c>FullName</c> key.</item>
/// <item>VirtualTag → its leaf <c>Name</c> only, as a BEST-EFFORT key (the live resolution
/// of virtual-tag cascade/write targets is unconfirmed).</item>
/// </list>
/// <b>This replaced a stale v2 projection (Gitea #490).</b> The catalog used to emit
/// <c>Tag.TagConfig.FullName</c> — the pre-v3 wire address — which since v3 is <i>not</i> the mux
/// key. The consequence was worse than the missing feature the issue reported: completion offered
/// paths that could never resolve at runtime, while a <b>correct</b> absolute RawPath got no
/// completion and hovered as "not a known configured tag path". Both directions are now right.
/// </para>
/// <para>
/// Follow-up: surface the UNS browse path as a completion <i>detail</i> (a non-inserted hint
/// shown alongside the resolvable key) for discoverability, rather than as an inserted value.
/// RawPaths are built through the shared <see cref="RawPathResolver"/> — the same byte-parity
/// authority <c>DraftValidator</c> and <c>AddressSpaceComposer</c> use — so a suggested path is
/// identical to the one the deploy gate and the runtime compute. A tag whose ancestry chain is
/// broken (unknown device / driver, invalid segment) resolves to <see langword="null"/> and is
/// omitted: the runtime could not route it either, so offering it would be a lie.
/// </para>
/// <para>
/// The <c>{{equip}}/&lt;RefName&gt;</c> form is served separately by
/// <see cref="GetEquipmentReferenceNamesAsync"/>; the compose seams substitute those references to
/// the same RawPaths listed here.
/// </para>
/// <para>
/// The catalog is <b>fleet-wide</b> (no cluster filter), matching the runtime's key space: the
/// deployed artifact is fleet-wide and the mux is keyed by RawPath alone. Two clusters using the
/// same RawPath collapse to one suggestion, which is correct — the string is what resolves.
/// </para>
/// <para>
/// Each call creates and disposes its own context via the pooled factory — the same pattern
@@ -149,22 +151,43 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
}
/// <summary>
/// Loads Tags + VirtualTags (untracked) and projects one <see cref="ScriptTagInfo"/> per row —
/// the SINGLE source of the per-category resolvable-key projection shared by
/// <see cref="GetPathsAsync"/> (distinct paths, prefix-filtered) and
/// <see cref="GetTagInfoAsync"/> (exact Ordinal lookup).
/// Loads the raw topology + Tags + VirtualTags (untracked) and projects one
/// <see cref="ScriptTagInfo"/> per resolvable key — the SINGLE source of the projection shared by
/// <see cref="GetPathsAsync"/> (distinct paths, prefix-filtered) and <see cref="GetTagInfoAsync"/>
/// (exact Ordinal lookup).
/// </summary>
private async Task<IReadOnlyList<ScriptTagInfo>> BuildEntriesAsync(CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
// v3: Tag is Raw-only — EquipmentId / FolderPath / DriverInstanceId were retired (the
// equipment↔Tag + driver bindings are gone). The resolvable key is the driver FullName carried
// in TagConfig; project only the surviving columns. (The Raw/UNS tag catalog is rebuilt in
// Batch 2/3.)
// The raw topology, fleet-wide, in the four ancestry maps RawPathResolver takes. Building the
// resolver here (rather than reimplementing the join) is what keeps a suggested path
// byte-identical to the one the deploy gate and the runtime compute.
var folders = (await db.RawFolders.AsNoTracking()
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
.ToListAsync(ct))
.ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
var drivers = (await db.DriverInstances.AsNoTracking()
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name })
.ToListAsync(ct))
.ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
var devices = (await db.Devices.AsNoTracking()
.Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name })
.ToListAsync(ct))
.ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal);
var groups = (await db.TagGroups.AsNoTracking()
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
.ToListAsync(ct))
.ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
var resolver = new RawPathResolver(folders, drivers, devices, groups);
var tagRows = await db.Tags
.AsNoTracking()
.Select(t => new { t.Name, t.DataType, t.TagConfig })
.Select(t => new { t.DeviceId, t.TagGroupId, t.Name, t.DataType })
.ToListAsync(ct);
var vtagRows = await db.VirtualTags
@@ -176,46 +199,22 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
foreach (var t in tagRows)
{
// The runtime GetTag key is the driver FullName from TagConfig; fall back to Name if absent.
var full = ExtractFullNameFromTagConfig(t.TagConfig);
var path = string.IsNullOrWhiteSpace(full) ? t.Name : full;
entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, null));
// v3: the runtime GetTag key IS the RawPath. A null result means a broken/unknown ancestry
// chain — the runtime could not route that tag either, so it is omitted rather than guessed.
var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
if (path is null) continue;
var driverInstanceId = devices.TryGetValue(t.DeviceId, out var dev) ? dev.DriverInstanceId : null;
entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, driverInstanceId));
}
foreach (var v in vtagRows)
{
// Virtual tag — best-effort: the live resolution of virtual-tag cascade/write targets is
// unconfirmed, so emit the leaf Name only (no UNS browse path, which never resolves).
// unconfirmed, so emit the leaf Name only.
entries.Add(new ScriptTagInfo(v.Name, "Virtual tag", v.DataType, null));
}
return entries;
}
/// <summary>
/// Extracts the driver-side full reference from a <c>Tag.TagConfig</c> JSON blob — the
/// top-level <c>FullName</c> string every shipped driver stores. Mirrors
/// <c>Commons.Types.TagConfigIntent.Parse(...).FullName</c> (the shared byte-parity authority;
/// AdminUI does not reference that assembly here). Falls back to the raw blob when it is not
/// a JSON object carrying a string <c>FullName</c>.
/// </summary>
private static string ExtractFullNameFromTagConfig(string tagConfig)
{
// Best-effort: pull the driver FullName out of the TagConfig JSON blob if present.
// Returns "" when absent/unparseable so the caller falls back to the tag Name — never
// the raw blob (which would surface as a garbage completion path).
if (string.IsNullOrWhiteSpace(tagConfig)) return "";
try
{
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
&& fullName.ValueKind == System.Text.Json.JsonValueKind.String)
{
return fullName.GetString() ?? "";
}
}
catch (System.Text.Json.JsonException) { /* fall through to tag Name */ }
return "";
}
}