diff --git a/docs/plans/2026-05-28-client-walker-implementation.md b/docs/plans/2026-05-28-client-walker-implementation.md new file mode 100644 index 0000000..4fe89fc --- /dev/null +++ b/docs/plans/2026-05-28-client-walker-implementation.md @@ -0,0 +1,1566 @@ +# Client LazyBrowseNode Walker Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Add an idempotent-expand `LazyBrowseNode` walker plus a low-level `BrowseChildren*Async` wrapper to each of the five language clients (.NET, Python, Rust, Go, Java), with unit tests against the existing fake-transport fixtures in each. Install Temurin 21 + Gradle on the macOS dev host so Java work is local. + +**Architecture:** Each client's existing `GalaxyRepositoryClient` (or `GalaxyClient`) gains two new public methods: a raw `BrowseChildrenRawAsync(request)` wrapper that handles auth/retry/transport, and a high-level `BrowseAsync(options?)` factory returning `LazyBrowseNode[]`. Each `LazyBrowseNode` exposes the underlying `GalaxyObject`, a `HasChildrenHint` boolean, a `Children` collection (empty until expanded), and an idempotent `ExpandAsync()` method that calls `BrowseChildren(parent_id=this.id)` once, walks all sibling pages, and populates `Children` with fresh unexpanded `LazyBrowseNode`s. Subsequent `ExpandAsync` calls are no-ops. + +**Tech Stack:** .NET 10 (C#), Python 3.12 (asyncio + grpcio), Rust 1.95 (tonic), Go 1.26, Java 21 (Gradle, grpc-java). + +**Design source:** `docs/plans/2026-05-28-client-walker-design.md`. + +--- + +## Task 0: Worktree state check + branch (or continue on existing) + +**Classification:** trivial +**Estimated implement time:** ~1 min +**Parallelizable with:** none + +**Files:** (none) + +**Step 1: Confirm branch** + +Run: `git branch --show-current && git status --short` +Expected: on `feat/lazy-browse-children` (where the prior work landed), clean except for the usual untracked `*-docs-*.md` review artifacts. + +The walker work continues on the same branch — it's a follow-up to the just-shipped `BrowseChildren` RPC. No new branch needed. + +Step 2: Confirm the design doc is committed (sanity check that we have the design to follow). + +Run: `git log --oneline -1 docs/plans/2026-05-28-client-walker-design.md` +Expected: shows the design commit (`eaf4793` or similar). + +No commit in this task. + +--- + +## Task 1: Java toolchain bootstrap + +**Classification:** small +**Estimated implement time:** ~3 min (mostly Homebrew download) +**Parallelizable with:** Tasks 2, 3, 4, 5 (Java work is Task 6 and depends on this; the other languages don't need it) + +**Files:** (none — environmental change only) + +This task is run-or-defer: if Homebrew install completes, the Java task (Task 6) proceeds; if it fails, Task 6 is skipped and the Java README still gets the docs-only update in Task 7. + +**Step 1: Install Temurin 21** + +```bash +brew install temurin@21 2>&1 | tail -10 +``` + +Expected: clean install or "already installed". If it errors due to Homebrew taps or network, report and STOP — Tasks 2/3/4/5 proceed independently. + +**Step 2: Install Gradle** + +```bash +brew install gradle 2>&1 | tail -5 +``` + +Expected: clean install. + +**Step 3: Verify** + +```bash +java -version 2>&1 | head -2 +gradle --version 2>&1 | head -5 +``` + +Expected: `openjdk version "21..."` and `Gradle 8.x` (or 9.x — accept either). + +**Step 4: Set `JAVA_HOME` if needed** + +```bash +echo "$JAVA_HOME" +``` + +If empty, find the JDK path: +```bash +/usr/libexec/java_home -v 21 +``` + +The plan does NOT modify shell init files (out of scope — too permanent for a project task). If `JAVA_HOME` is empty, Task 6's implementer will `export JAVA_HOME=$(/usr/libexec/java_home -v 21)` inline before each `gradle` invocation. + +**Step 5: Verify the Java client builds against its existing committed generated tree** + +```bash +cd /Users/dohertj2/Desktop/MxAccessGateway/clients/java && \ + ( [ -n "$JAVA_HOME" ] || export JAVA_HOME=$(/usr/libexec/java_home -v 21) ) && \ + gradle build -x test 2>&1 | tail -15 +``` + +Expected: `BUILD SUCCESSFUL`. If the build fails with a proto-plugin or dependency-resolution error, this is the time to find out — DON'T spend more than 15 minutes debugging. Report the error in your task summary; Task 6 will be skipped and the rest of the plan proceeds. + +No commit in this task (environmental only). + +--- + +## Task 2: .NET — LazyBrowseNode walker + tests + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 3, 4, 5, 6 (different language trees) + +**Files:** +- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/IGalaxyRepositoryClientTransport.cs` (add `BrowseChildrenAsync` method to the interface) +- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs` (implement the new interface method by delegating to the generated client) +- Create: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/BrowseChildrenOptions.cs` +- Create: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs` +- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs` (add `BrowseChildrenRawAsync` + `BrowseAsync`) +- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs` (record BrowseChildren calls + canned replies) +- Create: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs` + +### Step 1: Write the failing tests + +Create `LazyBrowseNodeTests.cs` with the six facts from the design's "Tests" section. Use the existing `MxGatewayClientOptions` and `FakeGalaxyRepositoryTransport` patterns (read `GalaxyRepositoryClientTests.cs` for the construction idiom — likely `new GalaxyRepositoryClient(transport)` with the fake). + +```csharp +using Grpc.Core; +using Xunit; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Client.Tests; + +public sealed class LazyBrowseNodeTests +{ + [Fact] + public async Task Browse_NoParent_ReturnsRoots() + { + FakeGalaxyRepositoryTransport fake = NewFake(); + fake.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(1, "Plant", isArea: true), BuildObject(99, "Other")], + childHasChildren: [true, false], + cacheSequence: 7)); + + using GalaxyRepositoryClient client = new(fake); + IReadOnlyList roots = await client.BrowseAsync(); + + Assert.Equal(2, roots.Count); + Assert.Equal("Plant", roots[0].Object.TagName); + Assert.True(roots[0].HasChildrenHint); + Assert.False(roots[0].IsExpanded); + Assert.False(roots[1].HasChildrenHint); + } + + [Fact] + public async Task Expand_PopulatesChildrenAndMarksExpanded() + { + FakeGalaxyRepositoryTransport fake = NewFake(); + fake.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(1, "Plant", isArea: true)], + childHasChildren: [true], + cacheSequence: 7)); + fake.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(2, "Mixer_001")], + childHasChildren: [false], + cacheSequence: 7)); + + using GalaxyRepositoryClient client = new(fake); + IReadOnlyList roots = await client.BrowseAsync(); + await roots[0].ExpandAsync(); + + Assert.True(roots[0].IsExpanded); + Assert.Single(roots[0].Children); + Assert.Equal("Mixer_001", roots[0].Children[0].Object.TagName); + Assert.Equal(2, fake.BrowseChildrenCalls.Count); // roots + expand + } + + [Fact] + public async Task Expand_CalledTwice_NoSecondRpc() + { + FakeGalaxyRepositoryTransport fake = NewFake(); + fake.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(1, "Plant", isArea: true)], + childHasChildren: [true], + cacheSequence: 7)); + fake.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(2, "Mixer_001")], + childHasChildren: [false], + cacheSequence: 7)); + + using GalaxyRepositoryClient client = new(fake); + IReadOnlyList roots = await client.BrowseAsync(); + await roots[0].ExpandAsync(); + await roots[0].ExpandAsync(); // no-op + + Assert.Equal(2, fake.BrowseChildrenCalls.Count); // still just roots + one expand + } + + [Fact] + public async Task Expand_UnknownParent_ThrowsMxGatewayException() + { + FakeGalaxyRepositoryTransport fake = NewFake(); + fake.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(1, "Plant", isArea: true)], + childHasChildren: [true], + cacheSequence: 7)); + fake.BrowseChildrenExceptions.Enqueue( + new RpcException(new Status(StatusCode.NotFound, "parent not found"))); + + using GalaxyRepositoryClient client = new(fake); + IReadOnlyList roots = await client.BrowseAsync(); + + // The existing client maps RpcException -> MxGatewayException via MxGatewayErrors helper; + // use whatever wrapper type GalaxyRepositoryClient.DiscoverHierarchyAsync currently throws + // for NotFound — match that exact type. + await Assert.ThrowsAnyAsync(() => roots[0].ExpandAsync().AsTask()); + } + + [Fact] + public async Task Expand_MultiPageSiblings_GathersAllPages() + { + FakeGalaxyRepositoryTransport fake = NewFake(); + fake.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(1, "Plant", isArea: true)], + childHasChildren: [true], + cacheSequence: 7)); + // Two pages of children under Plant. + BrowseChildrenReply page1 = BuildReply( + children: [BuildObject(2, "Mixer_001"), BuildObject(3, "Mixer_002")], + childHasChildren: [false, false], + cacheSequence: 7); + page1.NextPageToken = "7:abc:2"; + fake.BrowseChildrenReplies.Enqueue(page1); + fake.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(4, "Pump_001")], + childHasChildren: [false], + cacheSequence: 7)); + + using GalaxyRepositoryClient client = new(fake); + IReadOnlyList roots = await client.BrowseAsync(); + await roots[0].ExpandAsync(); + + Assert.Equal(3, roots[0].Children.Count); + Assert.Equal(new[] { "Mixer_001", "Mixer_002", "Pump_001" }, + roots[0].Children.Select(c => c.Object.TagName)); + // 1 (roots) + 2 (page1 + page2 under Plant) = 3 + Assert.Equal(3, fake.BrowseChildrenCalls.Count); + // Page 2 request carries the page token from page 1. + Assert.Equal("7:abc:2", fake.BrowseChildrenCalls[^1].Request.PageToken); + } + + [Fact] + public async Task Browse_WithFilter_ForwardsToRequest() + { + FakeGalaxyRepositoryTransport fake = NewFake(); + fake.BrowseChildrenReplies.Enqueue(BuildReply( + children: [], + childHasChildren: [], + cacheSequence: 7)); + + using GalaxyRepositoryClient client = new(fake); + await client.BrowseAsync(new BrowseChildrenOptions { TagNameGlob = "Mixer*", AlarmBearingOnly = true }); + + BrowseChildrenRequest req = fake.BrowseChildrenCalls[0].Request; + Assert.Equal("Mixer*", req.TagNameGlob); + Assert.True(req.AlarmBearingOnly); + } + + private static FakeGalaxyRepositoryTransport NewFake() + => new(new MxGatewayClientOptions { Endpoint = "fake", ApiKey = "mxgw_test_secret", UsePlaintext = true }); + + private static GalaxyObject BuildObject(int id, string tag, bool isArea = false) + => new() { GobjectId = id, TagName = tag, BrowseName = tag, IsArea = isArea }; + + private static BrowseChildrenReply BuildReply( + IReadOnlyList children, + IReadOnlyList childHasChildren, + ulong cacheSequence) + { + BrowseChildrenReply reply = new() + { + TotalChildCount = children.Count, + CacheSequence = cacheSequence, + }; + reply.Children.AddRange(children); + reply.ChildHasChildren.AddRange(childHasChildren); + return reply; + } +} +``` + +If `MxGatewayClientOptions` / `UsePlaintext` etc. don't compile, read an existing test file (`GalaxyRepositoryClientTests.cs`) and copy its construction exactly. + +### Step 2: Run tests to verify they fail (compile error) + +```bash +dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj --filter "FullyQualifiedName~LazyBrowseNodeTests" 2>&1 | tail -20 +``` + +Expected: compile failure — `BrowseAsync`, `LazyBrowseNode`, `BrowseChildrenOptions`, `BrowseChildrenReplies`, `BrowseChildrenCalls`, `BrowseChildrenExceptions` don't exist yet. + +### Step 3: Extend the transport interface + +In `IGalaxyRepositoryClientTransport.cs`, add (mirroring the existing `DiscoverHierarchyAsync`): + +```csharp +/// Returns direct children of a parent in the Galaxy hierarchy. +Task BrowseChildrenAsync( + BrowseChildrenRequest request, + CallOptions callOptions); +``` + +### Step 4: Implement the transport method + +In `GrpcGalaxyRepositoryClientTransport.cs`, add the implementation by delegating to the generated client. Find the existing `DiscoverHierarchyAsync` implementation in the same file and mirror its body exactly, substituting `BrowseChildrenAsync` for `DiscoverHierarchyAsync`. + +### Step 5: Add `BrowseChildrenOptions.cs` + +```csharp +namespace ZB.MOM.WW.MxGateway.Client; + +/// +/// Filters and shape options for . +/// Mirror of for the lazy-browse path. +/// +public sealed class BrowseChildrenOptions +{ + /// Restrict to children whose Galaxy category is in this set. + public IReadOnlyList CategoryIds { get; init; } = []; + + /// Restrict to children whose template chain contains any of these tokens. + public IReadOnlyList TemplateChainContains { get; init; } = []; + + /// Optional glob-style filter on tag_name. + public string? TagNameGlob { get; init; } + + /// Whether to populate each GalaxyObject.Attributes. Null leaves the server default. + public bool? IncludeAttributes { get; init; } + + /// Restrict to children that bear at least one alarm attribute. + public bool AlarmBearingOnly { get; init; } + + /// Restrict to children that have at least one historized attribute. + public bool HistorizedOnly { get; init; } +} +``` + +### Step 6: Add `LazyBrowseNode.cs` + +```csharp +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Client; + +/// +/// One node in a lazy-loaded Galaxy browse tree. Holds the underlying +/// and exposes to fetch +/// its direct children on demand. Expansion is one-shot: a second call is a +/// no-op. Pagination of large sibling sets is handled internally. +/// +public sealed class LazyBrowseNode +{ + private readonly GalaxyRepositoryClient _client; + private readonly BrowseChildrenOptions _options; + private readonly List _children = []; + private bool _isExpanded; + + internal LazyBrowseNode( + GalaxyRepositoryClient client, + GalaxyObject @object, + bool hasChildrenHint, + BrowseChildrenOptions options) + { + _client = client; + Object = @object; + HasChildrenHint = hasChildrenHint; + _options = options; + } + + /// The underlying Galaxy object for this node. + public GalaxyObject Object { get; } + + /// True when the server reports this node has at least one matching descendant. + public bool HasChildrenHint { get; } + + /// Direct children loaded by ; empty until then. + public IReadOnlyList Children => _children; + + /// True after the first call completes. + public bool IsExpanded => _isExpanded; + + /// + /// Fetches direct children from the gateway and populates + /// . Idempotent: subsequent calls are no-ops. + /// + public async Task ExpandAsync(CancellationToken cancellationToken = default) + { + if (_isExpanded) + { + return; + } + + string pageToken = string.Empty; + HashSet seenPageTokens = new(StringComparer.Ordinal); + do + { + BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options); + request.ParentGobjectId = Object.GobjectId; + request.PageToken = pageToken; + + BrowseChildrenReply reply = await _client + .BrowseChildrenRawAsync(request, cancellationToken) + .ConfigureAwait(false); + + for (int i = 0; i < reply.Children.Count; i++) + { + bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i]; + _children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options)); + } + + pageToken = reply.NextPageToken; + if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken)) + { + throw new MxGatewayException( + $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'."); + } + } + while (!string.IsNullOrWhiteSpace(pageToken)); + + _isExpanded = true; + } +} +``` + +### Step 7: Extend `GalaxyRepositoryClient` + +In `GalaxyRepositoryClient.cs`, add a new constant (next to `DiscoverHierarchyPageSize`) and these new methods (place them after `DiscoverHierarchyRawAsync`): + +```csharp +private const int BrowseChildrenPageSize = 500; + +/// Returns root-level browse nodes (objects with no parent). +public Task> BrowseAsync(CancellationToken cancellationToken = default) + => BrowseAsync(null, cancellationToken); + +/// Returns root-level browse nodes filtered by the given options. +public async Task> BrowseAsync( + BrowseChildrenOptions? options, + CancellationToken cancellationToken = default) +{ + BrowseChildrenOptions effective = options ?? new BrowseChildrenOptions(); + List roots = []; + string pageToken = string.Empty; + HashSet seenPageTokens = new(StringComparer.Ordinal); + do + { + BrowseChildrenRequest request = BuildBrowseChildrenRequest(effective); + request.PageToken = pageToken; + BrowseChildrenReply reply = await BrowseChildrenRawAsync(request, cancellationToken).ConfigureAwait(false); + + for (int i = 0; i < reply.Children.Count; i++) + { + bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i]; + roots.Add(new LazyBrowseNode(this, reply.Children[i], hint, effective)); + } + + pageToken = reply.NextPageToken; + if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken)) + { + throw new MxGatewayException( + $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'."); + } + } + while (!string.IsNullOrWhiteSpace(pageToken)); + + return roots; +} + +/// Issues a raw BrowseChildren RPC without result wrapping. +public Task BrowseChildrenRawAsync( + BrowseChildrenRequest request, + CancellationToken cancellationToken = default) +{ + ArgumentNullException.ThrowIfNull(request); + ThrowIfDisposed(); + + return _safeUnaryRetryPipeline.ExecuteAsync( + token => _transport.BrowseChildrenAsync(request, CreateCallOptions(token)), + cancellationToken).AsTask(); +} + +internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options) +{ + BrowseChildrenRequest request = new() + { + PageSize = BrowseChildrenPageSize, + AlarmBearingOnly = options.AlarmBearingOnly, + HistorizedOnly = options.HistorizedOnly, + }; + request.CategoryIds.Add(options.CategoryIds); + request.TemplateChainContains.Add(options.TemplateChainContains); + if (!string.IsNullOrWhiteSpace(options.TagNameGlob)) + { + request.TagNameGlob = options.TagNameGlob; + } + if (options.IncludeAttributes.HasValue) + { + request.IncludeAttributes = options.IncludeAttributes.Value; + } + return request; +} +``` + +The exact retry-pipeline call shape (`AsTask()` etc.) must match how `DiscoverHierarchyRawAsync` looks in the same file — copy that idiom. + +### Step 8: Extend `FakeGalaxyRepositoryTransport` + +Add (mirroring the existing `DiscoverHierarchyCalls` / `DiscoverHierarchyReply` / `DiscoverHierarchyExceptions` triple): + +```csharp +/// Records BrowseChildren RPC calls. +public List<(BrowseChildrenRequest Request, CallOptions CallOptions)> BrowseChildrenCalls { get; } = []; + +/// Default reply for BrowseChildren when the queue is empty. +public BrowseChildrenReply BrowseChildrenReply { get; set; } = new(); + +/// Queue of replies, dequeued in FIFO order. +public Queue BrowseChildrenReplies { get; } = new(); + +/// Queue of exceptions, dequeued in FIFO order. +public Queue BrowseChildrenExceptions { get; } = new(); + +/// Records the call and returns a queued reply, queued exception, or the default. +public Task BrowseChildrenAsync( + BrowseChildrenRequest request, + CallOptions callOptions) +{ + BrowseChildrenCalls.Add((request, callOptions)); + if (BrowseChildrenExceptions.TryDequeue(out Exception? exception)) + { + return Task.FromException(exception); + } + if (BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply)) + { + return Task.FromResult(reply); + } + return Task.FromResult(BrowseChildrenReply); +} +``` + +### Step 9: Build + test + +```bash +dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx 2>&1 | tail -10 +``` +Expected: 0 warnings, 0 errors. + +```bash +dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj --filter "FullyQualifiedName~LazyBrowseNodeTests" 2>&1 | tail -15 +``` +Expected: 6/6 PASS. + +```bash +dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj 2>&1 | tail -5 +``` +Expected: all green (no regression in `GalaxyRepositoryClientTests` etc.). + +### Step 10: Commit + +```bash +git add clients/dotnet/ +git commit -m "client/dotnet: LazyBrowseNode walker for lazy hierarchy browse" +``` + +--- + +## Task 3: Python — LazyBrowseNode walker + tests + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 2, 4, 5, 6 + +**Files:** +- Modify: `clients/python/src/zb_mom_ww_mxgateway/galaxy.py` +- Create or modify: `clients/python/src/zb_mom_ww_mxgateway/options.py` (add `BrowseChildrenOptions` dataclass; check if `DiscoverHierarchyOptions` already lives here — if so, sibling it) +- Modify: `clients/python/tests/test_galaxy.py` (add new tests at end of file; extend `FakeGalaxyStub` with BrowseChildren queue) + +### Step 1: Read the existing patterns first + +- `clients/python/src/zb_mom_ww_mxgateway/galaxy.py` — find `discover_hierarchy` (line 117) for the page-walking idiom, error mapping (`map_rpc_error`, `MxGatewayError`), and how the client uses `self._stub`. +- `clients/python/src/zb_mom_ww_mxgateway/options.py` — see existing options dataclass shape. +- `clients/python/tests/test_galaxy.py` — see `FakeGalaxyStub` (line 271), `FakeUnary` (286), `FakeStream` (304), and `test_discover_hierarchy_returns_proto_objects` (97) for the test pattern. + +### Step 2: Write failing tests + +Append to `clients/python/tests/test_galaxy.py`: + +```python +@pytest.mark.asyncio +async def test_browse_no_parent_returns_roots() -> None: + stub = FakeGalaxyStub() + stub.browse_children_replies.append( + _build_browse_reply( + children=[_obj(1, "Plant", is_area=True), _obj(99, "Other")], + child_has_children=[True, False], + cache_sequence=7, + ) + ) + async with GalaxyRepositoryClient( + ClientOptions(endpoint="fake", plaintext=True), + stub_factory=lambda channel: stub, + ) as client: + roots = await client.browse() + + assert len(roots) == 2 + assert roots[0].object.tag_name == "Plant" + assert roots[0].has_children_hint is True + assert roots[0].is_expanded is False + assert roots[1].has_children_hint is False + + +@pytest.mark.asyncio +async def test_browse_expand_populates_children_and_marks_expanded() -> None: + stub = FakeGalaxyStub() + stub.browse_children_replies.append( + _build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7)) + stub.browse_children_replies.append( + _build_browse_reply([_obj(2, "Mixer_001")], [False], 7)) + + async with GalaxyRepositoryClient( + ClientOptions(endpoint="fake", plaintext=True), + stub_factory=lambda channel: stub, + ) as client: + roots = await client.browse() + await roots[0].expand() + + assert roots[0].is_expanded is True + assert len(roots[0].children) == 1 + assert roots[0].children[0].object.tag_name == "Mixer_001" + assert len(stub.browse_children_calls) == 2 # roots + expand + + +@pytest.mark.asyncio +async def test_browse_expand_idempotent_no_second_rpc() -> None: + stub = FakeGalaxyStub() + stub.browse_children_replies.append( + _build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7)) + stub.browse_children_replies.append( + _build_browse_reply([_obj(2, "Mixer_001")], [False], 7)) + + async with GalaxyRepositoryClient( + ClientOptions(endpoint="fake", plaintext=True), + stub_factory=lambda channel: stub, + ) as client: + roots = await client.browse() + await roots[0].expand() + await roots[0].expand() + + assert len(stub.browse_children_calls) == 2 # roots + one expand + + +@pytest.mark.asyncio +async def test_browse_expand_unknown_parent_raises_mxgateway_error() -> None: + stub = FakeGalaxyStub() + stub.browse_children_replies.append( + _build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7)) + stub.browse_children_exceptions.append(_grpc_not_found("parent not found")) + + async with GalaxyRepositoryClient( + ClientOptions(endpoint="fake", plaintext=True), + stub_factory=lambda channel: stub, + ) as client: + roots = await client.browse() + with pytest.raises(MxGatewayError): + await roots[0].expand() + + +@pytest.mark.asyncio +async def test_browse_expand_multi_page_gathers_all_pages() -> None: + stub = FakeGalaxyStub() + stub.browse_children_replies.append( + _build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7)) + page1 = _build_browse_reply( + [_obj(2, "Mixer_001"), _obj(3, "Mixer_002")], [False, False], 7) + page1.next_page_token = "7:abc:2" + stub.browse_children_replies.append(page1) + stub.browse_children_replies.append( + _build_browse_reply([_obj(4, "Pump_001")], [False], 7)) + + async with GalaxyRepositoryClient( + ClientOptions(endpoint="fake", plaintext=True), + stub_factory=lambda channel: stub, + ) as client: + roots = await client.browse() + await roots[0].expand() + + names = [c.object.tag_name for c in roots[0].children] + assert names == ["Mixer_001", "Mixer_002", "Pump_001"] + assert len(stub.browse_children_calls) == 3 + assert stub.browse_children_calls[-1].page_token == "7:abc:2" + + +@pytest.mark.asyncio +async def test_browse_with_filter_forwards_to_request() -> None: + stub = FakeGalaxyStub() + stub.browse_children_replies.append(_build_browse_reply([], [], 7)) + + async with GalaxyRepositoryClient( + ClientOptions(endpoint="fake", plaintext=True), + stub_factory=lambda channel: stub, + ) as client: + await client.browse(BrowseChildrenOptions( + tag_name_glob="Mixer*", alarm_bearing_only=True)) + + req = stub.browse_children_calls[0] + assert req.tag_name_glob == "Mixer*" + assert req.alarm_bearing_only is True + + +def _obj(gid: int, tag: str, is_area: bool = False) -> galaxy_pb.GalaxyObject: + return galaxy_pb.GalaxyObject( + gobject_id=gid, tag_name=tag, browse_name=tag, is_area=is_area) + + +def _build_browse_reply(children, child_has_children, cache_sequence): + reply = galaxy_pb.BrowseChildrenReply( + total_child_count=len(children), cache_sequence=cache_sequence) + reply.children.extend(children) + reply.child_has_children.extend(child_has_children) + return reply + + +def _grpc_not_found(message: str): + # Match the way other tests in this file construct an grpc.aio.AioRpcError. + # If that helper exists already, reuse it; otherwise replicate the existing + # patterns inline. + import grpc + return grpc.aio.AioRpcError( + code=grpc.StatusCode.NOT_FOUND, + initial_metadata=grpc.aio.Metadata(), + trailing_metadata=grpc.aio.Metadata(), + details=message, + ) +``` + +Imports to add at top of file: +```python +from zb_mom_ww_mxgateway.options import BrowseChildrenOptions +``` + +If `GalaxyRepositoryClient` doesn't currently take a `stub_factory` kwarg, look at how existing tests inject the fake stub and copy that exact mechanism — the construction may use module-level patching instead. Match what works. + +### Step 3: Run tests to verify they fail + +```bash +cd clients/python && python -m pytest tests/test_galaxy.py -k "browse and not deploy" -v 2>&1 | tail -25 +``` + +Expected: collection errors or import errors — `BrowseChildrenOptions` and `client.browse()` don't exist. + +### Step 4: Add `BrowseChildrenOptions` to `options.py` + +```python +@dataclass(frozen=True) +class BrowseChildrenOptions: + """Filters and shape options for ``GalaxyRepositoryClient.browse``.""" + + category_ids: Sequence[int] = () + template_chain_contains: Sequence[str] = () + tag_name_glob: str | None = None + include_attributes: bool | None = None + alarm_bearing_only: bool = False + historized_only: bool = False +``` + +Match the existing `DiscoverHierarchyOptions` style: frozen dataclass if that's what it uses; `__slots__` if that's the convention. + +### Step 5: Extend `galaxy.py` + +Add to the top, after existing imports: +```python +from dataclasses import dataclass, field +from zb_mom_ww_mxgateway.options import BrowseChildrenOptions +``` + +Add a new module-level class `LazyBrowseNode` after the existing `GalaxyRepositoryClient` class definition (or as a nested class — pick whichever the file's style prefers): + +```python +class LazyBrowseNode: + """One node in a lazy-loaded Galaxy browse tree. + + Calling ``expand`` once fetches direct children and populates + ``children``. Subsequent calls are no-ops. + """ + + def __init__( + self, + client: "GalaxyRepositoryClient", + obj: galaxy_pb.GalaxyObject, + has_children_hint: bool, + options: BrowseChildrenOptions, + ) -> None: + self._client = client + self._object = obj + self._has_children_hint = has_children_hint + self._options = options + self._children: list["LazyBrowseNode"] = [] + self._is_expanded = False + + @property + def object(self) -> galaxy_pb.GalaxyObject: + return self._object + + @property + def has_children_hint(self) -> bool: + return self._has_children_hint + + @property + def children(self) -> list["LazyBrowseNode"]: + return list(self._children) + + @property + def is_expanded(self) -> bool: + return self._is_expanded + + async def expand(self) -> None: + if self._is_expanded: + return + async for child in self._client._iter_browse_children( + parent_gobject_id=self._object.gobject_id, + options=self._options): + self._children.append(child) + self._is_expanded = True +``` + +Add to `GalaxyRepositoryClient`: + +```python +_BROWSE_CHILDREN_PAGE_SIZE = 500 + +async def browse( + self, + options: BrowseChildrenOptions | None = None, +) -> list[LazyBrowseNode]: + """Returns root browse nodes (objects with no parent).""" + effective = options or BrowseChildrenOptions() + return [child async for child in self._iter_browse_children( + parent_gobject_id=None, options=effective)] + +async def _iter_browse_children( + self, + *, + parent_gobject_id: int | None, + options: BrowseChildrenOptions, +): + page_token = "" + seen: set[str] = set() + while True: + request = galaxy_pb.BrowseChildrenRequest( + page_size=self._BROWSE_CHILDREN_PAGE_SIZE, + page_token=page_token, + alarm_bearing_only=options.alarm_bearing_only, + historized_only=options.historized_only, + ) + if parent_gobject_id is not None: + request.parent_gobject_id = parent_gobject_id + request.category_ids.extend(options.category_ids) + request.template_chain_contains.extend(options.template_chain_contains) + if options.tag_name_glob: + request.tag_name_glob = options.tag_name_glob + if options.include_attributes is not None: + request.include_attributes = options.include_attributes + + try: + reply = await self._stub.BrowseChildren(request, metadata=self._auth_metadata()) + except grpc.aio.AioRpcError as error: + raise map_rpc_error("browse children", error) from error + + for index, obj in enumerate(reply.children): + hint = index < len(reply.child_has_children) and reply.child_has_children[index] + yield LazyBrowseNode(self, obj, hint, options) + + page_token = reply.next_page_token + if not page_token: + return + if page_token in seen: + raise MxGatewayError( + f"Galaxy BrowseChildren returned a repeated page token '{page_token}'.") + seen.add(page_token) +``` + +The exact `metadata=self._auth_metadata()` / `try/except` pattern must match the existing `discover_hierarchy` method — copy from line 117 of `galaxy.py`. + +### Step 6: Extend `FakeGalaxyStub` in `test_galaxy.py` + +Add (mirroring the existing `discover_hierarchy_replies` pattern): + +```python +def __init__(self) -> None: + # ... existing init ... + self.browse_children_calls: list = [] + self.browse_children_replies: list = [] + self.browse_children_exceptions: list = [] + +def BrowseChildren(self, request, metadata=None): + self.browse_children_calls.append(request) + if self.browse_children_exceptions: + raise self.browse_children_exceptions.pop(0) + if self.browse_children_replies: + return _await_returning(self.browse_children_replies.pop(0)) + return _await_returning(galaxy_pb.BrowseChildrenReply()) +``` + +(`_await_returning` is the existing helper that wraps a value in something awaitable to match grpc.aio's interface — if it doesn't exist by that name, find the equivalent in `FakeUnary` / `FakeStream`.) + +### Step 7: Run tests + +```bash +cd clients/python && python -m pytest tests/test_galaxy.py -v 2>&1 | tail -25 +``` + +Expected: all 6 new browse tests pass; existing tests still pass. + +### Step 8: Commit + +```bash +git add clients/python/ +git commit -m "client/python: LazyBrowseNode walker for lazy hierarchy browse" +``` + +--- + +## Task 4: Rust — LazyBrowseNode walker + tests + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 2, 3, 5, 6 + +**Files:** +- Modify: `clients/rust/src/galaxy.rs` (add `LazyBrowseNode`, `BrowseChildrenOptions`, `browse`, `browse_children_raw`) +- Modify: `clients/rust/tests/client_behavior.rs` (add tests; extend the `FakeGalaxy` impl from line 265+ to record BrowseChildren calls) + +### Step 1: Read existing patterns + +- `clients/rust/src/galaxy.rs` lines 145-186 — `discover_hierarchy` for paging idiom. +- `clients/rust/src/error.rs` — error variants. `GalaxyError::NotFound` or generic `Error::NotFound`. +- `clients/rust/tests/client_behavior.rs` — existing `discover_hierarchy` test for the pattern; the `FakeGalaxy` impl is in `clients/rust/src/galaxy.rs` lines 265+ as a test module (`#[cfg(test)] mod tests`). + +Wait — the `FakeGalaxy` impl is in `clients/rust/src/galaxy.rs:265` (visible from the earlier grep). Confirm: the fake might be inside `galaxy.rs` itself, not `tests/`. If so, tests live in the same file via `#[cfg(test)] mod tests`. Follow the existing convention. + +### Step 2: Write failing tests + +In whichever file holds the existing `discover_hierarchy` test, add 6 facts modeled on it: + +```rust +#[tokio::test] +async fn browse_no_parent_returns_roots() { + let (mut client, fake) = start_fake_with_replies(vec![ + build_browse_reply( + vec![obj(1, "Plant", true), obj(99, "Other", false)], + vec![true, false], + 7, + ), + ]).await; + + let roots = client.browse(None).await.expect("browse roots"); + assert_eq!(roots.len(), 2); + assert_eq!(roots[0].object().tag_name, "Plant"); + assert!(roots[0].has_children_hint()); + assert!(!roots[0].is_expanded()); + assert!(!roots[1].has_children_hint()); +} + +#[tokio::test] +async fn browse_expand_populates_children_and_marks_expanded() { /* ... */ } + +#[tokio::test] +async fn browse_expand_idempotent_no_second_rpc() { /* ... */ } + +#[tokio::test] +async fn browse_expand_unknown_parent_returns_not_found_error() { /* ... */ } + +#[tokio::test] +async fn browse_expand_multi_page_gathers_all_pages() { /* ... */ } + +#[tokio::test] +async fn browse_with_filter_forwards_to_request() { /* ... */ } +``` + +Helper functions `obj`, `build_browse_reply`, `start_fake_with_replies` — model on whatever helpers exist for the `discover_hierarchy` tests in the same file. + +### Step 3: Run tests to confirm failure + +```bash +cd clients/rust && cargo test --workspace browse_ 2>&1 | tail -15 +``` +Expected: compile errors — `client.browse`, `BrowseChildrenOptions`, `LazyBrowseNode` don't exist. + +### Step 4: Add `BrowseChildrenOptions` struct in `galaxy.rs` + +```rust +#[derive(Debug, Clone, Default)] +pub struct BrowseChildrenOptions { + pub category_ids: Vec, + pub template_chain_contains: Vec, + pub tag_name_glob: Option, + pub include_attributes: Option, + pub alarm_bearing_only: bool, + pub historized_only: bool, +} +``` + +### Step 5: Add `LazyBrowseNode` + +Because Rust ownership makes parent→client back-references awkward, use `Arc` and interior mutability for `Children`: + +```rust +use std::sync::Arc; +use tokio::sync::Mutex; + +pub struct LazyBrowseNode { + inner: Arc, +} + +struct LazyBrowseNodeInner { + client: GalaxyClient, // GalaxyClient must be Clone (or wrap in Arc) + object: GalaxyObject, + has_children_hint: bool, + options: BrowseChildrenOptions, + state: Mutex, +} + +struct LazyBrowseNodeState { + children: Vec, + is_expanded: bool, +} + +impl LazyBrowseNode { + pub fn object(&self) -> &GalaxyObject { &self.inner.object } + pub fn has_children_hint(&self) -> bool { self.inner.has_children_hint } + + pub async fn children(&self) -> Vec { + let state = self.inner.state.lock().await; + state.children.iter().map(|n| LazyBrowseNode { inner: Arc::clone(&n.inner) }).collect() + } + + pub async fn is_expanded(&self) -> bool { + self.inner.state.lock().await.is_expanded + } + + pub async fn expand(&self) -> Result<(), Error> { + let mut state = self.inner.state.lock().await; + if state.is_expanded { return Ok(()); } + + let new_children = self.inner.client.clone().browse_children_inner( + Some(self.inner.object.gobject_id), + self.inner.options.clone(), + ).await?; + + state.children = new_children; + state.is_expanded = true; + Ok(()) + } +} +``` + +If `GalaxyClient` doesn't implement `Clone`, refactor to wrap the inner state in `Arc>` for the channel — but check the existing struct first. The earlier grep showed `pub struct GalaxyClient { ... }` with `&mut self` methods, so it's likely not Clone. In that case, store a clone of the gRPC `Channel` (tonic channels ARE Clone) and rebuild the raw client per call: + +```rust +struct LazyBrowseNodeInner { + channel: tonic::transport::Channel, + auth: AuthMetadata, // whatever the existing client uses for headers + object: GalaxyObject, + has_children_hint: bool, + options: BrowseChildrenOptions, + state: Mutex, +} +``` + +Pick whichever fits the codebase with the least churn. Document the choice in the task summary. + +### Step 6: Add `browse` and `browse_children_raw` to `GalaxyClient` + +```rust +impl GalaxyClient { + pub async fn browse( + &mut self, + options: Option, + ) -> Result, Error> { + self.browse_children_inner(None, options.unwrap_or_default()).await + } + + pub async fn browse_children_raw( + &mut self, + request: BrowseChildrenRequest, + ) -> Result { + let mut request = tonic::Request::new(request); + self.attach_auth(&mut request)?; + let reply = self.raw.browse_children(request).await + .map_err(|status| map_status_error("browse children", status))? + .into_inner(); + Ok(reply) + } + + async fn browse_children_inner( + &mut self, + parent_gobject_id: Option, + options: BrowseChildrenOptions, + ) -> Result, Error> { + let mut nodes = Vec::new(); + let mut page_token = String::new(); + let mut seen: HashSet = HashSet::new(); + loop { + let mut request = BrowseChildrenRequest { + page_size: 500, + page_token: page_token.clone(), + category_ids: options.category_ids.clone(), + template_chain_contains: options.template_chain_contains.clone(), + tag_name_glob: options.tag_name_glob.clone().unwrap_or_default(), + alarm_bearing_only: options.alarm_bearing_only, + historized_only: options.historized_only, + ..Default::default() + }; + if let Some(pid) = parent_gobject_id { + request.parent = Some(browse_children_request::Parent::ParentGobjectId(pid)); + } + if let Some(attrs) = options.include_attributes { + request.include_attributes = Some(attrs); + } + + let reply = self.browse_children_raw(request).await?; + for (i, obj) in reply.children.into_iter().enumerate() { + let hint = reply.child_has_children.get(i).copied().unwrap_or(false); + nodes.push(self.make_lazy_node(obj, hint, options.clone())); + } + + page_token = reply.next_page_token; + if page_token.is_empty() { break; } + if !seen.insert(page_token.clone()) { + return Err(Error::Other(format!( + "Galaxy BrowseChildren returned a repeated page token '{page_token}'."))); + } + } + Ok(nodes) + } + + fn make_lazy_node( + &self, + object: GalaxyObject, + has_children_hint: bool, + options: BrowseChildrenOptions, + ) -> LazyBrowseNode { + // ... construct the LazyBrowseNodeInner with whatever channel/auth state is needed ... + } +} +``` + +The exact `Parent::ParentGobjectId` enum variant name comes from prost's oneof generation — check `clients/rust/src/generated/galaxy_repository.v1.rs` for the exact module path. If `parent` is generated as `parent: Option`, the snippet above is correct. + +### Step 7: Extend `FakeGalaxy` impl + +In whatever `#[cfg(test)] mod tests { ... impl GalaxyRepository for FakeGalaxy { ... } }` block exists (the Task 7 stub at line ~265 of `galaxy.rs`), replace the `unimplemented!()` body of `browse_children` with a real recorded-call implementation: + +```rust +async fn browse_children( + &self, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + self.browse_children_calls.lock().await.push(req.clone()); + if let Some(error) = self.browse_children_errors.lock().await.pop() { + return Err(error); + } + let reply = self.browse_children_replies + .lock().await + .pop_front() + .unwrap_or_default(); + Ok(Response::new(reply)) +} +``` + +And add the corresponding fields to the `FakeGalaxy` struct: +```rust +browse_children_calls: Mutex>, +browse_children_replies: Mutex>, +browse_children_errors: Mutex>, +``` + +### Step 8: Build + test + +```bash +cd clients/rust && cargo check --workspace 2>&1 | tail -15 +``` +Expected: clean. + +```bash +cd clients/rust && cargo test --workspace 2>&1 | tail -10 +``` +Expected: all green (new browse tests + existing discover_hierarchy). + +```bash +cd clients/rust && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10 +``` +Expected: clean. + +### Step 9: Commit + +```bash +git add clients/rust/ +git commit -m "client/rust: LazyBrowseNode walker for lazy hierarchy browse" +``` + +--- + +## Task 5: Go — LazyBrowseNode walker + tests + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 2, 3, 4, 6 + +**Files:** +- Modify: `clients/go/mxgateway/galaxy.go` +- Modify: `clients/go/mxgateway/options.go` (add `BrowseChildrenOptions`) +- Modify: `clients/go/mxgateway/galaxy_test.go` (add tests + extend `fakeGalaxyServer` from line 370) + +### Step 1: Read existing patterns + +- `clients/go/mxgateway/galaxy.go:150` — `DiscoverHierarchy` paging idiom. +- `clients/go/mxgateway/galaxy_test.go:96` — `TestGalaxyDiscoverHierarchyReturnsObjects` for the test pattern. +- `clients/go/mxgateway/galaxy_test.go:370` — `fakeGalaxyServer` struct (extend it). +- `clients/go/mxgateway/options.go` — existing `DiscoverHierarchyOptions` to mirror. + +### Step 2: Failing tests + +Append to `galaxy_test.go`: + +```go +func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) { /* ... */ } +func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) { /* ... */ } +func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) { /* ... */ } +func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) { /* ... */ } +func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) { /* ... */ } +func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) { /* ... */ } +``` + +Each follows the existing `Test...DiscoverHierarchy...` pattern: spin up a `fakeGalaxyServer`, dial a `GalaxyClient` against it, queue replies/errors via the fake, assert behavior and recorded calls. + +### Step 3-7: Implement + +Add to `options.go`: +```go +type BrowseChildrenOptions struct { + CategoryIds []int32 + TemplateChainContains []string + TagNameGlob string + IncludeAttributes *bool + AlarmBearingOnly bool + HistorizedOnly bool +} +``` + +Add to `galaxy.go`: +```go +type LazyBrowseNode struct { + client *GalaxyClient + object *pb.GalaxyObject + hasChildrenHint bool + options BrowseChildrenOptions + mu sync.Mutex + children []*LazyBrowseNode + isExpanded bool +} + +func (n *LazyBrowseNode) Object() *pb.GalaxyObject { return n.object } +func (n *LazyBrowseNode) HasChildrenHint() bool { return n.hasChildrenHint } +func (n *LazyBrowseNode) Children() []*LazyBrowseNode { + n.mu.Lock(); defer n.mu.Unlock() + out := make([]*LazyBrowseNode, len(n.children)) + copy(out, n.children) + return out +} +func (n *LazyBrowseNode) IsExpanded() bool { + n.mu.Lock(); defer n.mu.Unlock() + return n.isExpanded +} + +func (n *LazyBrowseNode) Expand(ctx context.Context) error { + n.mu.Lock(); defer n.mu.Unlock() + if n.isExpanded { return nil } + parentID := n.object.GobjectId + children, err := n.client.browseChildrenInner(ctx, &parentID, n.options) + if err != nil { return err } + n.children = children + n.isExpanded = true + return nil +} + +const browseChildrenPageSize = 500 + +func (c *GalaxyClient) Browse(ctx context.Context, opts *BrowseChildrenOptions) ([]*LazyBrowseNode, error) { + effective := BrowseChildrenOptions{} + if opts != nil { effective = *opts } + return c.browseChildrenInner(ctx, nil, effective) +} + +func (c *GalaxyClient) BrowseChildrenRaw(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) { + callCtx, cancel := c.callContext(ctx) + defer cancel() + return c.raw.BrowseChildren(callCtx, req) +} + +func (c *GalaxyClient) browseChildrenInner( + ctx context.Context, + parentGobjectID *int32, + opts BrowseChildrenOptions, +) ([]*LazyBrowseNode, error) { + var nodes []*LazyBrowseNode + pageToken := "" + seen := map[string]struct{}{} + for { + req := &pb.BrowseChildrenRequest{ + PageSize: browseChildrenPageSize, + PageToken: pageToken, + CategoryIds: opts.CategoryIds, + TemplateChainContains: opts.TemplateChainContains, + TagNameGlob: opts.TagNameGlob, + AlarmBearingOnly: opts.AlarmBearingOnly, + HistorizedOnly: opts.HistorizedOnly, + } + if parentGobjectID != nil { + req.Parent = &pb.BrowseChildrenRequest_ParentGobjectId{ParentGobjectId: *parentGobjectID} + } + if opts.IncludeAttributes != nil { + req.IncludeAttributes = opts.IncludeAttributes + } + + reply, err := c.BrowseChildrenRaw(ctx, req) + if err != nil { return nil, mapGalaxyError("browse children", err) } + + for i, obj := range reply.Children { + hint := i < len(reply.ChildHasChildren) && reply.ChildHasChildren[i] + nodes = append(nodes, &LazyBrowseNode{ + client: c, object: obj, hasChildrenHint: hint, options: opts, + }) + } + + pageToken = reply.NextPageToken + if pageToken == "" { return nodes, nil } + if _, dup := seen[pageToken]; dup { + return nil, fmt.Errorf("Galaxy BrowseChildren returned a repeated page token %q", pageToken) + } + seen[pageToken] = struct{}{} + } +} +``` + +Adjust the `BrowseChildrenRequest_ParentGobjectId` type name to match what protoc-gen-go actually generated for the oneof — check `galaxy_repository.pb.go` for the exact identifier. Adjust `mapGalaxyError` to whatever the existing client uses (look at how `DiscoverHierarchy` wraps errors). + +### Step 8: Build + test + +```bash +cd clients/go && gofmt -l ./... 2>&1 +cd clients/go && go build ./... 2>&1 | tail -5 +cd clients/go && go test ./... 2>&1 | tail -5 +``` +Expected: gofmt clean, build clean, tests green. + +### Step 9: Commit + +```bash +git add clients/go/ +git commit -m "client/go: LazyBrowseNode walker for lazy hierarchy browse" +``` + +--- + +## Task 6: Java — LazyBrowseNode walker + tests + +**Classification:** standard +**Estimated implement time:** ~6 min (~10 min if proto regen needed) +**Parallelizable with:** Tasks 2, 3, 4, 5 +**Blocked by:** Task 1 (toolchain bootstrap must succeed) + +If Task 1 reported failure, **skip this task** and have the doc-update task (Task 7) cover only Java's README snippet without expecting a working build. + +**Files:** +- Modify: `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClient.java` +- Create: `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/BrowseChildrenOptions.java` +- Create: `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/LazyBrowseNode.java` +- Modify: `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClientTests.java` + +### Step 1: Regen protos to pick up BrowseChildren + +```bash +cd clients/java +[ -n "$JAVA_HOME" ] || export JAVA_HOME=$(/usr/libexec/java_home -v 21) +gradle generateProto 2>&1 | tail -10 +``` + +Expected: `BUILD SUCCESSFUL`. The generated tree under `src/main/generated/main/{java,grpc}/galaxy_repository/` should now contain `BrowseChildrenRequest`, `BrowseChildrenReply`, and a `browseChildren` method on the generated stub. + +Verify: +```bash +grep -l "browseChildren\|BrowseChildren" clients/java/zb-mom-ww-mxgateway-client/src/main/generated/ -r 2>&1 | head +``` +Expected: at least one hit per generated file (the message class + the gRPC stub). + +### Step 2-7: Implement the walker + +Follow the same pattern as the other languages, adapted for Java. Use `synchronized` for the expand-once invariant; `CompletableFuture` for async if the existing client surface is async (check `GalaxyRepositoryClient.java`). If the existing client is sync (using `BlockingStub`), make the walker sync too — match the file. + +Test layout: the existing `GalaxyRepositoryClientTests.java` uses an `InProcessServer` or `MockServer` pattern — copy that exactly. Don't invent a new test fixture. + +### Step 8: Build + test + +```bash +cd clients/java && gradle build 2>&1 | tail -15 +``` +Expected: `BUILD SUCCESSFUL`. + +### Step 9: Commit + +```bash +git add clients/java/ +git commit -m "client/java: LazyBrowseNode walker for lazy hierarchy browse" +``` + +If Task 1 reported failure: skip this entire task. Report "Java skipped — toolchain unavailable" and proceed. + +--- + +## Task 7: README updates — walker example per client + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Tasks 2-6 once they're done (or as a final consolidation task) + +**Files:** +- Modify: `clients/dotnet/README.md` +- Modify: `clients/python/README.md` +- Modify: `clients/rust/README.md` +- Modify: `clients/go/README.md` +- Modify: `clients/java/README.md` + +### Step 1: For each README + +Find the existing "Browsing lazily" section (added in commit `0d6193c`). Below the existing raw-RPC snippet, add a short "High-level walker" subsection: + +``` +#### High-level walker + +For UI trees, the client provides a `LazyBrowseNode` walker that handles +sibling pagination and the `child_has_children` hint for you: + +[code snippet per language] + +`ExpandAsync` is idempotent — calling it twice fires only one RPC. To +refresh after a Galaxy redeploy, call `BrowseAsync` again from the root. +``` + +Per-language snippet (5-10 lines each): + +**.NET:** +```csharp +using GalaxyRepositoryClient client = ...; +var roots = await client.BrowseAsync(); +foreach (var node in roots) +{ + if (node.HasChildrenHint) await node.ExpandAsync(); + foreach (var child in node.Children) Console.WriteLine(child.Object.TagName); +} +``` + +**Python:** +```python +async with GalaxyRepositoryClient(opts) as client: + roots = await client.browse() + for node in roots: + if node.has_children_hint: + await node.expand() + for child in node.children: + print(child.object.tag_name) +``` + +**Rust:** +```rust +let mut client = GalaxyClient::connect(opts).await?; +let roots = client.browse(None).await?; +for node in &roots { + if node.has_children_hint() { + node.expand().await?; + } + for child in node.children().await { + println!("{}", child.object().tag_name); + } +} +``` + +**Go:** +```go +client, _ := mxgateway.DialGalaxy(ctx, opts) +roots, _ := client.Browse(ctx, nil) +for _, node := range roots { + if node.HasChildrenHint() { + _ = node.Expand(ctx) + } + for _, child := range node.Children() { + fmt.Println(child.Object().GetTagName()) + } +} +``` + +**Java:** +```java +GalaxyRepositoryClient client = ...; +List roots = client.browseAsync(null).get(); +for (LazyBrowseNode node : roots) { + if (node.hasChildrenHint()) node.expandAsync().get(); + node.getChildren().forEach(c -> System.out.println(c.getObject().getTagName())); +} +``` + +### Step 2: Commit + +```bash +git add clients/dotnet/README.md clients/python/README.md clients/rust/README.md clients/go/README.md clients/java/README.md +git commit -m "docs: per-client High-level walker example using LazyBrowseNode" +``` + +--- + +## Task 8: Final integration build and verification + +**Classification:** standard +**Estimated implement time:** ~3 min +**Parallelizable with:** none (final gate) + +### Step 1: Build everything that's locally buildable + +```bash +dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx 2>&1 | tail -5 +cd clients/rust && cargo build --workspace 2>&1 | tail -3 && cd - +cd clients/go && go build ./... 2>&1 | tail -3 && cd - +cd clients/python && python -m pytest --collect-only 2>&1 | tail -3 && cd - +``` +If Java toolchain installed (Task 1 succeeded): +```bash +cd clients/java && gradle build 2>&1 | tail -5 && cd - +``` + +### Step 2: Run all client tests + +```bash +dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj 2>&1 | tail -5 +cd clients/python && python -m pytest 2>&1 | tail -5 && cd - +cd clients/rust && cargo test --workspace 2>&1 | tail -5 && cd - +cd clients/go && go test ./... 2>&1 | tail -5 && cd - +cd clients/java && gradle test 2>&1 | tail -5 && cd - # if Java succeeded +``` +All should be green. + +### Step 3: Self-review the cumulative diff + +```bash +git log --oneline main..HEAD +git diff --stat main..HEAD | tail -30 +``` + +Confirm each new commit corresponds to one plan task. + +### Step 4: Stop — do NOT push or open a PR + +Final commit only happens after the user reviews this task's output. diff --git a/docs/plans/2026-05-28-client-walker-implementation.md.tasks.json b/docs/plans/2026-05-28-client-walker-implementation.md.tasks.json new file mode 100644 index 0000000..12f6370 --- /dev/null +++ b/docs/plans/2026-05-28-client-walker-implementation.md.tasks.json @@ -0,0 +1,15 @@ +{ + "planPath": "docs/plans/2026-05-28-client-walker-implementation.md", + "tasks": [ + {"id": 23, "subject": "Task 0: Branch state check", "status": "pending"}, + {"id": 24, "subject": "Task 1: Java toolchain bootstrap", "status": "pending", "blockedBy": [23]}, + {"id": 25, "subject": "Task 2: .NET LazyBrowseNode walker + 6 tests", "status": "pending", "blockedBy": [23]}, + {"id": 26, "subject": "Task 3: Python LazyBrowseNode walker + 6 tests", "status": "pending", "blockedBy": [23]}, + {"id": 27, "subject": "Task 4: Rust LazyBrowseNode walker + 6 tests", "status": "pending", "blockedBy": [23]}, + {"id": 28, "subject": "Task 5: Go LazyBrowseNode walker + 6 tests", "status": "pending", "blockedBy": [23]}, + {"id": 29, "subject": "Task 6: Java LazyBrowseNode walker + tests", "status": "pending", "blockedBy": [23, 24]}, + {"id": 30, "subject": "Task 7: README walker examples for all 5 clients", "status": "pending", "blockedBy": [25, 26, 27, 28]}, + {"id": 31, "subject": "Task 8: Final integration build + verification", "status": "pending", "blockedBy": [29, 30]} + ], + "lastUpdated": "2026-05-28T18:30:00Z" +}