11-task TDD plan from the approved alias-tag design. Approach A (reuse Tag entity, broaden composer/artifact equipment-tag filter); converter rewrites relay VirtualTags as alias Tags. No entity/EF migration.
34 KiB
Galaxy Alias Tag (UNS) 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: Let an Equipment node surface a Galaxy attribute under a friendly UNS name as a first-class driver-bound Tag (an alias), resolved by a direct Galaxy-driver subscription — replacing relay VirtualTags whose script is nothing but return ctx.GetTag("X").Value;.
Architecture: Approach A — an alias is an ordinary Tag row (EquipmentId set, DriverInstanceId = the Galaxy gateway, TagConfig = {"FullName":"<galaxyRef>"}). The only structural change is broadening the equipment-tag filter in the composer + artifact decoder to admit GalaxyMxGateway-backed equipment tags; everything downstream (materialise, subscribe, write-through) is reused unchanged. No Configuration entity and no EF migration. A converter rewrites existing relay VirtualTags as alias Tags.
Tech Stack: .NET 10, Akka.NET, Blazor Server (InteractiveServer, no bUnit), EF Core (OtOpcUaConfigDbContext, optimistic RowVersion), xUnit + Shouldly, EF InMemory for service tests.
Design: docs/plans/2026-06-11-alias-tag-design.md (committed master 305023aa).
Grounded facts (verified against the code)
- Composer filter —
Phase7Composer.cs:365-381equipmentTags=EquipmentId is not nullAND driver namespaceKind == NamespaceKind.Equipment;FullName: ExtractTagFullName(t.TagConfig)(reads top-level"FullName"). The Galaxy/SystemPlatform filter is the inverse (EquipmentId is null,Kind == SystemPlatform) — untouched. - Artifact decoder —
DeploymentArtifact.cs:447-526BuildEquipmentTagPlansmirrors it (buildsequipmentNamespaces+driverToNamespace, requiresequipmentNamespaces.Contains(nsId)). Byte-parity with the composer. The artifact'sDriverInstancesarray carriesDriverType(DriverInstancePlan(DriverInstanceId, DriverType, ConfigJson)). - Validator —
DraftValidator.cs;DraftSnapshotexposes fullTags(withTagConfig) +DriverInstances(withDriverType).ValidateDriverNamespaceCompatibility(line 192) checks a driver's home namespace and is unaffected. No tag-level namespace-kind invariant insp_ValidateDraft. - Service —
UnsTreeService.cs:CreateTagAsync(744),UpdateTagAsync(794),LoadTagsForEquipmentAsync(86),LoadTagDriversForEquipmentAsync(714, filters to Equipment-kind namespaces),CheckTagDriverGuardAsync(1192, rejects non-Equipment-kind drivers — aliases need a Galaxy-aware variant),ResolveEquipmentClusterAsync(1161). Name-uniqueness guard =db.Tags.AnyAsync(t => t.EquipmentId == eq && t.Name == name). - DTOs —
EquipmentChildRows.csEquipmentTagRow(TagId, Name, DriverInstanceId, DataType, AccessLevel);TagInput.cs. - Modal sibling —
Components/Shared/Uns/ScriptedAlarmModal.razor(the_loadedKeyguard +Visible/IsNew/EquipmentId/Existing/OnSaved/OnCancelparam shape to copy). - Galaxy picker —
Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razorparams:CurrentAddress+CurrentAddressChanged+GetConfigJson : Func<string>(the gatewayDriverConfig, fed toBrowserService.OpenAsync("GalaxyMxGateway", configJson, ct)). Hosted insideDriverTagPicker(seeGalaxyDriverPage.razor:60). - Commons —
EquipmentScriptPaths.cs:GetTagRefRegex,DeriveEquipmentBase,SubstituteEquipmentToken,ContainsEquipToken. - Equipment page Tags tab —
Components/Pages/Uns/EquipmentPage.razor: tab markup:139,OpenAddTag :344,ReloadTagsAsync :339,_tags :296,<TagModal …> :180. - FleetAdmin policy — string
"FleetAdmin"(e.g.RoleGrants.razor,ScriptAnalysisEndpoints.cs).
Hard rules
Stage by explicit path (never git add .); never stage sql_login.txt or src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/; never echo the gateway API key into a new tracked file; no force-push; no --no-verify; no Configuration entity / EF migration change. Feature branch off master 305023aa.
Same-file contention / ordering
Phase7Composer.cs+DeploymentArtifact.cs→ T2 only.EquipmentScriptPaths.cs→ T1 only.DraftValidator.cs+Tag.cs→ T3 only.IUnsTreeService.cs+UnsTreeService.cs→ T4 → T5 → T7 (serialize).EquipmentPage.razor→ T6 → T8 (serialize).- T1, T2, T3 are mutually independent (parallel after T0). T9 (docs) is parallel with anything.
Task 0: Feature branch
Classification: trivial Estimated implement time: ~1 min Parallelizable with: none
Step 1: From master at 305023aa:
git checkout -b feat/uns-alias-tag
git log --oneline -1 # expect 305023aa design commit on the branch base
No code change. Do not commit anything yet.
Task 1: Commons relay-body parser
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 2, Task 3
Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs - Test:
tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs
Add a pure helper that recognises an exact relay body and extracts its single ctx.GetTag reference. This is load-bearing for the converter (Task 7) and is fully offline-TDD-able.
Step 1: Write failing tests (append to EquipmentScriptPathsTests.cs):
[Theory]
[InlineData("return ctx.GetTag(\"TestMachine_020.TestChangingInt\").Value;", "TestMachine_020.TestChangingInt")]
[InlineData(" return ctx . GetTag ( \"A.B\" ) . Value ; ", "A.B")]
[InlineData("return ctx.GetTag(\"{{equip}}.Speed\").Value;", "{{equip}}.Speed")]
public void TryParseRelayBody_accepts_exact_passthrough(string src, string expectedRef)
{
EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeTrue();
r.ShouldBe(expectedRef);
}
[Theory]
[InlineData("return ctx.GetTag(\"A.B\").Value * 2;")] // arithmetic
[InlineData("var x = ctx.GetTag(\"A.B\").Value; return x;")] // extra statement
[InlineData("return ctx.GetTag(\"A.B\").Value + ctx.GetTag(\"C.D\").Value;")] // two reads
[InlineData("return ctx.GetTag(\"A.B\").Quality;")] // not .Value
[InlineData("return 5;")] // no GetTag
[InlineData("")] // empty
public void TryParseRelayBody_rejects_non_relay(string src)
{
EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeFalse();
r.ShouldBeNull();
}
Step 2: Run dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter TryParseRelayBody → FAIL (method missing).
Step 3: Implement in EquipmentScriptPaths.cs:
// A pure pass-through virtual-tag body: exactly `return ctx.GetTag("<ref>").Value;`
// (whitespace-insensitive). Captures <ref> for relay→alias conversion. Anything with extra
// statements, arithmetic, a different member than .Value, or multiple GetTag calls is NOT a relay.
private static readonly Regex RelayBodyRegex = new(
@"^\s*return\s+ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)\s*\.\s*Value\s*;\s*$",
RegexOptions.Compiled);
/// <summary>Recognise an exact relay body and capture its single GetTag reference.</summary>
public static bool TryParseRelayBody(string? source, out string? tagReference)
{
tagReference = null;
if (string.IsNullOrWhiteSpace(source)) return false;
var m = RelayBodyRegex.Match(source);
if (!m.Success) return false;
tagReference = m.Groups[1].Value;
return true;
}
Step 4: Run the filter again → PASS. Run the full EquipmentScriptPaths suite → all green.
Step 5: Commit
git add src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs \
tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs
git commit -m "feat(commons): TryParseRelayBody — detect pure ctx.GetTag relay scripts"
Task 2: Broaden the equipment-tag filter (composer + artifact)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 1, Task 3
Data-contract composition + byte-parity pair — the two sites must change together.
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs:365-369 - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs:474-505 - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs(new) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs(new)
Step 1: Write failing composer test (Phase7ComposerAliasTagTests.cs). Model the arrange on Phase7ComposerEquipTokenTests.cs (same project — copy its builder/setup). Assert:
- An equipment-scoped Tag (
EquipmentIdset) bound to aGalaxyMxGatewaydriver whose namespace isSystemPlatform, withTagConfig={"FullName":"TestMachine_020.TestChangingInt"}, now appears in the composed EquipmentTags withFullName == "TestMachine_020.TestChangingInt". - A SystemPlatform mirror Tag (
EquipmentId == null, same Galaxy driver) still appears in GalaxyTags and not in EquipmentTags (no regression).
Step 2: dotnet test …OpcUaServer.Tests --filter AliasTag → FAIL (alias tag dropped).
Step 3: Implement composer — Phase7Composer.cs, the equipmentTags .Where (line 367-369). The lambda already binds di and ns:
.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
&& (ns.Kind == NamespaceKind.Equipment || di.DriverType == "GalaxyMxGateway"))
(One clause added: || di.DriverType == "GalaxyMxGateway". The Galaxy/SystemPlatform producer keeps its EquipmentId is null guard, so a Galaxy alias — EquipmentId set — never double-counts.)
Step 4: Implement artifact — DeploymentArtifact.BuildEquipmentTagPlans. Add a driverType map alongside driverToNamespace (in the diArr loop, ~line 476-483):
var driverToType = new Dictionary<string, string>(StringComparer.Ordinal);
// … inside the existing foreach over diArr, after reading id/ns:
var dtype = el.TryGetProperty("DriverType", out var dtEl) ? dtEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(dtype))
driverToType[id!] = dtype!;
Then broaden the qualifying check (replace line 504-505):
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
var isGalaxyAlias = driverToType.TryGetValue(di!, out var dtype2) && dtype2 == "GalaxyMxGateway";
if (!equipmentNamespaces.Contains(nsId) && !isGalaxyAlias) continue;
Step 5: Write failing parity test (DeploymentArtifactAliasParityTests.cs). Model on DeploymentArtifactEquipTokenTests.cs. Compose a draft containing one Galaxy alias tag, serialise to the artifact, decode via DeploymentArtifact, and assert the decoded EquipmentTags equals the composer's EquipmentTags for that tag (same FullName, EquipmentId, DriverInstanceId, Name, DataType) — i.e. the alias survives the round-trip byte-parity.
Step 6: Run both new test files + the existing DeploymentArtifact* and Phase7Composer* suites → all green (no parity regression).
Step 7: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs \
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
git commit -m "feat(composer): admit GalaxyMxGateway-backed equipment alias tags (+byte-parity)"
Task 3: DraftValidator alias-reference check + doc reframe
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2
Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs - Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs:5-9,33(doc only) - Test:
tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs
Step 1: Write failing tests (append to DraftValidatorTests.cs, follow its existing arrange helpers). A draft with a Galaxy-gateway-bound equipment Tag whose TagConfig lacks FullName (e.g. {}) → Validate returns a AliasTagMissingReference error keyed on the TagId. A well-formed alias ({"FullName":"X.Y"}) → no such error. A normal Equipment-kind driver tag with {} → no alias error (not a Galaxy alias).
Step 2: dotnet test …Configuration.Tests --filter AliasTag → FAIL.
Step 3: Implement. Register a new method in Validate(...) and add it:
private static void ValidateAliasTagFullName(DraftSnapshot draft, List<ValidationError> errors)
{
var typeByDriver = draft.DriverInstances
.ToDictionary(d => d.DriverInstanceId, d => d.DriverType, StringComparer.Ordinal);
foreach (var t in draft.Tags)
{
if (t.EquipmentId is null) continue;
if (!typeByDriver.TryGetValue(t.DriverInstanceId, out var dtype) || dtype != "GalaxyMxGateway")
continue;
if (string.IsNullOrWhiteSpace(ExtractTagConfigFullName(t.TagConfig)))
errors.Add(new("AliasTagMissingReference",
$"Alias tag '{t.TagId}' on equipment '{t.EquipmentId}' is missing a Galaxy reference (TagConfig.FullName)",
t.TagId));
}
}
// Minimal local reader (mirrors Phase7Composer.ExtractTagFullName) — top-level "FullName" string.
private static string? ExtractTagConfigFullName(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fn)
&& fn.ValueKind == System.Text.Json.JsonValueKind.String
? fn.GetString() : null;
}
catch (System.Text.Json.JsonException) { return null; }
}
Step 4: Run the filter → PASS; full DraftValidatorTests → green.
Step 5: Doc reframe in Tag.cs — replace the class-summary line + the EquipmentId remark (lines 5-9, 33) so they read: EquipmentId set ⟺ the tag participates in the Equipment tree, regardless of the driver's namespace kind. A GalaxyMxGateway-bound equipment tag is an alias (a Galaxy attribute surfaced under a UNS name); its Galaxy reference lives in TagConfig.FullName. EquipmentId is NULL for SystemPlatform mirror tags (FolderPath-scoped).
Step 6: Commit
git add src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs \
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs \
tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs
git commit -m "feat(validation): require TagConfig.FullName on Galaxy alias tags; reframe Tag doc"
Task 4: Service read-side — alias DTO, gateway lookup, Source column
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 2, Task 3 (different files); serialize before Task 5
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/AliasTagInput.cs - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs(extendEquipmentTagRow) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs(LoadTagsForEquipmentAsync:86 + newLoadGalaxyGatewaysForEquipmentAsync) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs(new)
Step 1: Add the DTO AliasTagInput.cs:
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>Operator-editable fields for a Galaxy alias tag (an equipment Tag bound to the Galaxy
/// gateway). FullName is the picked Galaxy reference (tag_name.AttributeName); it is stored as
/// TagConfig {"FullName":…}. AccessLevel defaults ReadOnly at the call site.</summary>
public sealed record AliasTagInput(
string TagId, string Name, string DriverInstanceId, string DataType,
TagAccessLevel AccessLevel, string FullName);
Step 2: Extend EquipmentTagRow (defaulted trailing params keep existing constructions compiling — mirror the UnsMutationResult.CreatedId trick):
public sealed record EquipmentTagRow(
string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel,
bool IsAlias = false, string? Source = null);
Step 3: Write failing tests (UnsTreeServiceAliasTagTests.cs; use the UnsTreeTestDb helper like UnsTreeServiceTagTests.cs). Seed a cluster with a GalaxyMxGateway driver in a SystemPlatform namespace + an Equipment-kind namespace, an equipment, and tags. Assert:
LoadGalaxyGatewaysForEquipmentAsync(eq)returns the gateway (DriverInstanceId, display,DriverConfig) and excludes non-Galaxy drivers.LoadTagsForEquipmentAsyncmarks a Galaxy-gateway-bound tag withIsAlias == trueandSource == "galaxy:<FullName>", and a normal driver tag withIsAlias == false.
Step 4: Implement.
- Interface additions (
IUnsTreeService.cs):
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)>>
LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default);
Task<UnsMutationResult> CreateAliasTagAsync(string equipmentId, AliasTagInput input, CancellationToken ct = default); // impl in Task 5
Task<UnsMutationResult> UpdateAliasTagAsync(string tagId, AliasTagInput input, byte[] rowVersion, CancellationToken ct = default); // Task 5
LoadGalaxyGatewaysForEquipmentAsync(model onLoadTagDriversForEquipmentAsync:714 but filterd.DriverType == "GalaxyMxGateway"in the equipment's cluster; selectDriverInstanceId, Name, DriverConfig).LoadTagsForEquipmentAsync(:86) — to setIsAlias/Source, the projection needs each tag's driver type +TagConfig. Joindb.Tagstodb.DriverInstancesonDriverInstanceId; in a post-query.Select, setIsAlias = driverType == "GalaxyMxGateway", andSource = IsAlias ? $"galaxy:{ExtractFullName(TagConfig)}" : null. Pull the rows first (AsNoTracking().Where(eq).Join(...)), then map in memory so you can parseTagConfigwith the same smallFullNamereader used in Task 3 (add a privateExtractTagConfigFullNametoUnsTreeServiceor a shared Commons helper — DRY: prefer one shared reader).
Step 5: Run dotnet test …AdminUI.Tests --filter AliasTag → PASS. Run UnsTreeServiceEquipmentChildRowsTests + UnsTreeServiceTagTests → green (defaulted params kept them compiling).
Step 6: Commit (stage the five files by path).
git commit -m "feat(adminui): alias DTO + Galaxy gateway lookup + Source/IsAlias on tag rows"
Task 5: Service write-side — Create/Update alias + Galaxy guard
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (serialize after Task 4)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs
Step 1: Write failing tests (append). Assert:
CreateAliasTagAsync(eq, input)writes aTagwithDriverInstanceId= gateway,EquipmentId = eq,FolderPath = null,TagConfig == {"FullName":"<input.FullName>"},AccessLevel == input.AccessLevel.- Empty/whitespace
FullName→Ok == false(error mentions reference). - Binding a non-Galaxy driver via the alias path →
Ok == false("not a Galaxy gateway"). - Cross-cluster gateway →
Ok == false(cluster mismatch). - Duplicate
Nameon the equipment (vs an existing tag) →Ok == false. UpdateAliasTagAsyncupdatesName/DataType/AccessLevel/FullNameand bumps via RowVersion path.
Step 2: Run → FAIL (methods are interface stubs from Task 4; implement now).
Step 3: Implement a Galaxy-aware guard + the two methods (model on CreateTagAsync :744 / UpdateTagAsync :794, but assemble TagConfig from FullName and use the alias guard instead of CheckTagDriverGuardAsync):
private static async Task<UnsMutationResult?> CheckAliasDriverGuardAsync(
OtOpcUaConfigDbContext db, string driverInstanceId, string? equipmentCluster, CancellationToken ct)
{
var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
if (driver is null) return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
if (driver.DriverType != "GalaxyMxGateway")
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not a Galaxy gateway.");
if (driver.ClusterId != equipmentCluster)
return new UnsMutationResult(false,
$"Galaxy gateway '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}'.");
return null;
}
private static string BuildAliasTagConfig(string fullName) =>
System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, string> { ["FullName"] = fullName });
CreateAliasTagAsync: validate FullName non-empty (return new UnsMutationResult(false, "Alias is missing a Galaxy reference.")), resolve cluster, run CheckAliasDriverGuardAsync, reuse the equipment signal-name uniqueness check, then db.Tags.Add(new Tag { …, FolderPath = null, AccessLevel = input.AccessLevel, TagConfig = BuildAliasTagConfig(input.FullName) }). Factor the entity build into a private BuildAliasTag(equipmentId, input) — Task 7's converter reuses it.
Step 4: Run → PASS; full UnsTreeService* suite → green.
Step 5: Commit
git commit -m "feat(adminui): CreateAliasTagAsync/UpdateAliasTagAsync + Galaxy-gateway guard"
Task 6: AliasTagModal + Tags-tab "Add alias" wiring
Classification: standard
Estimated implement time: ~5 min
Parallelizable with: none (serialize after Task 5; before Task 8 on EquipmentPage.razor)
No bUnit — proven live in Task 10. Keep markup faithful to existing modals.
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AliasTagModal.razor - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor
Step 1: Build AliasTagModal.razor — copy the shell + _loadedKey guard + param block from ScriptedAlarmModal.razor. Params: Visible, IsNew, EquipmentId, Existing (a small AliasTagEditDto — add it to IUnsTreeService load path if editing is in scope, else reuse TagEditDto/LoadTagAsync and pre-split TagConfig.FullName), Gateways (IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)> from LoadGalaxyGatewaysForEquipmentAsync), OnSaved, OnCancel. Body:
- A gateway
<select>(auto-selected when exactly one). - The Galaxy picker: host
<DriverTagPicker>wrapping<GalaxyAddressPickerBody CurrentAddress="_form.FullName" CurrentAddressChanged="v => _form.FullName = v" GetConfigJson="() => _selectedGatewayConfig" />(mirrorGalaxyDriverPage.razor:60)._selectedGatewayConfig= the selected gateway'sDriverConfig. Name(InputText),DataType(<select>over the same OPC type listTagModaluses — reuse that list source),AccessLevel(<select>overTagAccessLevel, defaultReadOnly).- Save →
Svc.CreateAliasTagAsync/Svc.UpdateAliasTagAsync; surfaceresult.Error; onOkraiseOnSaved.
Step 2: Wire the Tags tab in EquipmentPage.razor:
- Beside Add tag (
:142) add:<button class="btn btn-outline-primary btn-sm" @onclick="OpenAddAlias" disabled="@(_gateways.Count == 0)">Add alias (browse Galaxy)</button>with a hint span when_gateways.Count == 0("no Galaxy gateway in this cluster"). - In the tags table, render the alias badge + Source column: when
t.IsAlias, show a<span class="badge">alias</span>and@t.Source; Edit routes alias rows toOpenEditAlias, native rows to the existingOpenEditTag. - Add
<AliasTagModal Visible=… EquipmentId=@EquipmentId Gateways=_gateways OnSaved=OnAliasSaved OnCancel=… />next to<TagModal …>. - Handlers: load
_gatewaysviaSvc.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId!)insideReloadTagsAsync(:339);OpenAddAlias/OpenEditAlias/OnAliasSavedmirror the tag handlers (ReloadTagsAsyncafter save/delete).
Step 3: dotnet build the AdminUI project → 0 errors. (Behaviour verified live in Task 10.)
Step 4: Commit
git commit -m "feat(adminui): AliasTagModal + Tags-tab Add-alias (Galaxy picker)"
Task 7: Converter service method
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (serialize after Task 5; needs Task 1)
Atomic config mutation (deletes VirtualTag/Script rows, inserts Tag rows). Correctness-critical.
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RelayConversion.cs(request + result DTOs) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceRelayConverterTests.cs(new)
Step 1: DTOs (RelayConversion.cs):
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>One relay VirtualTag that will/would become an alias Tag.</summary>
public sealed record RelayConversionItem(string EquipmentId, string VirtualTagName, string FullName, string DataType);
/// <summary>One relay VirtualTag that cannot be converted, with the reason.</summary>
public sealed record RelayConversionSkip(string EquipmentId, string VirtualTagName, string Reason);
/// <summary>Outcome of a (dry-run or applied) conversion pass.</summary>
public sealed record RelayConversionResult(
IReadOnlyList<RelayConversionItem> Converted,
IReadOnlyList<RelayConversionSkip> Skipped,
bool Applied);
Step 2: Interface (IUnsTreeService.cs):
/// <summary>Convert pure-relay VirtualTags to Galaxy alias Tags. equipmentId = null → fleet-wide.
/// dryRun = true previews without mutating. FleetAdmin-gated at the call site.</summary>
Task<RelayConversionResult> ConvertRelayVirtualTagsToAliasesAsync(
string? equipmentId, bool dryRun, CancellationToken ct = default);
Step 3: Write failing tests (UnsTreeServiceRelayConverterTests.cs, use UnsTreeTestDb). Cases:
- Exact relay converted — vtag
speed-rpmbound to scriptreturn ctx.GetTag("TestMachine_020.TestChangingInt").Value;on an equipment whose cluster has the gateway → apply: aTagexists (Name=speed-rpm,TagConfig={"FullName":"TestMachine_020.TestChangingInt"},AccessLevel=ReadOnly, DataType carried), and the VirtualTag + its orphan Script are gone. Result lists it underConverted. - Non-relay untouched — vtag whose script does arithmetic →
Skipped(reason "not a pure relay"); vtag + script remain. - Shared script kept then deleted — one Script referenced by two relay vtags: converting the first keeps the Script (still referenced); converting both deletes it.
- Historize=true — relay vtag with
Historize=true→Skipped(reason mentions historize); unchanged. - No gateway — equipment whose cluster has no
GalaxyMxGateway→Skipped(reason "no Galaxy gateway"); unchanged. - Dry-run no mutation — case 1 with
dryRun=true→ sameConvertedlist but the VirtualTag/Script still present and no Tag added. - Fleet vs per-equipment scope —
equipmentId=nullsweeps all; a specific id limits to that equipment.
Step 4: Run --filter RelayConverter → FAIL.
Step 5: Implement ConvertRelayVirtualTagsToAliasesAsync:
- One
dbcontext; gather candidate VirtualTags (Where(EquipmentId == id)or all), join theirScript.SourceCode. - Per vtag:
EquipmentScriptPaths.TryParseRelayBody(source, out var rawRef)→ non-relay ⇒ skip ("not a pure relay"). - Resolve the equipment's cluster gateway (reuse
LoadGalaxyGatewaysForEquipmentAsynclogic /ResolveEquipmentClusterAsync); none ⇒ skip ("no Galaxy gateway in cluster"). Historize == true⇒ skip ("historized — Tag has no historize column; convert manually").{{equip}}expansion: ifEquipmentScriptPaths.ContainsEquipToken(rawRef), derive the equipment's base from its existing non-relay Tag FullNames (DeriveEquipmentBaseoverdb.Tags.Where(EquipmentId==eq && driver==GalaxyMxGateway).Select(TagConfig.FullName)plus any Equipment-kind driver tag FullNames); if base is null ⇒ skip ("equipment-relative token with no derivable base"); elsefullName = SubstituteEquipmentToken("ctx.GetTag(\"" + rawRef + "\")", base)-extracted concrete ref (or applySubstituteEquipmentTokentorawRefdirectly via a small wrapper). OtherwisefullName = rawRef.- If
dryRun: add toConverted, mutate nothing. - Else:
db.Tags.Add(BuildAliasTag(eq, new AliasTagInput(NewTagId(), vtag.Name, gatewayId, vtag.DataType, TagAccessLevel.ReadOnly, fullName)))(reuse Task 5'sBuildAliasTag);db.VirtualTags.Remove(vtag); decrement the Script's live reference count (count of VirtualTags + ScriptedAlarms still referencingScriptIdafter this batch's removals) anddb.Scripts.Remove(script)only when it reaches zero. - One
await db.SaveChangesAsync(ct)at the end for atomicity (all-or-nothing). ReturnRelayConversionResult(converted, skipped, Applied: !dryRun). - Name-collision safety: the delete-vtag + add-tag for the same
(EquipmentId, Name)happen in one SaveChanges, so the equipment signal-name collision check is satisfied (no generation where both exist).
Step 6: Run --filter RelayConverter → PASS; full UnsTreeService* → green.
Step 7: Commit (stage the four files).
git commit -m "feat(adminui): ConvertRelayVirtualTagsToAliasesAsync (relay VTag -> alias Tag)"
Task 8: Converter UI (per-equipment + fleet-wide)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (serialize after Task 6; needs Task 7)
No bUnit — proven live in Task 10.
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor - Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/RelayAliasConvert.razor - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor(nav entry)
Step 1: Per-equipment action — on the Tags tab add a Convert relay virtual-tags to aliases… button (shown when the equipment has any virtual tags). It calls ConvertRelayVirtualTagsToAliasesAsync(EquipmentId, dryRun:true), shows a preview panel (Converted list + Skipped-with-reason list), and a Apply button that re-calls with dryRun:false then ReloadTagsAsync + ReloadVirtualTagsAsync.
Step 2: Fleet-wide page RelayAliasConvert.razor:
@page "/uns/convert-relays"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")]
@rendermode RenderMode.InteractiveServer
@inject IUnsTreeService Svc
A Preview button → ConvertRelayVirtualTagsToAliasesAsync(null, dryRun:true) → table of Converted (Equipment, Name, FullName) + Skipped (Equipment, Name, Reason) + counts; an Apply conversion button (confirmation) → dryRun:false → re-preview to show the now-empty Converted set. Reuse the panel markup from Step 1 (a small shared <RelayConversionPreview> partial is optional; inline is fine).
Step 3: Nav — add a RelayAliasConvert link under the existing UNS/maintenance nav group in MainLayout.razor (FleetAdmin-gated link, mirror how RoleGrants/maintenance links render).
Step 4: dotnet build AdminUI → 0 errors.
Step 5: Commit
git commit -m "feat(adminui): relay->alias converter UI (per-equipment + fleet-wide page)"
Task 9: Docs
Classification: small Estimated implement time: ~4 min Parallelizable with: any task (docs-only)
Files:
- Modify:
docs/Uns.md - Modify:
CLAUDE.md
Step 1: docs/Uns.md — add an "Alias tags" subsection under the equipment Tags tab: what an alias is (a Galaxy attribute surfaced under a UNS name via a direct driver subscription, no script), how to add one (Tags tab → Add alias (browse Galaxy)), AccessLevel/write-through note, and the converter (per-equipment action + /uns/convert-relays fleet sweep, dry-run then apply; skip reasons).
Step 2: CLAUDE.md — a short note near the "Contained Name vs Tag Name" / Galaxy section: alias tags = GalaxyMxGateway-backed equipment Tags carrying TagConfig.FullName; they replace pure-relay virtual tags; converter at /uns/convert-relays.
Step 3: Commit
git add docs/Uns.md CLAUDE.md
git commit -m "docs: alias tags + relay converter (Uns.md, CLAUDE.md)"
Task 10: Full-suite gate + live /run verify + finish
Classification: verification Estimated implement time: ~6 min (+ user-driven live) Parallelizable with: none (after all)
Step 1: dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 errors. dotnet test ZB.MOM.WW.OtOpcUa.slnx → green (call out any pre-existing unrelated failures).
Step 2: Live docker-dev /run (user drives; agent does not sign in). Rebuild: docker compose -f docker-dev/docker-compose.yml up -d --build central-1 central-2 (no -v; migrator no-ops — no schema change), UI at http://localhost:9200. Checklist:
- On
blender-20(/uns/equipment/EQ-89e6583eeb9c) Tags tab → Add alias → Galaxy picker browses the gateway → pick an attribute → save → alias row shows the badge +galaxy:<FullName>. - Publish/deploy → read the alias live via Client.CLI (
browse/read) — value + quality + timestamp present. - Set an alias
AccessLevelwritable → write via Client.CLI → the Galaxy attribute changes; a read-only alias rejects the write. - Per-equipment converter dry-run on
blender-20→ preview lists the 24 relays → Apply → the 24 virtual tags become alias Tags (Tags tab), Virtual Tags tab emptied of them, values still live, no new script-log entries for them. - Fleet-wide
/uns/convert-relays→ preview → (optionally) apply.
Step 3: Finish — use superpowers-extended-cc:finishing-a-development-branch (verify tests, present the 4 options; carry the session's standing intent toward merge-to-master + push, but confirm given the user-driven live step). Do not stage pending.md, sql_login.txt, or pki/.
Done gate: build clean + dotnet test green + live /run pass.