docs(plan): Galaxy alias tag implementation plan (T0–T10)
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.
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
# 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-381` `equipmentTags` = `EquipmentId is not null` AND driver namespace `Kind == 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-526` `BuildEquipmentTagPlans` mirrors it (builds `equipmentNamespaces` + `driverToNamespace`, requires `equipmentNamespaces.Contains(nsId)`). Byte-parity with the composer. The artifact's `DriverInstances` array carries `DriverType` (`DriverInstancePlan(DriverInstanceId, DriverType, ConfigJson)`).
|
||||
- **Validator** — `DraftValidator.cs`; `DraftSnapshot` exposes full `Tags` (with `TagConfig`) + `DriverInstances` (with `DriverType`). `ValidateDriverNamespaceCompatibility` (line 192) checks a driver's *home* namespace and is unaffected. No tag-level namespace-kind invariant in `sp_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.cs` `EquipmentTagRow(TagId, Name, DriverInstanceId, DataType, AccessLevel)`; `TagInput.cs`.
|
||||
- **Modal sibling** — `Components/Shared/Uns/ScriptedAlarmModal.razor` (the `_loadedKey` guard + `Visible/IsNew/EquipmentId/Existing/OnSaved/OnCancel` param shape to copy).
|
||||
- **Galaxy picker** — `Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor` params: `CurrentAddress` + `CurrentAddressChanged` + `GetConfigJson : Func<string>` (the gateway `DriverConfig`, fed to `BrowserService.OpenAsync("GalaxyMxGateway", configJson, ct)`). Hosted inside `DriverTagPicker` (see `GalaxyDriverPage.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`:
|
||||
```bash
|
||||
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`):
|
||||
```csharp
|
||||
[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`:
|
||||
```csharp
|
||||
// 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**
|
||||
```bash
|
||||
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 (`EquipmentId` set) bound to a `GalaxyMxGateway` driver whose namespace is `SystemPlatform`, with `TagConfig={"FullName":"TestMachine_020.TestChangingInt"}`, now appears in the composed **EquipmentTags** with `FullName == "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`:
|
||||
```csharp
|
||||
.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):
|
||||
```csharp
|
||||
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):
|
||||
```csharp
|
||||
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**
|
||||
```bash
|
||||
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:
|
||||
```csharp
|
||||
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**
|
||||
```bash
|
||||
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` (extend `EquipmentTagRow`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (`LoadTagsForEquipmentAsync` :86 + new `LoadGalaxyGatewaysForEquipmentAsync`)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs` (new)
|
||||
|
||||
**Step 1: Add the DTO** `AliasTagInput.cs`:
|
||||
```csharp
|
||||
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):
|
||||
```csharp
|
||||
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.
|
||||
- `LoadTagsForEquipmentAsync` marks a Galaxy-gateway-bound tag with `IsAlias == true` and `Source == "galaxy:<FullName>"`, and a normal driver tag with `IsAlias == false`.
|
||||
|
||||
**Step 4: Implement.**
|
||||
- Interface additions (`IUnsTreeService.cs`):
|
||||
```csharp
|
||||
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 on `LoadTagDriversForEquipmentAsync` :714 but filter `d.DriverType == "GalaxyMxGateway"` in the equipment's cluster; select `DriverInstanceId, Name, DriverConfig`).
|
||||
- `LoadTagsForEquipmentAsync` (:86) — to set `IsAlias`/`Source`, the projection needs each tag's driver type + `TagConfig`. Join `db.Tags` to `db.DriverInstances` on `DriverInstanceId`; in a post-query `.Select`, set `IsAlias = driverType == "GalaxyMxGateway"`, and `Source = IsAlias ? $"galaxy:{ExtractFullName(TagConfig)}" : null`. Pull the rows first (`AsNoTracking().Where(eq).Join(...)`), then map in memory so you can parse `TagConfig` with the same small `FullName` reader used in Task 3 (add a private `ExtractTagConfigFullName` to `UnsTreeService` or 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 a `Tag` with `DriverInstanceId` = 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 `Name` on the equipment (vs an existing tag) → `Ok == false`.
|
||||
- `UpdateAliasTagAsync` updates `Name`/`DataType`/`AccessLevel`/`FullName` and 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`):
|
||||
```csharp
|
||||
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" />` (mirror `GalaxyDriverPage.razor:60`). `_selectedGatewayConfig` = the selected gateway's `DriverConfig`.
|
||||
- `Name` (InputText), `DataType` (`<select>` over the same OPC type list `TagModal` uses — reuse that list source), `AccessLevel` (`<select>` over `TagAccessLevel`, default `ReadOnly`).
|
||||
- Save → `Svc.CreateAliasTagAsync` / `Svc.UpdateAliasTagAsync`; surface `result.Error`; on `Ok` raise `OnSaved`.
|
||||
|
||||
**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 to `OpenEditAlias`, native rows to the existing `OpenEditTag`.
|
||||
- Add `<AliasTagModal Visible=… EquipmentId=@EquipmentId Gateways=_gateways OnSaved=OnAliasSaved OnCancel=… />` next to `<TagModal …>`.
|
||||
- Handlers: load `_gateways` via `Svc.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId!)` inside `ReloadTagsAsync` (`:339`); `OpenAddAlias`/`OpenEditAlias`/`OnAliasSaved` mirror the tag handlers (`ReloadTagsAsync` after 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`):
|
||||
```csharp
|
||||
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`):
|
||||
```csharp
|
||||
/// <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:
|
||||
1. **Exact relay converted** — vtag `speed-rpm` bound to script `return ctx.GetTag("TestMachine_020.TestChangingInt").Value;` on an equipment whose cluster has the gateway → apply: a `Tag` exists (`Name=speed-rpm`, `TagConfig={"FullName":"TestMachine_020.TestChangingInt"}`, `AccessLevel=ReadOnly`, DataType carried), and the VirtualTag + its orphan Script are gone. Result lists it under `Converted`.
|
||||
2. **Non-relay untouched** — vtag whose script does arithmetic → `Skipped` (reason "not a pure relay"); vtag + script remain.
|
||||
3. **Shared script kept then deleted** — one Script referenced by two relay vtags: converting the first keeps the Script (still referenced); converting both deletes it.
|
||||
4. **Historize=true** — relay vtag with `Historize=true` → `Skipped` (reason mentions historize); unchanged.
|
||||
5. **No gateway** — equipment whose cluster has no `GalaxyMxGateway` → `Skipped` (reason "no Galaxy gateway"); unchanged.
|
||||
6. **Dry-run no mutation** — case 1 with `dryRun=true` → same `Converted` list but the VirtualTag/Script still present and no Tag added.
|
||||
7. **Fleet vs per-equipment scope** — `equipmentId=null` sweeps all; a specific id limits to that equipment.
|
||||
|
||||
**Step 4:** Run `--filter RelayConverter` → FAIL.
|
||||
|
||||
**Step 5: Implement** `ConvertRelayVirtualTagsToAliasesAsync`:
|
||||
- One `db` context; gather candidate VirtualTags (`Where(EquipmentId == id)` or all), join their `Script.SourceCode`.
|
||||
- Per vtag: `EquipmentScriptPaths.TryParseRelayBody(source, out var rawRef)` → non-relay ⇒ skip ("not a pure relay").
|
||||
- Resolve the equipment's cluster gateway (reuse `LoadGalaxyGatewaysForEquipmentAsync` logic / `ResolveEquipmentClusterAsync`); none ⇒ skip ("no Galaxy gateway in cluster").
|
||||
- `Historize == true` ⇒ skip ("historized — Tag has no historize column; convert manually").
|
||||
- **`{{equip}}` expansion:** if `EquipmentScriptPaths.ContainsEquipToken(rawRef)`, derive the equipment's base from its existing **non-relay** Tag FullNames (`DeriveEquipmentBase` over `db.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"); else `fullName = SubstituteEquipmentToken("ctx.GetTag(\"" + rawRef + "\")", base)`-extracted concrete ref (or apply `SubstituteEquipmentToken` to `rawRef` directly via a small wrapper). Otherwise `fullName = rawRef`.
|
||||
- If `dryRun`: add to `Converted`, mutate nothing.
|
||||
- Else: `db.Tags.Add(BuildAliasTag(eq, new AliasTagInput(NewTagId(), vtag.Name, gatewayId, vtag.DataType, TagAccessLevel.ReadOnly, fullName)))` (reuse Task 5's `BuildAliasTag`); `db.VirtualTags.Remove(vtag)`; decrement the Script's live reference count (count of VirtualTags + ScriptedAlarms still referencing `ScriptId` *after* this batch's removals) and `db.Scripts.Remove(script)` only when it reaches zero.
|
||||
- One `await db.SaveChangesAsync(ct)` at the end for atomicity (all-or-nothing). Return `RelayConversionResult(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`:
|
||||
```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 `Tag`s 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:
|
||||
1. 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>`.
|
||||
2. Publish/deploy → read the alias live via Client.CLI (`browse`/`read`) — value + quality + timestamp present.
|
||||
3. Set an alias `AccessLevel` writable → write via Client.CLI → the Galaxy attribute changes; a read-only alias rejects the write.
|
||||
4. 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.
|
||||
5. 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.
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-11-alias-tag.md",
|
||||
"designPath": "docs/plans/2026-06-11-alias-tag-design.md",
|
||||
"branch": "feat/uns-alias-tag",
|
||||
"baseBranch": "master",
|
||||
"baseSha": "305023aa",
|
||||
"status": "pending",
|
||||
"note": "Galaxy alias tag: an equipment-scoped Tag bound to GalaxyMxGateway surfacing a Galaxy attribute under a UNS name via direct subscription, replacing relay VirtualTags. Approach A reuses the Tag entity; NO entity/EF migration. Same-file contention: Phase7Composer.cs+DeploymentArtifact.cs in T2 only; EquipmentScriptPaths.cs in T1 only; DraftValidator.cs+Tag.cs in T3 only; IUnsTreeService.cs+UnsTreeService.cs serialize T4->T5->T7; EquipmentPage.razor serializes T6->T8. T1/T2/T3 parallel after T0. BuildAliasTag (T5) reused by the converter (T7). EquipmentTagRow gains DEFAULTED IsAlias/Source so existing constructions compile.",
|
||||
"tasks": [
|
||||
{ "id": 285, "planTask": 0, "subject": "T0: Create feature branch", "classification": "trivial", "status": "pending", "blockedBy": [] },
|
||||
{ "id": 286, "planTask": 1, "subject": "T1: Commons TryParseRelayBody + tests", "classification": "small", "status": "pending", "blockedBy": [285], "parallelizableWith": [287, 288] },
|
||||
{ "id": 287, "planTask": 2, "subject": "T2: Broaden equipment-tag filter (composer + artifact)", "classification": "high-risk", "status": "pending", "blockedBy": [285], "parallelizableWith": [286, 288] },
|
||||
{ "id": 288, "planTask": 3, "subject": "T3: DraftValidator alias-reference check + Tag doc", "classification": "standard", "status": "pending", "blockedBy": [285], "parallelizableWith": [286, 287] },
|
||||
{ "id": 289, "planTask": 4, "subject": "T4: Service read-side (DTO, gateway lookup, Source)", "classification": "standard", "status": "pending", "blockedBy": [285] },
|
||||
{ "id": 290, "planTask": 5, "subject": "T5: Service write-side (Create/Update alias + guard)", "classification": "standard", "status": "pending", "blockedBy": [289] },
|
||||
{ "id": 291, "planTask": 6, "subject": "T6: AliasTagModal + Tags-tab Add-alias wiring", "classification": "standard", "status": "pending", "blockedBy": [290] },
|
||||
{ "id": 292, "planTask": 7, "subject": "T7: Converter service method + DTOs + tests", "classification": "high-risk", "status": "pending", "blockedBy": [286, 290] },
|
||||
{ "id": 293, "planTask": 8, "subject": "T8: Converter UI (per-equipment + fleet-wide)", "classification": "standard", "status": "pending", "blockedBy": [291, 292] },
|
||||
{ "id": 294, "planTask": 9, "subject": "T9: Docs (Uns.md, CLAUDE.md)", "classification": "small", "status": "pending", "blockedBy": [] },
|
||||
{ "id": 295, "planTask": 10, "subject": "T10: Full-suite gate + live /run + finish", "classification": "verification", "status": "pending", "blockedBy": [287, 288, 291, 292, 293, 294] }
|
||||
],
|
||||
"lastUpdated": "2026-06-11"
|
||||
}
|
||||
Reference in New Issue
Block a user