Files
mxaccessgw/docs/plans/2026-05-28-client-walker-implementation.md
T
Joseph Doherty 97e583e96b docs: implementation plan for per-language LazyBrowseNode walker
9 tasks: Java toolchain install (Homebrew), 5 parallel per-language
walker implementations, README updates, final verification. Java
walker is gated on toolchain bootstrap success; other languages
proceed independently if Java fails.
2026-05-28 14:17:52 -04:00

1567 lines
56 KiB
Markdown

# 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<LazyBrowseNode> 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<LazyBrowseNode> 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<LazyBrowseNode> 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<LazyBrowseNode> 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<MxGatewayException>(() => 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<LazyBrowseNode> 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<GalaxyObject> children,
IReadOnlyList<bool> 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
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
Task<BrowseChildrenReply> 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;
/// <summary>
/// Filters and shape options for <see cref="GalaxyRepositoryClient.BrowseAsync"/>.
/// Mirror of <see cref="DiscoverHierarchyOptions"/> for the lazy-browse path.
/// </summary>
public sealed class BrowseChildrenOptions
{
/// <summary>Restrict to children whose Galaxy category is in this set.</summary>
public IReadOnlyList<int> CategoryIds { get; init; } = [];
/// <summary>Restrict to children whose template chain contains any of these tokens.</summary>
public IReadOnlyList<string> TemplateChainContains { get; init; } = [];
/// <summary>Optional glob-style filter on <c>tag_name</c>.</summary>
public string? TagNameGlob { get; init; }
/// <summary>Whether to populate each <c>GalaxyObject.Attributes</c>. Null leaves the server default.</summary>
public bool? IncludeAttributes { get; init; }
/// <summary>Restrict to children that bear at least one alarm attribute.</summary>
public bool AlarmBearingOnly { get; init; }
/// <summary>Restrict to children that have at least one historized attribute.</summary>
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;
/// <summary>
/// One node in a lazy-loaded Galaxy browse tree. Holds the underlying
/// <see cref="GalaxyObject"/> and exposes <see cref="ExpandAsync"/> 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.
/// </summary>
public sealed class LazyBrowseNode
{
private readonly GalaxyRepositoryClient _client;
private readonly BrowseChildrenOptions _options;
private readonly List<LazyBrowseNode> _children = [];
private bool _isExpanded;
internal LazyBrowseNode(
GalaxyRepositoryClient client,
GalaxyObject @object,
bool hasChildrenHint,
BrowseChildrenOptions options)
{
_client = client;
Object = @object;
HasChildrenHint = hasChildrenHint;
_options = options;
}
/// <summary>The underlying Galaxy object for this node.</summary>
public GalaxyObject Object { get; }
/// <summary>True when the server reports this node has at least one matching descendant.</summary>
public bool HasChildrenHint { get; }
/// <summary>Direct children loaded by <see cref="ExpandAsync"/>; empty until then.</summary>
public IReadOnlyList<LazyBrowseNode> Children => _children;
/// <summary>True after the first <see cref="ExpandAsync"/> call completes.</summary>
public bool IsExpanded => _isExpanded;
/// <summary>
/// Fetches direct children from the gateway and populates
/// <see cref="Children"/>. Idempotent: subsequent calls are no-ops.
/// </summary>
public async Task ExpandAsync(CancellationToken cancellationToken = default)
{
if (_isExpanded)
{
return;
}
string pageToken = string.Empty;
HashSet<string> 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;
/// <summary>Returns root-level browse nodes (objects with no parent).</summary>
public Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(CancellationToken cancellationToken = default)
=> BrowseAsync(null, cancellationToken);
/// <summary>Returns root-level browse nodes filtered by the given options.</summary>
public async Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(
BrowseChildrenOptions? options,
CancellationToken cancellationToken = default)
{
BrowseChildrenOptions effective = options ?? new BrowseChildrenOptions();
List<LazyBrowseNode> roots = [];
string pageToken = string.Empty;
HashSet<string> 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;
}
/// <summary>Issues a raw BrowseChildren RPC without result wrapping.</summary>
public Task<BrowseChildrenReply> 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
/// <summary>Records BrowseChildren RPC calls.</summary>
public List<(BrowseChildrenRequest Request, CallOptions CallOptions)> BrowseChildrenCalls { get; } = [];
/// <summary>Default reply for BrowseChildren when the queue is empty.</summary>
public BrowseChildrenReply BrowseChildrenReply { get; set; } = new();
/// <summary>Queue of replies, dequeued in FIFO order.</summary>
public Queue<BrowseChildrenReply> BrowseChildrenReplies { get; } = new();
/// <summary>Queue of exceptions, dequeued in FIFO order.</summary>
public Queue<Exception> BrowseChildrenExceptions { get; } = new();
/// <summary>Records the call and returns a queued reply, queued exception, or the default.</summary>
public Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions)
{
BrowseChildrenCalls.Add((request, callOptions));
if (BrowseChildrenExceptions.TryDequeue(out Exception? exception))
{
return Task.FromException<BrowseChildrenReply>(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<i32>,
pub template_chain_contains: Vec<String>,
pub tag_name_glob: Option<String>,
pub include_attributes: Option<bool>,
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<LazyBrowseNodeInner>,
}
struct LazyBrowseNodeInner {
client: GalaxyClient, // GalaxyClient must be Clone (or wrap in Arc)
object: GalaxyObject,
has_children_hint: bool,
options: BrowseChildrenOptions,
state: Mutex<LazyBrowseNodeState>,
}
struct LazyBrowseNodeState {
children: Vec<LazyBrowseNode>,
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<LazyBrowseNode> {
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<Mutex<_>>` 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<LazyBrowseNodeState>,
}
```
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<BrowseChildrenOptions>,
) -> Result<Vec<LazyBrowseNode>, Error> {
self.browse_children_inner(None, options.unwrap_or_default()).await
}
pub async fn browse_children_raw(
&mut self,
request: BrowseChildrenRequest,
) -> Result<BrowseChildrenReply, Error> {
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<i32>,
options: BrowseChildrenOptions,
) -> Result<Vec<LazyBrowseNode>, Error> {
let mut nodes = Vec::new();
let mut page_token = String::new();
let mut seen: HashSet<String> = 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<browse_children_request::Parent>`, 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<BrowseChildrenRequest>,
) -> Result<Response<BrowseChildrenReply>, 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<Vec<BrowseChildrenRequest>>,
browse_children_replies: Mutex<VecDeque<BrowseChildrenReply>>,
browse_children_errors: Mutex<Vec<Status>>,
```
### 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<LazyBrowseNode> 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.