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

56 KiB

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 LazyBrowseNodes. 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

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

brew install gradle 2>&1 | tail -5

Expected: clean install.

Step 3: Verify

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

echo "$JAVA_HOME"

If empty, find the JDK path:

/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

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).

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)

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):

/// <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

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

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):

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):

/// <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

dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx 2>&1 | tail -10

Expected: 0 warnings, 0 errors.

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.

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

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:

@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:

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

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

@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:

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):

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:

_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):

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

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

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:

#[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

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

#[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:

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:

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

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:

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:

browse_children_calls: Mutex<Vec<BrowseChildrenRequest>>,
browse_children_replies: Mutex<VecDeque<BrowseChildrenReply>>,
browse_children_errors: Mutex<Vec<Status>>,

Step 8: Build + test

cd clients/rust && cargo check --workspace 2>&1 | tail -15

Expected: clean.

cd clients/rust && cargo test --workspace 2>&1 | tail -10

Expected: all green (new browse tests + existing discover_hierarchy).

cd clients/rust && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10

Expected: clean.

Step 9: Commit

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:150DiscoverHierarchy paging idiom.
  • clients/go/mxgateway/galaxy_test.go:96TestGalaxyDiscoverHierarchyReturnsObjects for the test pattern.
  • clients/go/mxgateway/galaxy_test.go:370fakeGalaxyServer struct (extend it).
  • clients/go/mxgateway/options.go — existing DiscoverHierarchyOptions to mirror.

Step 2: Failing tests

Append to galaxy_test.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:

type BrowseChildrenOptions struct {
    CategoryIds           []int32
    TemplateChainContains []string
    TagNameGlob           string
    IncludeAttributes     *bool
    AlarmBearingOnly      bool
    HistorizedOnly        bool
}

Add to galaxy.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

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

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

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:

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

cd clients/java && gradle build 2>&1 | tail -15

Expected: BUILD SUCCESSFUL.

Step 9: Commit

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:

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:

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:

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:

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:

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

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

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):

cd clients/java && gradle build 2>&1 | tail -5 && cd -

Step 2: Run all client tests

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

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.