97e583e96b
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.
1567 lines
56 KiB
Markdown
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.
|