diff --git a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md new file mode 100644 index 00000000..a08bbe5b --- /dev/null +++ b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md @@ -0,0 +1,1091 @@ +# Universal Discovery Browser (Wave 0) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Implement the universal Discover-backed browser (`DiscoveryDriverBrowser`) so the AdminUI `/uns` address picker can browse any driver whose `DiscoverAsync` enumerates from the device — lighting up AbCip, TwinCAT, and FOCAS pickers with zero per-driver browser code. + +**Architecture:** A `CapturingAddressSpaceBuilder` records what a driver streams into `ITagDiscovery.DiscoverAsync`; a `CapturedTreeBrowseSession` serves that tree through the existing `IBrowseSession` picker plumbing; a `DiscoveryDriverBrowser` (separate `IUniversalDriverBrowser`, NOT one of the injected `IDriverBrowser` set — the `ToDictionary` dup-key constraint) orchestrates construct→patch→connect→capture→bounded-shutdown with capture coalescing and a global concurrency cap. `BrowserSessionService` falls back to it when no bespoke browser matches. + +**Tech Stack:** .NET 10, xUnit + Shouldly, Blazor Server (AdminUI), no new NuGet packages (only `Microsoft.Extensions.Logging.Abstractions` on Commons if not already transitive). + +**Spec:** `docs/plans/2026-07-15-universal-discovery-browser-design.md` (the authoritative design — read it FIRST). Umbrella: `docs/plans/2026-07-15-driver-expansion-program-design.md` §3.1 (cross-cutting rules) + §4. + +--- + +## Orchestration rules for the executing agent (READ BEFORE DISPATCHING) + +1. **Work on a feature branch**: `feat/universal-discovery-browser` (Task 0 creates it). Never commit to master. +2. **Safe parallelization.** Tasks marked `Parallelizable with:` have **disjoint `Files:` lists** and may be dispatched as concurrent subagents. Hard rules: + - **Parallel subagents MUST NOT run any git command** (no add/commit/status). Parallel `git index` access races and corrupts the index (proven in this repo — see the "parallel implementers race the git index" memory). The orchestrator commits each batch's files **sequentially, itself**, after all subagents in the batch return. + - A parallel subagent edits **only** the files in its task's `Files:` list. If it believes it needs another file, that is a plan defect — it must return the finding, not expand scope. + - Never dispatch two tasks concurrently that share a file, even "read-mostly" ones like a `.csproj`. +3. **Batch order** (each batch waits for the previous; tasks inside a batch run in parallel): + - Batch A: Task 1, Task 2, Task 13 + - Batch B: Task 3, Task 4, Task 5 + - Batch C: Task 6 → then Task 7 (sequential — same area) + - Batch D: Task 8 + - Batch E: Task 9, Task 11 + - Batch F: Task 10, Task 12 + - Batch G: Task 14, Task 15, Task 16 + - Batch H: Task 17 → Task 18 (sequential) +4. **Verification gates:** run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` after every batch; run the named test project after every test-bearing task. Batch H's Task 18 is a **live `/run` gate** — AdminUI Razor changes are NOT trusted on unit tests + review alone (repo rule: no bUnit; live-verify or it didn't happen). +5. **TDD:** every code task writes the failing test first, sees it fail, implements, sees it pass. Test projects: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests` (builder/session/browser), `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` (service fallback), `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests` (interface default). +6. Single-test command shape: `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter "FullyQualifiedName~"`. + +--- + +### Task 0: Branch setup + +**Classification:** trivial +**Estimated implement time:** ~1 min +**Parallelizable with:** none (must run first) + +**Step 1:** `git checkout -b feat/universal-discovery-browser` (from up-to-date master). + +--- + +### Task 1: `ITagDiscovery.SupportsOnlineDiscovery` default member + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 2, Task 13 + +**Files:** +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs` +- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagDiscoveryDefaultsTests.cs` (create) + +**Step 1: Write the failing test** + +```csharp +using Shouldly; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; + +public class TagDiscoveryDefaultsTests +{ + private sealed class MinimalDiscovery : ITagDiscovery + { + public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask; + } + + [Fact] + public void SupportsOnlineDiscovery_DefaultsToFalse() => + ((ITagDiscovery)new MinimalDiscovery()).SupportsOnlineDiscovery.ShouldBeFalse(); + + [Fact] + public void RediscoverPolicy_DefaultRemainsUntilStable() => + ((ITagDiscovery)new MinimalDiscovery()).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable); +} +``` + +**Step 2: Run it — expect FAIL** (`SupportsOnlineDiscovery` doesn't compile yet): +`dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~TagDiscoveryDefaultsTests"` + +**Step 3: Implement** — add to `ITagDiscovery` directly below the `RediscoverPolicy` member (`ITagDiscovery.cs:32`): + +```csharp + /// + /// True when enumerates the tag set from the live backend + /// (browsable by the universal discovery browser), rather than replaying + /// pre-declared/authored tags. Default false — flat-address drivers stay manual-entry. + /// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5. + /// + bool SupportsOnlineDiscovery => false; +``` + +**Step 4: Run test — expect PASS.** + +**Step 5:** (Orchestrator commits after Batch A.) + +--- + +### Task 2: Commons project references + +**Classification:** trivial +**Estimated implement time:** ~2 min +**Parallelizable with:** Task 1, Task 13 + +**Files:** +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj` + +**Step 1:** Add to the existing `` (Commons currently has NO project references — `Core.Abstractions` is a leaf with zero ProjectReferences, so this is cycle-free; verified 2026-07-15): + +```xml + + + +``` + +Also add `` **only if** `dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Commons` fails on `ILogger<>` in Task 8 (Akka may already flow it transitively; the version is centrally pinned in `Directory.Packages.props` — do NOT add a `Version=` attribute). + +**Step 2:** `dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Commons` — expect success. + +--- + +### Task 3: AbCip opt-in (`SupportsOnlineDiscovery => true`) + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 4, Task 5 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` +- Test: the existing AbCip unit-test project under `tests/Drivers/` (add one fact to an existing test class, or a new `AbCipDiscoveryGateTests.cs` following that project's driver-construction pattern) + +**Step 1: Failing test** — construct the driver the way the project's existing tests do (fakes, no network) and assert: + +```csharp +((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeTrue(); +``` + +**Step 2:** Run → FAIL (default false). **Step 3:** In `AbCipDriver`, next to its existing `RediscoverPolicy` override (or with the other ITagDiscovery members), add: + +```csharp + /// Controller enumeration is real (CIP Symbol Object walk) — config-gated on + /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time. + public bool SupportsOnlineDiscovery => true; +``` + +**Step 4:** Run → PASS. Also run the full AbCip unit suite for the project you touched. + +--- + +### Task 4: TwinCAT opt-in + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 3, Task 5 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` +- Test: existing TwinCAT unit-test project under `tests/Drivers/` + +Same shape as Task 3 (ADS symbol upload behind `EnableControllerBrowse`; patch guarantees it). + +--- + +### Task 5: FOCAS opt-in + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 3, Task 4 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` +- Test: existing FOCAS unit-test project under `tests/Drivers/` + +Same shape as Task 3. Comment must note FOCAS is the `UntilStable` driver — its FixedTree fills post-connect; the universal browser's settle loop (Task 9) handles it; the `FixedTree.Enabled` patch turns it on. + +--- + +### Task 6: `CapturingAddressSpaceBuilder` + captured-tree model + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (Batch C) + +**Files:** +- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs` +- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs` +- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs` (create) + +**Step 1: Failing tests** — a fake `ITagDiscovery` streams a known graph; assert tree shape, ids, cap, alarm mark: + +```csharp +using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing; + +public class CapturingAddressSpaceBuilderTests +{ + private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) => + new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm); + + [Fact] + public void CapturesNestedFoldersAndLeaves() + { + var b = new CapturingAddressSpaceBuilder(nodeCap: 100); + var plc = b.Folder("Plc1", "PLC 1"); + plc.Variable("Speed", "Speed", Attr("Program:Main.Speed")); + var udt = plc.Folder("Motor", "Motor"); + udt.Variable("Amps", "Amps", Attr("Program:Main.Motor.Amps")); + + var tree = b.Build(); + tree.Truncated.ShouldBeFalse(); + tree.Root.Children.Count.ShouldBe(1); + var plcNode = tree.Root.Children[0]; + plcNode.Id.ShouldBe("/Plc1"); + plcNode.IsLeaf.ShouldBeFalse(); + plcNode.Children.Select(c => c.Id).ShouldBe(new[] { "Program:Main.Speed", "/Plc1/Motor" }); + tree.ById["Program:Main.Motor.Amps"].Attribute!.FullName.ShouldBe("Program:Main.Motor.Amps"); + } + + [Fact] + public void LeafNodeIdIsTheDriverFullName() + { + var b = new CapturingAddressSpaceBuilder(100); + var handle = b.Variable("T", "T", Attr("Device.Tag1")); + handle.FullReference.ShouldBe("Device.Tag1"); + b.Build().ById["Device.Tag1"].IsLeaf.ShouldBeTrue(); + } + + [Fact] + public void NodeCapTruncatesInsteadOfGrowing() + { + var b = new CapturingAddressSpaceBuilder(nodeCap: 3); + for (var i = 0; i < 10; i++) b.Variable($"T{i}", $"T{i}", Attr($"Dev.T{i}")); + var tree = b.Build(); + tree.Truncated.ShouldBeTrue(); + tree.Count.ShouldBe(3); + } + + [Fact] + public void MarkAsAlarmConditionRecordsAlarmAndReturnsNoopSink() + { + var b = new CapturingAddressSpaceBuilder(100); + var h = b.Variable("A", "A", Attr("Dev.Alarm1")); + var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("A", AlarmSeverity.High, null)); + sink.ShouldNotBeNull(); + Should.NotThrow(() => sink.OnTransition(null!)); // no-op — browse never delivers transitions + b.Build().ById["Dev.Alarm1"].IsAlarmMarked.ShouldBeTrue(); + } + + [Fact] + public void AddPropertyAttachesToCurrentScope() + { + var b = new CapturingAddressSpaceBuilder(100); + var f = b.Folder("Dev", "Dev"); + f.AddProperty("Model", DriverDataType.String, "1756-L83E"); + b.Build().ById["/Dev"].Properties.ShouldContain(p => p.Name == "Model" && (string?)p.Value == "1756-L83E"); + } +} +``` + +(If `AlarmSeverity.High` doesn't exist, use any real member of `AlarmSeverity` — check `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`.) + +**Step 2:** Run → FAIL (types don't exist). + +**Step 3: Implement.** `CapturedTree.cs`: + +```csharp +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// One node captured from a driver's DiscoverAsync stream. Folder ids are +/// path-composed ("/parent/child"); leaf ids are the driver FullName (the commit value). +public sealed class CapturedNode +{ + public required string Id { get; init; } + public required string Name { get; init; } // browse name + public required string DisplayName { get; init; } + public required bool IsLeaf { get; init; } + public DriverAttributeInfo? Attribute { get; init; } // leaf only + public bool IsAlarmMarked { get; set; } + public List Children { get; } = []; + public List<(string Name, DriverDataType Type, object? Value)> Properties { get; } = []; +} + +/// Immutable-after-Build snapshot of a whole discovery capture. May be shared by +/// multiple coalesced browse sessions — sessions drop their reference on dispose, never mutate. +public sealed class CapturedTree +{ + public required CapturedNode Root { get; init; } + public required bool Truncated { get; init; } + public required int Count { get; init; } + public required IReadOnlyDictionary ById { get; init; } +} +``` + +`CapturingAddressSpaceBuilder.cs`: + +```csharp +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// +/// In-memory that records the streamed discovery tree +/// for the universal browser instead of materializing OPC UA nodes. Single-threaded like +/// every builder a driver streams into during DiscoverAsync. Enforces a node cap: once +/// exceeded it stops recording (drivers keep streaming harmlessly) and marks the tree +/// truncated. See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.1. +/// +public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder +{ + private sealed class CaptureState(int nodeCap) + { + public int Count; + public bool Truncated; + public readonly int NodeCap = nodeCap; + public readonly Dictionary ById = new(StringComparer.Ordinal); + public bool TryReserve() + { + if (Count >= NodeCap) { Truncated = true; return false; } + Count++; + return true; + } + } + + private readonly CaptureState _state; + private readonly CapturedNode _scope; + + public CapturingAddressSpaceBuilder(int nodeCap = 50_000) + { + _state = new CaptureState(nodeCap); + _scope = new CapturedNode { Id = "", Name = "", DisplayName = "", IsLeaf = false }; + } + + private CapturingAddressSpaceBuilder(CaptureState state, CapturedNode scope) + { + _state = state; + _scope = scope; + } + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { + var node = new CapturedNode + { + Id = _scope.Id + "/" + browseName, + Name = browseName, + DisplayName = displayName, + IsLeaf = false, + }; + if (_state.TryReserve()) + { + _scope.Children.Add(node); + _state.ById.TryAdd(node.Id, node); + } + // Over cap: the child builder writes into a detached node — recording stops, streaming doesn't break. + return new CapturingAddressSpaceBuilder(_state, node); + } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + var node = new CapturedNode + { + Id = attributeInfo.FullName, + Name = browseName, + DisplayName = displayName, + IsLeaf = true, + Attribute = attributeInfo, + }; + if (_state.TryReserve()) + { + _scope.Children.Add(node); + _state.ById.TryAdd(node.Id, node); + } + return new CapturedVariableHandle(node); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) => + _scope.Properties.Add((browseName, dataType, value)); + + public CapturedTree Build() => new() + { + Root = _scope, + Truncated = _state.Truncated, + Count = _state.Count, + ById = _state.ById, + }; + + private sealed class CapturedVariableHandle(CapturedNode node) : IVariableHandle + { + public string FullReference => node.Id; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) + { + node.IsAlarmMarked = true; + return NoopAlarmSink.Instance; + } + } + + private sealed class NoopAlarmSink : IAlarmConditionSink + { + public static readonly NoopAlarmSink Instance = new(); + public void OnTransition(AlarmEventArgs args) { } // browse never delivers live transitions + } +} +``` + +`Build()` is only valid on the **root** builder (the one the browser constructed) — acceptable; the browser owns it. + +**Step 4:** Run the test class → PASS. **Step 5:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx`. + +--- + +### Task 7: `CapturedTreeBrowseSession` + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none (Batch C, after Task 6) + +**Files:** +- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs` +- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs` (create) + +**Step 1: Failing tests:** + +```csharp +public class CapturedTreeBrowseSessionTests +{ + private static CapturedTree SampleTree(bool truncated = false) { /* build via CapturingAddressSpaceBuilder like Task 6, pass truncated via tiny cap when true */ } + + [Fact] public async Task RootReturnsTopLevelNodes() { /* RootAsync → BrowseNode list; folder Kind=Folder w/ HasChildrenHint, leaf Kind=Leaf, leaf NodeId == FullName */ } + [Fact] public async Task ExpandReturnsCapturedChildren() { } + [Fact] public async Task ExpandUnknownNodeReturnsEmpty() { } + [Fact] public async Task AttributesForLeafMapsDriverAttributeInfo() { /* Name/DriverDataType.ToString()/IsArray/SecurityClass.ToString()/IsAlarm (incl. IsAlarmMarked OR attr.IsAlarm) */ } + [Fact] public async Task TruncatedTreeAppendsMarkerFolderAtRoot() { /* last root node: Kind=Folder, HasChildrenHint=false, DisplayName contains "truncated" */ } + [Fact] public async Task CallsRefreshLastUsedUtc() { } + [Fact] public async Task DisposedSessionThrowsObjectDisposed() { } + [Fact] public async Task TwoSessionsOverSameTreeAreIndependent() { /* dispose one; the other still serves — shared immutable tree, §4.3 */ } +} +``` + +**Step 2:** FAIL. **Step 3: Implement:** + +```csharp +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// +/// Serves a captured discovery snapshot through the standard picker session interface. +/// No I/O after open; the tree is point-in-time (picker offers Refresh = close + re-open). +/// The tree may be shared by coalesced sessions — dispose drops only this session's +/// reference. See design §4.3. +/// +public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSession +{ + private CapturedTree? _tree = tree; + + public Guid Token { get; } = Guid.NewGuid(); + public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; + + public Task> RootAsync(CancellationToken ct) + { + var t = Tree(); + var nodes = t.Root.Children.Select(Map).ToList(); + if (t.Truncated) + nodes.Add(new BrowseNode("__truncated__", + "⚠ Results truncated — narrow the driver config or use manual entry", + BrowseNodeKind.Folder, HasChildrenHint: false)); + LastUsedUtc = DateTime.UtcNow; + return Task.FromResult>(nodes); + } + + public Task> ExpandAsync(string nodeId, CancellationToken ct) + { + var t = Tree(); + LastUsedUtc = DateTime.UtcNow; + return Task.FromResult>( + t.ById.TryGetValue(nodeId, out var n) ? n.Children.Select(Map).ToList() : []); + } + + public Task> AttributesAsync(string nodeId, CancellationToken ct) + { + var t = Tree(); + LastUsedUtc = DateTime.UtcNow; + if (!t.ById.TryGetValue(nodeId, out var n)) + return Task.FromResult>([]); + var attrs = new List(); + if (n.Attribute is { } a) + attrs.Add(new AttributeInfo(n.Name, a.DriverDataType.ToString(), a.IsArray, + a.SecurityClass.ToString(), a.IsAlarm || n.IsAlarmMarked)); + attrs.AddRange(n.Properties.Select(p => + new AttributeInfo(p.Name, p.Type.ToString(), false, "", false))); + return Task.FromResult>(attrs); + } + + public ValueTask DisposeAsync() + { + _tree = null; // drop this session's reference only — tree may be shared (coalescing) + return ValueTask.CompletedTask; + } + + private CapturedTree Tree() => _tree ?? throw new ObjectDisposedException(nameof(CapturedTreeBrowseSession)); + + private static BrowseNode Map(CapturedNode n) => n.IsLeaf + ? new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Leaf, false) + : new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Folder, n.Children.Count > 0); +} +``` + +**Step 4:** PASS. **Step 5:** build the slnx. + +--- + +### Task 8: `IUniversalDriverBrowser` + `DiscoveryDriverBrowser` core open path + +**Classification:** high-risk (driver lifecycle orchestration) +**Estimated implement time:** ~5 min +**Parallelizable with:** none (Batch D) + +**Files:** +- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IUniversalDriverBrowser.cs` +- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs` +- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserTests.cs` (create; includes the shared `FakeDiscoveryDriver` + `FakeDriverFactory` used by Tasks 9–10) + +**Step 1: Failing tests** (the fake driver implements `IDriver` + `ITagDiscovery` with all `IDriver` members trivial — read `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs` for the full member list; expose counters `InitializeCalls`/`DiscoverCalls`/`ShutdownCalls` and injectable `Func` discover behavior): + +- `OpenAsync_RunsInitializeDiscoverShutdown_InOrder` +- `OpenAsync_ShutdownRuns_EvenWhenDiscoverThrows` (and the discovery exception — not a shutdown error — surfaces) +- `OpenAsync_UnknownType_ThrowsCleanMessage` (factory returns null) +- `OpenAsync_NotDiscoveryCapable_Throws` / `OpenAsync_FlagFalse_Throws` +- `PatchForBrowse_MergesAbCipPatch` (config `{"Host":"1.2.3.4"}` + AbCip ⇒ JSON with both `Host` and `EnableControllerBrowse:true`; unknown type ⇒ unchanged) +- `PatchForBrowse_DeepMergesFocasFixedTree` (config `{"FixedTree":{"Series":"30i"}}` ⇒ keeps `Series`, adds `Enabled:true`) +- `CanBrowse_TrueForDiscoveryCapable` / `CanBrowse_FalseWhenTryCreateReturnsNull` +- `CanBrowse_FalseWhenTryCreateThrows_NoExceptionEscapes` (design §4.2 hardening) +- `CanBrowse_ShutsDownThrowawayInstance` (fake counts ShutdownCalls even on the false path) +- `BoundedShutdown_AbandonsWedgedDriver` (fake `ShutdownAsync` = `Task.Delay(Timeout.Infinite, ct)` ignoring nothing — but also a variant that ignores the token entirely; open still returns on the discovery outcome; use a short `shutdownTimeout` via the test-visible ctor) + +**Step 2:** FAIL. **Step 3: Implement.** + +`IUniversalDriverBrowser.cs`: + +```csharp +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// +/// Universal fallback browser: captures any discovery-capable driver's DiscoverAsync +/// output and serves it as a browse session. Resolved by BrowserSessionService only +/// when no bespoke IDriverBrowser matches the driver type (bespoke-first). A separate +/// interface — NOT an IDriverBrowser — because BrowserSessionService indexes those +/// ToDictionary-by-DriverType and the universal browser has no single type. +/// +public interface IUniversalDriverBrowser +{ + /// Cheap gate (construct + inspect, no connect): can this driver type + config be + /// browsed? Never throws. Drives the picker's Browse-button visibility. + bool CanBrowse(string driverType, string configJson); + + /// Construct the driver (with the per-type browse patch), connect, capture one + /// full discovery pass (with UntilStable settle), shut the driver down (bounded), and + /// return a session over the captured snapshot. Throws on failure — the caller + /// (BrowserSessionService) converts to BrowseOpenResult. + Task OpenAsync(string driverType, string configJson, CancellationToken ct); +} +``` + +`DiscoveryDriverBrowser.cs` (Task 8 version — settle and coalescing arrive in Tasks 9–10): + +```csharp +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.2. +public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser +{ + /// Whole-open bound: construct + connect + discover (+ settle). Separate from the + /// 20 s per-call browse timeout, which only covers serving from memory. + public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60); + + /// Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged + /// in discovery may be equally wedged in shutdown). + public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10); + + public const int NodeCap = 50_000; + + /// Per-type declarative config patch guaranteeing browse mode at open time (§5.1). + /// A dictionary entry per config-gated driver — not code. + internal static readonly IReadOnlyDictionary BrowsePatches = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["AbCip"] = """{ "EnableControllerBrowse": true }""", + ["TwinCAT"] = """{ "EnableControllerBrowse": true }""", + ["FOCAS"] = """{ "FixedTree": { "Enabled": true } }""", + // OpcUaClient/BACnet/MTConnect: no patch — their discovery is unconditional. + }; + + private readonly IDriverFactory _driverFactory; + private readonly ILogger _logger; + private readonly TimeSpan _openTimeout; + private readonly TimeSpan _shutdownTimeout; + + public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger logger) + : this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { } + + // Test seam: short timeouts. + internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger logger, + TimeSpan openTimeout, TimeSpan shutdownTimeout) + { + _driverFactory = driverFactory; + _logger = logger; + _openTimeout = openTimeout; + _shutdownTimeout = shutdownTimeout; + } + + public bool CanBrowse(string driverType, string configJson) + { + IDriver? probe = null; + try + { + probe = _driverFactory.TryCreate(driverType, $"browse-can-{Guid.NewGuid():N}", + PatchForBrowse(driverType, configJson)); + return probe is ITagDiscovery { SupportsOnlineDiscovery: true }; + } + catch + { + // TryCreate parses configJson and can throw on mid-authoring JSON. UI affordance + // gate — must never throw into the page (§4.2). + return false; + } + finally + { + if (probe is not null) _ = BoundedShutdownAsync(probe, driverType); // best-effort, never leak + } + } + + public async Task OpenAsync(string driverType, string configJson, CancellationToken ct) + { + var tree = await CaptureAsync(driverType, configJson, ct).ConfigureAwait(false); + return new CapturedTreeBrowseSession(tree); + } + + private async Task CaptureAsync(string driverType, string configJson, CancellationToken ct) + { + var patched = PatchForBrowse(driverType, configJson); + var driver = _driverFactory.TryCreate(driverType, $"browse-{Guid.NewGuid():N}", patched) + ?? throw new InvalidOperationException( + $"Driver type '{driverType}' is not available in this host."); + try + { + if (driver is not ITagDiscovery disc || !disc.SupportsOnlineDiscovery) + throw new InvalidOperationException( + $"Driver type '{driverType}' has no online discovery — author tags manually."); + + using var openCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + openCts.CancelAfter(_openTimeout); + + await driver.InitializeAsync(patched, openCts.Token).ConfigureAwait(false); + return await DiscoverTreeAsync(disc, openCts.Token).ConfigureAwait(false); + } + finally + { + await BoundedShutdownAsync(driver, driverType).ConfigureAwait(false); + } + } + + // Task 9 replaces this with the UntilStable settle version. + private static async Task DiscoverTreeAsync(ITagDiscovery disc, CancellationToken ct) + { + var builder = new CapturingAddressSpaceBuilder(NodeCap); + await disc.DiscoverAsync(builder, ct).ConfigureAwait(false); + return builder.Build(); + } + + private async Task BoundedShutdownAsync(IDriver driver, string driverType) + { + try + { + using var cts = new CancellationTokenSource(_shutdownTimeout); + // WaitAsync: a token-ignoring wedged driver is ABANDONED (dropped), never awaited forever. + await driver.ShutdownAsync(cts.Token).WaitAsync(cts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Browse-capture shutdown for {DriverType} failed/timed out; instance abandoned", driverType); + } + } + + internal static string PatchForBrowse(string driverType, string configJson) + { + if (!BrowsePatches.TryGetValue(driverType, out var patch)) return configJson; + var config = JsonNode.Parse(string.IsNullOrWhiteSpace(configJson) ? "{}" : configJson) + ?.AsObject() ?? new JsonObject(); + Merge(config, JsonNode.Parse(patch)!.AsObject()); + return config.ToJsonString(); + } + + private static void Merge(JsonObject target, JsonObject patch) + { + foreach (var (key, value) in patch) + { + if (value is JsonObject po && target[key] is JsonObject to) Merge(to, po); + else target[key] = value?.DeepClone(); + } + } +} +``` + +**Step 4:** PASS + slnx build. (If `ILogger<>` fails to resolve in Commons, apply Task 2's conditional PackageReference now.) + +--- + +### Task 9: `UntilStable` settle loop (FOCAS) + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 11 + +**Files:** +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs` +- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserSettleTests.cs` (create) + +**Why:** FOCAS's FixedTree cache fills a couple of seconds *after* connect (`FocasDriver.cs` — comment above `DiscoverAsync`, background `FixedTreeLoopAsync`). A one-shot capture right after Initialize returns an **empty** tree. Design §4.2 step 4. + +**Step 1: Failing tests** — fake driver with `RediscoverPolicy = UntilStable` whose discover streams nothing on pass 1, three nodes on passes 2+: +- `UntilStable_RerunsUntilNonEmptyAndStable` (final tree has 3 nodes; ≥3 discover calls: empty→3→3-stable) +- `UntilStable_NeverStable_ReturnsLastNonEmptyAtTimeout` (node count grows every pass; short openTimeout via internal ctor; returns the last capture + logs — NOT an exception) +- `UntilStable_NeverAnyNodes_FailsAtOpenTimeout` (all passes empty ⇒ `OperationCanceledException`/timeout error surfaces) +- `OncePolicy_SingleDiscoverPass` (fake with `Once`: exactly 1 discover call) + +**Step 2:** FAIL. **Step 3:** Replace `DiscoverTreeAsync` with an instance method: + +```csharp + private static readonly TimeSpan SettleInterval = TimeSpan.FromSeconds(1); + + private async Task DiscoverTreeAsync(ITagDiscovery disc, string driverType, CancellationToken ct) + { + if (disc.RediscoverPolicy != DiscoveryRediscoverPolicy.UntilStable) + { + var builder = new CapturingAddressSpaceBuilder(NodeCap); + await disc.DiscoverAsync(builder, ct).ConfigureAwait(false); + return builder.Build(); + } + + // UntilStable (FOCAS): the discovered shape fills in asynchronously after connect — + // re-capture until non-empty and stable across two consecutive passes, bounded by the + // open-timeout. Mirrors the deploy-time contract in DriverInstanceActor. + CapturedTree? previous = null; + while (true) + { + var builder = new CapturingAddressSpaceBuilder(NodeCap); + try + { + await disc.DiscoverAsync(builder, ct).ConfigureAwait(false); + var current = builder.Build(); + if (current.Count > 0 && previous?.Count == current.Count) return current; + previous = current; + await Task.Delay(SettleInterval, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (previous is { Count: > 0 }) + { + _logger.LogWarning( + "UntilStable settle for {DriverType} hit the open-timeout before stabilising; returning the last capture ({Count} nodes)", + driverType, previous.Count); + return previous; // best effort: a fresh-but-unsettled tree beats a failed open + } + } + } +``` + +Update the call site to pass `driverType`. **Step 4:** PASS. + +--- + +### Task 10: Capture coalescing + `MaxConcurrentCaptures` cap + +**Classification:** high-risk (concurrency) +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 12 + +**Files:** +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs` +- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserConcurrencyTests.cs` (create) + +**Step 1: Failing tests** (gate fake drivers on `TaskCompletionSource` signals — no `Task.Delay` races): +- `SameKey_TwoConcurrentOpens_OneCapture_DistinctTokens` (fake counts Initialize calls == 1; two sessions, different `Token`s; both serve the same tree content) +- `SameKey_DisposingOneSession_LeavesOtherBrowsable` +- `SameKey_FailedCapture_FailsBothAwaiters` (same exception surfaces to both) +- `SameKey_OpenAfterCompletion_RunsFreshCapture` (in-flight-only map — Initialize calls == 2; this is the Refresh path) +- `DistinctKeys_CapLimitsInflightCaptures` (6 opens on 6 distinct keys, `MaxConcurrentCaptures = 4`: assert ≤4 fakes have entered Initialize while gated; release → all 6 complete) +- `QueuedOpen_ObservesCallerCancellation` (a queued caller's ct cancels → its open throws OCE; the in-flight captures are unaffected) +- `CoalescedAwaiter_CancellationDoesNotCancelSharedCapture` (first caller cancels; second caller still gets the tree) + +**Step 2:** FAIL. **Step 3: Implement** — add to `DiscoveryDriverBrowser`: + +```csharp + /// Global cap on simultaneous captures (§4.2). Coalesced awaiters don't hold a slot. + public const int MaxConcurrentCaptures = 4; + + private readonly ConcurrentDictionary>> _inflight = new(); + private readonly SemaphoreSlim _captureSlots = new(MaxConcurrentCaptures, MaxConcurrentCaptures); +``` + +Rewrite `OpenAsync`: + +```csharp + public async Task OpenAsync(string driverType, string configJson, CancellationToken ct) + { + var key = CaptureKey(driverType, configJson); + // Lazy: exactly one RunCaptureAsync per key even under GetOrAdd races. + var capture = _inflight.GetOrAdd(key, + _ => new Lazy>(() => RunCaptureAsync(key, driverType, configJson))); + // The shared capture runs independent of any single caller (its own OpenTimeout bounds it); + // each caller — including a queued one — observes ITS OWN ct via WaitAsync. + var tree = await capture.Value.WaitAsync(ct).ConfigureAwait(false); + return new CapturedTreeBrowseSession(tree); + } + + private async Task RunCaptureAsync(string key, string driverType, string configJson) + { + try + { + await _captureSlots.WaitAsync().ConfigureAwait(false); // FIFO-ish queue for a slot + try + { + return await CaptureAsync(driverType, configJson, CancellationToken.None).ConfigureAwait(false); + } + finally + { + _captureSlots.Release(); + } + } + finally + { + _inflight.TryRemove(key, out _); // in-flight only — no result caching (Refresh = fresh capture) + } + } + + internal static string CaptureKey(string driverType, string configJson) => + driverType.ToLowerInvariant() + ":" + + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(configJson ?? ""))); +``` + +(`CaptureAsync` gets `CancellationToken.None` deliberately: the capture is shared and internally bounded by `_openTimeout`; per-caller cancellation must not kill another caller's capture. If every caller abandons, the capture still completes and is dropped — bounded waste, by design.) Add `using System.Collections.Concurrent;`. + +**Step 4:** PASS. Run the WHOLE Commons.Tests project (Tasks 6–10 together). + +--- + +### Task 11: `BrowserSessionService` universal fallback + `CanBrowse` on the service + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 9 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs` +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs` +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — extend/create `BrowserSessionServiceTests.cs` (follow the project's existing test style if a class exists) + +**Step 1: Failing tests:** +- `Open_BespokeBrowserWinsOverUniversal` (both available for the type ⇒ bespoke's session) +- `Open_FallsBackToUniversal_WhenNoBespokeAndCanBrowse` (fake `IUniversalDriverBrowser`) +- `Open_NoBespokeAndCannotBrowse_ReturnsNoBrowserMessage` (existing message text unchanged) +- `Open_UniversalThrow_ReturnsOkFalseWithMessage` (never throws out) +- `CanBrowse_TrueForBespokeType_TrueForUniversalType_FalseOtherwise` +- Existing tests still green (no `ToDictionary` change). + +**Step 2:** FAIL. **Step 3: Implement.** + +`IBrowserSessionService` — add: + +```csharp + /// True when a browse affordance exists for this driver type: a bespoke browser + /// is registered, or the universal browser can serve it. Cheap (no connect); never throws. + /// Drives the picker's Browse-button visibility. + bool CanBrowse(string driverType, string configJson); +``` + +`BrowserSessionService` — add the ctor dependency (keep the primary-constructor style): + +```csharp +public sealed class BrowserSessionService( + IEnumerable browsers, + BrowseSessionRegistry registry, + ILogger logger, + IUniversalDriverBrowser universalBrowser) : IBrowserSessionService +``` + +Replace the `OpenAsync` miss branch (currently `BrowserSessionService.cs:27-28`): + +```csharp + if (!_browsersByType.TryGetValue(driverType, out var browser)) + return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false); +``` + +And add: + +```csharp + public bool CanBrowse(string driverType, string configJson) => + _browsersByType.ContainsKey(driverType) || universalBrowser.CanBrowse(driverType, configJson); + + private async Task OpenUniversalAsync(string driverType, string configJson, CancellationToken ct) + { + if (!universalBrowser.CanBrowse(driverType, configJson)) + return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty); + try + { + var session = await universalBrowser.OpenAsync(driverType, configJson, ct).ConfigureAwait(false); + registry.Register(session); + return new(true, null, session.Token); + } + catch (Exception ex) + { + logger.LogInformation(ex, + "Universal browse open failed for driverType={DriverType}: {Message}", driverType, ex.Message); + return new(false, ex.Message, Guid.Empty); + } + } +``` + +**Step 4:** PASS: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests`. + +--- + +### Task 12: DI registration in `AddAdminUI` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 10 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` + +**Step 1:** In `AddAdminUI`, after the two bespoke `IDriverBrowser` lines (`EndpointRouteBuilderExtensions.cs:49-50`), add: + +```csharp + // Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the + // fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory + // → CanBrowse false → pickers gracefully stay manual-entry (design §6). + services.AddSingleton(sp => new DiscoveryDriverBrowser( + sp.GetService() ?? ZB.MOM.WW.OtOpcUa.Core.Abstractions.NullDriverFactory.Instance, + sp.GetRequiredService>())); +``` + +(Hoist the namespaces into `using`s per file style.) + +**Step 2:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — success. The Host smoke: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` still green. + +--- + +### Task 13: TagModal → editor parameter plumbing (`DriverType` + `GetDriverConfigJson`) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 2 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (only `BuildEditorParameters`, ~line 352) +- Modify: all 7 editors in `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/` (`AbCipTagConfigEditor.razor`, `TwinCATTagConfigEditor.razor`, `FocasTagConfigEditor.razor`, `ModbusTagConfigEditor.razor`, `S7TagConfigEditor.razor`, `AbLegacyTagConfigEditor.razor`, `OpcUaClientTagConfigEditor.razor`) + +**Why:** typed editors today receive only `ConfigJson`/`ConfigJsonChanged` — the browse-capable picker bodies need the selected driver's type + DriverConfig JSON to open a session. `DynamicComponent` throws on unmatched parameters, so **every** editor must declare the new params (unused ones just ignore them). + +**Step 1:** `TagModal.razor` `BuildEditorParameters()` becomes: + +```csharp + private IDictionary BuildEditorParameters() => new Dictionary + { + ["ConfigJson"] = _form.TagConfig, + ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v), + ["DriverType"] = SelectedDriverType ?? "", + ["GetDriverConfigJson"] = (Func)(() => SelectedDriverConfig), + }; +``` + +**Step 2:** Each of the 7 editors adds (next to the existing `ConfigJson` parameters): + +```csharp + /// DriverType of the selected driver — lets browse-capable pickers open a universal session. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; +``` + +**Step 3:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — success. (No unit test — AdminUI has no bUnit; Task 18 live-verifies.) + +--- + +### Task 14: AbCip picker body — universal browse mode + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 15, Task 16 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor` +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor` (pass-through only: add `DriverType="@DriverType" GetConfigJson="@GetDriverConfigJson"` to the `` element, ~line 36) + +**Step 1:** Rework `AbCipAddressPickerBody.razor` following the **exact** pattern of `OpcUaClientAddressPickerBody.razor` (same directory — read it first; it is the house template: `IBrowserSessionService` + `AuthenticationStateProvider`/`IAuthorizationService` DriverOperator gate + `DriverBrowseTree` + fire-and-forget close in `DisposeAsync`). Keep the existing manual tag-name/element-index builder markup **unchanged** at the top; append the browse affordance below it: + +- New params: `[Parameter] public string DriverType { get; set; } = "AbCip";` and `[Parameter] public Func GetConfigJson { get; set; } = () => "{}";` +- Browse-button visibility: `_canOperate && BrowserService.CanBrowse(DriverType, GetConfigJson())` — evaluate once in `OnInitializedAsync` into `_canBrowse` (CanBrowse constructs a throwaway driver; don't call it per-render). +- Open: `await BrowserService.OpenAsync(DriverType, GetConfigJson(), default)` — NOT a hardcoded type string. +- While open, show **two** buttons: `Close` and `Refresh` (Refresh = `await CloseBrowseAsync(); await OpenBrowseAsync();` — a fresh capture per design §4.3). Label the tree header "snapshot taken at open/refresh time". +- `OnTreeSelectAsync(BrowseNode node)`: only when `node.Kind == BrowseNodeKind.Leaf` (check `DriverBrowseTree`'s callback contract — if it already filters to leaves, match it): set `_tagName = node.NodeId` (the captured leaf NodeId IS the driver FullName / CIP tag path), clear `_useIndex`, and fire the existing `OnChangedAsync()`. + +**Step 2:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx`. **Step 3:** visual sanity deferred to Task 18 (live gate). + +--- + +### Task 15: TwinCAT picker body — universal browse mode + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 14, Task 16 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor` +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor` (pass-through) + +Identical shape to Task 14 with `DriverType = "TwinCAT"` (confirm the exact registered DriverType string via `grep -rn "DriverTypeName" src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/` — use what `Register()` uses, casing matters only for display since lookups are OrdinalIgnoreCase). Tree leaf NodeId = ADS symbol path → the editor's address field. + +--- + +### Task 16: FOCAS picker body — universal browse mode + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 14, Task 15 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor` +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor` (pass-through) + +Identical shape (confirm DriverType string from the FOCAS `Register()`). Note in a comment: FOCAS opens take a few seconds longer (UntilStable settle, Task 9) — the existing `_opening` spinner covers it. + +--- + +### Task 17: Full sweep + doc status flip + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** none + +**Files:** +- Modify: `docs/plans/2026-07-15-universal-discovery-browser-design.md` (header only: `**Status:** draft` → `**Status:** P1 implemented ; P2/P3 pending`) +- Modify: `docs/plans/2026-07-15-driver-expansion-program-design.md` (§9 Status: note Wave 0 code landed) + +**Step 1:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — zero warnings-as-errors failures. +**Step 2:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — full suite. **Known pre-existing failures are possible in unrelated suites; compare against a master baseline run if anything unrelated is red — do not chase unrelated rot, report it.** DB-backed suites need the central SQL Server reachable (`10.100.0.35,14330`); integration suites (`*.IntegrationTests`) need their fixtures — if offline, scope to: `Core.Abstractions.Tests`, `Commons.Tests`, `AdminUI.Tests`, plus the three driver unit projects touched. +**Step 3:** Doc edits. **Step 4:** Commit. + +--- + +### Task 18: Live `/run` verify on docker-dev (GATE — do not skip) + +**Classification:** high-risk (the repo's Razor-inertness trap: binding bugs pass unit tests + review) +**Estimated implement time:** ~15 min wall (mostly container builds) +**Parallelizable with:** none + +**Files:** none (verification only) + +**Step 1:** Bring the AbCip fixture up on the docker host: `lmxopcua-fix up abcip controllogix` (helper in `~/bin`; the ControlLogix sim listens on `10.100.0.35:44818`). +**Step 2:** Rebuild **BOTH** docker-dev centrals (`:9200` round-robins central-1/central-2 — rebuilding one gives flapping results; durable repo fact): `docker compose -f docker-dev/docker-compose.yml up -d --build` (or the repo's documented docker-dev rebuild recipe — check `docker-dev/README` / `docs/v2/dev-environment.md`). +**Step 3:** AdminUI at `http://localhost:9200` (login DISABLED — auto-admin; do this yourself, do not defer to the user). Using the Chrome browser tools (or playwright-skill): +1. Create/locate an AbCip driver whose DriverConfig points at `10.100.0.35:44818` with a device entry (follow `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests` fixture config for the exact device shape). Leave `EnableControllerBrowse` **absent** — the browse patch must supply it. +2. Equipment page → Tags tab → Add tag → select the AbCip driver → typed editor → open the address picker. +3. **Assert:** Browse button visible (CanBrowse=true) → click → tree of controller tags renders → Refresh works → pick a leaf → the address field holds the tag path → save the tag → TagConfig JSON contains it. +4. **Negative:** select a Modbus driver → its picker shows NO Browse button (SupportsOnlineDiscovery=false ⇒ CanBrowse=false). +**Step 4:** If any leg fails: debug, fix, re-run. This gate finding a dead binding is EXPECTED value, not failure of the plan. +**Step 5:** Record the result (screenshots/notes) in the final report; commit any fixes. + +--- + +## Out of scope (explicitly) + +- TwinCAT/FOCAS live-fixture verification (no shipped fixtures assumed reachable; AbCip is the gate). +- The fixture-gated integration test (design §9) — follow-up once the AbCip fixture harness pattern is settled. +- v3 Raw-tree re-targeting (design §11) — v3 consumes this unchanged. +- Bespoke lazy browsers (P3), MTConnect/BACnet opt-ins (P2 — ride their driver builds). diff --git a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json new file mode 100644 index 00000000..741e7914 --- /dev/null +++ b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json @@ -0,0 +1,25 @@ +{ + "planPath": "docs/plans/2026-07-15-universal-discovery-browser-implementation.md", + "tasks": [ + {"id": 0, "subject": "Task 0: Branch setup (feat/universal-discovery-browser)", "status": "pending"}, + {"id": 1, "subject": "Task 1: ITagDiscovery.SupportsOnlineDiscovery default member", "status": "pending", "blockedBy": [0], "parallelWith": [2, 13]}, + {"id": 2, "subject": "Task 2: Commons project references (Core.Abstractions)", "status": "pending", "blockedBy": [0], "parallelWith": [1, 13]}, + {"id": 3, "subject": "Task 3: AbCip opt-in SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [1], "parallelWith": [4, 5]}, + {"id": 4, "subject": "Task 4: TwinCAT opt-in SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [1], "parallelWith": [3, 5]}, + {"id": 5, "subject": "Task 5: FOCAS opt-in SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [1], "parallelWith": [3, 4]}, + {"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "pending", "blockedBy": [1, 2]}, + {"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "pending", "blockedBy": [6]}, + {"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "pending", "blockedBy": [7]}, + {"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "pending", "blockedBy": [8], "parallelWith": [11]}, + {"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "pending", "blockedBy": [9], "parallelWith": [12]}, + {"id": 11, "subject": "Task 11: BrowserSessionService universal fallback + CanBrowse", "status": "pending", "blockedBy": [8], "parallelWith": [9]}, + {"id": 12, "subject": "Task 12: DI registration in AddAdminUI", "status": "pending", "blockedBy": [11], "parallelWith": [10]}, + {"id": 13, "subject": "Task 13: TagModal -> editor parameter plumbing (DriverType + GetDriverConfigJson)", "status": "pending", "blockedBy": [0], "parallelWith": [1, 2]}, + {"id": 14, "subject": "Task 14: AbCip picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 3], "parallelWith": [15, 16]}, + {"id": 15, "subject": "Task 15: TwinCAT picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 4], "parallelWith": [14, 16]}, + {"id": 16, "subject": "Task 16: FOCAS picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 5], "parallelWith": [14, 15]}, + {"id": 17, "subject": "Task 17: Full build/test sweep + doc status flip", "status": "pending", "blockedBy": [14, 15, 16]}, + {"id": 18, "subject": "Task 18: Live /run verify on docker-dev (GATE)", "status": "pending", "blockedBy": [17]} + ], + "lastUpdated": "2026-07-15T17:30:00Z" +}