diff --git a/docs/plans/2026-05-28-lazy-browse-implementation.md b/docs/plans/2026-05-28-lazy-browse-implementation.md new file mode 100644 index 0000000..655b235 --- /dev/null +++ b/docs/plans/2026-05-28-lazy-browse-implementation.md @@ -0,0 +1,1078 @@ +# Lazy Browse (BrowseChildren) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Add an OPC UA-style `BrowseChildren` gRPC RPC and make the dashboard's BrowsePage load one level at a time, both backed by a new parent→children index on the existing shared `IGalaxyHierarchyCache`. + +**Architecture:** A new `GalaxyBrowseProjector` is added next to the existing `GalaxyHierarchyProjector`. It reuses the cache's immutable `GalaxyHierarchyCacheEntry` and a new `ChildrenByParent` map built once per refresh inside `GalaxyHierarchyIndex`. Two consumers call the projector: a new `BrowseChildren` handler on `GalaxyRepositoryGrpcService`, and a new in-process `IDashboardBrowseService` used by `BrowsePage.razor` for click-to-expand rendering. The full Galaxy SQL refresh path is unchanged. + +**Tech Stack:** .NET 10 / ASP.NET Core gRPC / Blazor Server (Bootstrap CSS only) / xUnit. Wire contract: proto3 (`additive only` per the file's wire-compatibility policy). + +**Design source:** `docs/plans/2026-05-28-lazy-browse-design.md`. + +### Deliberate deviation from the design doc + +The design's Section 2 said stale page tokens return `FailedPrecondition`. The existing `DiscoverHierarchy` returns `InvalidArgument` for the same condition (`GalaxyRepositoryGrpcService.cs:258-263`). Section 1 of the design said "same as today's DiscoverHierarchy." We follow today's behavior — `InvalidArgument` — so the two RPCs stay consistent. Update the design doc's error table accordingly in Task 10. + +--- + +## Task 0: Worktree & branch + +**Classification:** trivial +**Estimated implement time:** ~1 min +**Parallelizable with:** none + +**Files:** +- Create: (none) + +**Step 1: Verify clean working tree from current `main`** + +Run: `git status --short` +Expected: only the untracked `*-docs-*.md` files listed in the repo gitStatus snapshot are allowed; no modified tracked files. + +**Step 2: Branch** + +Run: `git checkout -b feat/lazy-browse-children` +Expected: `Switched to a new branch 'feat/lazy-browse-children'` + +No commit. + +--- + +## Task 1: Add proto contract for `BrowseChildren` + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none (everything else needs the generated types) + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto:21-33` (add RPC), end of file (add messages) +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs` (extend) + +**Step 1: Write the failing contract round-trip test** + +Append a new fact to `ProtobufContractRoundTripTests` (match the file's existing pattern — read it first to see how it builds messages and asserts serialize/parse identity). The test should: +- Build a `BrowseChildrenRequest` with every scalar/oneof field set (one option of the `parent` oneof per assertion, all three exercised in separate Theory rows or three Facts). +- Build a `BrowseChildrenReply` with two `GalaxyObject`s, two `child_has_children` booleans, `next_page_token`, `total_child_count`, and `cache_sequence` set. +- Round-trip both via `parser.ParseFrom(message.ToByteArray())` and assert `Equals`. + +**Step 2: Run test to verify it fails** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ProtobufContractRoundTripTests` +Expected: compile failure (types not yet generated). + +**Step 3: Edit the proto** + +In `src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto`: + +Inside `service GalaxyRepository`, after `WatchDeployEvents`, add: + +```proto + // Returns the direct children of a parent object (or the root objects when + // `parent` is unset). Designed for OPC UA-style lazy expand: clients walk + // one level at a time instead of paging the full hierarchy. Filters mirror + // DiscoverHierarchy exactly. Backed by the same shared hierarchy cache. + rpc BrowseChildren(BrowseChildrenRequest) returns (BrowseChildrenReply); +``` + +At the end of the file (after `GalaxyAttribute`), append: + +```proto +message BrowseChildrenRequest { + // Parent selector. Empty oneof returns root objects (parent_gobject_id == 0). + oneof parent { + int32 parent_gobject_id = 1; + string parent_tag_name = 2; + string parent_contained_path = 3; + } + + // Maximum number of direct children to return. Server default 500; cap 5000. + int32 page_size = 4; + // Opaque token returned by a previous BrowseChildren response. Bound to the + // cache sequence, parent selector, and the filter set; a mismatch returns + // InvalidArgument. + string page_token = 5; + + // --- Filter parity with DiscoverHierarchy. AND-combined. --- + repeated int32 category_ids = 6; + repeated string template_chain_contains = 7; + string tag_name_glob = 8; + optional bool include_attributes = 9; + bool alarm_bearing_only = 10; + bool historized_only = 11; +} + +message BrowseChildrenReply { + // Direct children matching the filter, sorted areas-first then by + // case-insensitive display name (same order as the dashboard tree). + repeated GalaxyObject children = 1; + // Non-empty when another page of siblings is available. + string next_page_token = 2; + // Total matching direct children of the parent (post-filter). + int32 total_child_count = 3; + // Parallel array, indexed with `children`. True when the child has at least + // one matching descendant under the same filter set. Lets a UI choose + // whether to draw an expand triangle without an extra round trip. + repeated bool child_has_children = 4; + // Cache sequence this reply was projected from. Clients may pass it back as + // part of the page_token contract. Mismatch on the next page -> InvalidArgument. + uint64 cache_sequence = 5; +} +``` + +**Step 4: Regenerate and verify** + +Run: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` +Expected: succeeds; `src/ZB.MOM.WW.MxGateway.Contracts/Generated/GalaxyRepository.cs` now contains `BrowseChildrenRequest`, `BrowseChildrenReply`, and `GalaxyRepositoryBase.BrowseChildren`. + +**Step 5: Re-run the test** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ProtobufContractRoundTripTests` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto \ + src/ZB.MOM.WW.MxGateway.Contracts/Generated/ \ + src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs +git commit -m "contracts: add BrowseChildren RPC for lazy hierarchy browse" +``` + +--- + +## Task 2: Extend `GalaxyHierarchyIndex` with `ChildrenByParent` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (Tasks 3, 4 read this) + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs` (add property, populate in `Build`) +- Create: `src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs` + +**Step 1: Write the failing test** + +Create `src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs` with four facts: + +1. `ChildrenByParent_RootsUnderSentinelZero` — three objects, all `ParentGobjectId == 0`, → `index.ChildrenByParent[0]` has all three. +2. `ChildrenByParent_NestedHierarchy_LinksParentToChildren` — area `A` (id 1, parent 0), object `B` (id 2, parent 1), object `C` (id 3, parent 2) → `[0]={A}`, `[1]={B}`, `[2]={C}`. +3. `ChildrenByParent_SelfParentedObject_DoesNotRecurse` — object with `GobjectId == ParentGobjectId == 5` is included under sentinel `0` (parent ignored when equal to self), never under itself. +4. `ChildrenByParent_SortsAreasFirstThenByDisplayName` — under one parent: instance `"zebra"`, area `"alpha"`, area `"beta"` → returned order `[alpha, beta, zebra]`, areas-first, OrdinalIgnoreCase within group. + +Build `GalaxyObject` lists directly with `new GalaxyObject { GobjectId = ..., ParentGobjectId = ..., IsArea = ..., ContainedName = ..., BrowseName = ..., TagName = ... }`. Call `GalaxyHierarchyIndex.Build(objects)`. + +**Step 2: Run the tests to verify they fail** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyHierarchyIndexTests` +Expected: compile failure (no `ChildrenByParent` property yet). + +**Step 3: Add `ChildrenByParent` and populate it** + +In `GalaxyHierarchyIndex.cs`: + +- Add ctor parameter and read-only property: + + ```csharp + public IReadOnlyDictionary> ChildrenByParent { get; } + ``` + + Include it in the private ctor and in `Empty` (use `new Dictionary>()`). + +- In `Build`, after the existing per-object loop builds `views`/`viewsById`/`tagsByAddress`, add a single pass: + + ```csharp + Dictionary> childrenByParent = new(); + foreach (GalaxyObjectView view in views) + { + int parentKey = view.Object.ParentGobjectId; + // Treat self-parented (corrupt) rows as roots; matches DashboardBrowseTreeBuilder. + if (parentKey == view.Object.GobjectId) + { + parentKey = 0; + } + if (!childrenByParent.TryGetValue(parentKey, out List? bucket)) + { + bucket = []; + childrenByParent[parentKey] = bucket; + } + bucket.Add(view); + } + ``` + +- Sort each bucket: areas first, then `OrdinalIgnoreCase` on display name. The display name is `view.Object.BrowseName ?? view.Object.ContainedName ?? view.Object.TagName` (mirror `DashboardBrowseNode.DisplayName`). Add a private helper `DisplayNameOf(view)` so the projector and tests can reuse it later. + + ```csharp + foreach (List bucket in childrenByParent.Values) + { + bucket.Sort(CompareByAreaThenDisplayName); + } + + Dictionary> readOnlyChildren = new(childrenByParent.Count); + foreach (KeyValuePair> kvp in childrenByParent) + { + readOnlyChildren[kvp.Key] = kvp.Value; + } + ``` + +- Pass `readOnlyChildren` into the ctor. + +**Step 4: Run the tests to verify they pass** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyHierarchyIndexTests` +Expected: 4/4 PASS. + +**Step 5: Run the full server test suite to catch regressions** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj` +Expected: all green. + +**Step 6: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs \ + src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs +git commit -m "galaxy: add ChildrenByParent index for level-at-a-time browse" +``` + +--- + +## Task 3: Add `GalaxyBrowseProjector.ProjectChildren` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 5 (dashboard service — relies only on this projector via abstraction) + +**Files:** +- Create: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs` +- Create: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs` +- Create: `src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs` + +**Step 1: Write failing tests** + +In `GalaxyBrowseProjectorTests.cs`, build a fixture with five objects under one root area: + +``` +Area "Plant" (id 1, parent 0) +├── Instance "Mixer_001" (id 2, parent 1) +├── Instance "Mixer_002" (id 3, parent 1) +├── Area "Line_A" (id 4, parent 1) +│ └── Instance "Sensor_A1" (id 5, parent 4) +└── Instance "Pump_001" (id 6, parent 1) +``` + +Tests (one fact each): + +1. `Project_NoParent_ReturnsRootArea` — empty `parent` oneof → one child: `Plant`. `ChildHasChildren[0] == true`. +2. `Project_ByParentGobjectId_ReturnsDirectChildren` — `ParentGobjectId = 1` → 4 children in order `[Line_A, Mixer_001, Mixer_002, Pump_001]` (area first, then alpha). `ChildHasChildren = [true, false, false, false]`. +3. `Project_ByParentTagName_ResolvesParent` — `ParentTagName = "Plant"` → same 4 children. +4. `Project_ByParentContainedPath_ResolvesParent` — `ParentContainedPath = "Plant"` → same 4 children. +5. `Project_UnknownParent_ThrowsNotFound` — `ParentGobjectId = 999` → `RpcException` with `StatusCode.NotFound`. +6. `Project_PagedAcrossSiblings_ReturnsEverySiblingOnce` — page size 2 over 4 children at parent 1, walk both pages, assert union is the full set, no duplicates. +7. `Project_TagNameGlobFiltersChildren_AndUpdatesHasChildren` — `TagNameGlob = "Mixer*"`, parent 1 → only `Mixer_001`/`Mixer_002`; `ChildHasChildren = [false, false]` (matching glob, no matching descendants). +8. `Project_HistorizedOnlyFiltersDescendants_HasChildrenReflectsFilter` — mark only `Sensor_A1` as historized; with `HistorizedOnly = true` and parent 1, only `Line_A` returned, `ChildHasChildren = [true]` (because its descendant matches). +9. `Project_IncludeAttributesFalse_ReturnsSkeletons` — verify `children[i].Attributes` is empty after the call. +10. `Project_BrowseSubtrees_ExcludesChildrenOutsideAllowedGlobs` — pass `["Plant/Line_*"]` as `browseSubtreeGlobs`, parent 1 → only `Line_A`. + +Use the same `CreateEntry` helper pattern as the existing `GalaxyHierarchyProjectorTests`. Read that file first if uncertain. + +**Step 2: Run tests to verify they fail** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyBrowseProjectorTests` +Expected: compile failure (projector doesn't exist). + +**Step 3: Implement `GalaxyBrowseChildrenResult`** + +```csharp +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +public sealed record GalaxyBrowseChildrenResult( + IReadOnlyList Children, + IReadOnlyList ChildHasChildren, + int TotalChildCount, + string FilterSignature); +``` + +**Step 4: Implement `GalaxyBrowseProjector.ProjectChildren`** + +`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs`: + +```csharp +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using Grpc.Core; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +public static class GalaxyBrowseProjector +{ + private static readonly ConditionalWeakTable< + GalaxyHierarchyCacheEntry, + ConcurrentDictionary> FilteredChildrenCache = new(); + + public static GalaxyBrowseChildrenResult ProjectChildren( + GalaxyHierarchyCacheEntry entry, + BrowseChildrenRequest request, + IReadOnlyList? browseSubtreeGlobs, + int offset, + int pageSize) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(request); + if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset)); + if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); + + int parentId = ResolveParentId(entry, request); + string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs, parentId); + FilteredChildren filtered = GetFilteredChildren(entry, request, browseSubtreeGlobs, parentId, filterSignature); + + bool includeAttributes = IncludeAttributes(request); + int end = (int)Math.Min((long)offset + pageSize, filtered.Children.Count); + List page = new(Math.Max(0, end - offset)); + List hasChildren = new(Math.Max(0, end - offset)); + for (int i = offset; i < end; i++) + { + page.Add(CloneObject(filtered.Children[i].Object, includeAttributes)); + hasChildren.Add(filtered.HasMatchingDescendant[i]); + } + + return new GalaxyBrowseChildrenResult(page, hasChildren, filtered.Children.Count, filterSignature); + } + + private static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request) + { + switch (request.ParentCase) + { + case BrowseChildrenRequest.ParentOneofCase.None: + return 0; + case BrowseChildrenRequest.ParentOneofCase.ParentGobjectId: + if (request.ParentGobjectId == 0) return 0; + if (!entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId)) + throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")); + return request.ParentGobjectId; + case BrowseChildrenRequest.ParentOneofCase.ParentTagName: + { + GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault( + v => string.Equals(v.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase)); + if (match is null) throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")); + return match.Object.GobjectId; + } + case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath: + { + GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault( + v => string.Equals(v.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase)); + if (match is null) throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")); + return match.Object.GobjectId; + } + default: + return 0; + } + } + + private static FilteredChildren GetFilteredChildren( + GalaxyHierarchyCacheEntry entry, + BrowseChildrenRequest request, + IReadOnlyList? browseSubtreeGlobs, + int parentId, + string filterSignature) + { + ConcurrentDictionary memo = + FilteredChildrenCache.GetValue(entry, static _ => new ConcurrentDictionary(StringComparer.Ordinal)); + + return memo.GetOrAdd( + filterSignature, + static (_, state) => + { + IReadOnlyDictionary> map = state.Entry.Index.ChildrenByParent; + IReadOnlyList directChildren = map.TryGetValue(state.ParentId, out IReadOnlyList? list) + ? list + : Array.Empty(); + + List matched = []; + List hasMatching = []; + foreach (GalaxyObjectView view in directChildren) + { + if (!MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs)) continue; + if (!MatchesFilters(view.Object, state.Request)) continue; + matched.Add(view); + hasMatching.Add(HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs)); + } + + return new FilteredChildren(matched, hasMatching); + }, + (Entry: entry, ParentId: parentId, Request: request, BrowseSubtreeGlobs: browseSubtreeGlobs)); + } + + private static bool HasMatchingDescendant( + GalaxyObjectView parent, + GalaxyHierarchyIndex index, + BrowseChildrenRequest request, + IReadOnlyList? browseSubtreeGlobs) + { + if (!index.ChildrenByParent.TryGetValue(parent.Object.GobjectId, out IReadOnlyList? children)) return false; + + Stack stack = new(); + foreach (GalaxyObjectView child in children) stack.Push(child); + while (stack.Count > 0) + { + GalaxyObjectView candidate = stack.Pop(); + if (MatchesBrowseSubtrees(candidate, browseSubtreeGlobs) + && MatchesFilters(candidate.Object, request)) + { + return true; + } + if (index.ChildrenByParent.TryGetValue(candidate.Object.GobjectId, out IReadOnlyList? grandchildren)) + { + foreach (GalaxyObjectView grandchild in grandchildren) stack.Push(grandchild); + } + } + return false; + } + + private static bool MatchesBrowseSubtrees(GalaxyObjectView view, IReadOnlyList? globs) + => globs is null || globs.Count == 0 || globs.Any(g => GalaxyGlobMatcher.IsMatch(view.ContainedPath, g)); + + private static bool MatchesFilters(GalaxyObject obj, BrowseChildrenRequest request) + { + if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId)) return false; + foreach (string tpl in request.TemplateChainContains) + { + if (!obj.TemplateChain.Any(t => t.Contains(tpl, StringComparison.OrdinalIgnoreCase))) return false; + } + if (!string.IsNullOrWhiteSpace(request.TagNameGlob) + && !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob)) return false; + if (request.AlarmBearingOnly && !obj.Attributes.Any(a => a.IsAlarm)) return false; + if (request.HistorizedOnly && !obj.Attributes.Any(a => a.IsHistorized)) return false; + return true; + } + + private static bool IncludeAttributes(BrowseChildrenRequest request) + => !request.HasIncludeAttributes || request.IncludeAttributes; + + private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes) + { + GalaxyObject clone = source.Clone(); + if (!includeAttributes) clone.Attributes.Clear(); + return clone; + } + + public static string ComputeFilterSignature( + BrowseChildrenRequest request, + IReadOnlyList? browseSubtreeGlobs, + int parentId) + { + StringBuilder sb = new(); + sb.Append("parent=").Append(parentId); + sb.Append("|cat=").AppendJoin(',', request.CategoryIds.Order()); + sb.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase)); + sb.Append("|glob=").Append(request.TagNameGlob); + sb.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset"); + sb.Append("|alarm=").Append(request.AlarmBearingOnly); + sb.Append("|hist=").Append(request.HistorizedOnly); + sb.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty()).Order(StringComparer.OrdinalIgnoreCase)); + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())); + return Convert.ToHexString(hash, 0, 12); + } + + private sealed record FilteredChildren( + IReadOnlyList Children, + IReadOnlyList HasMatchingDescendant); +} +``` + +**Step 5: Run the projector tests** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyBrowseProjectorTests` +Expected: 10/10 PASS. + +**Step 6: Run the full server suite** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj` +Expected: all green. + +**Step 7: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs \ + src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs \ + src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs +git commit -m "galaxy: add GalaxyBrowseProjector for direct-children projection" +``` + +--- + +## Task 4: Wire `BrowseChildren` into `GalaxyRepositoryGrpcService` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 5 + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs` +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs:23-26` +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs` +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs` + +**Step 1: Failing scope resolver test** + +In `GatewayGrpcScopeResolverTests.cs` (read it first to match style — likely a Theory or per-request-type Fact), add a case that `Resolve(new BrowseChildrenRequest())` returns `GatewayScopes.MetadataRead`. + +**Step 2: Failing service test** + +In `GalaxyRepositoryGrpcServiceTests.cs` (read it first to match the existing fixtures — likely a `FakeGalaxyHierarchyCache` helper), add facts: + +1. `BrowseChildren_RootCall_ReturnsRoots` — cache contains the area-tree from Task 3; empty `parent` → reply contains exactly the root areas, `CacheSequence > 0`, `ChildHasChildren` populated. +2. `BrowseChildren_FirstLoadNotComplete_ReturnsUnavailable` — fresh cache with `Status == Unknown` and no completion → `RpcException(Unavailable)` after the 5s budget. (Use a fake cache whose `WaitForFirstLoadAsync` never completes within the test's short token.) +3. `BrowseChildren_StaleToken_ReturnsInvalidArgument` — projector returns `next_page_token` with sequence S; bump cache to sequence S+1; second call with that token → `InvalidArgument` with "stale" in the message. +4. `BrowseChildren_FilterChangeBetweenPages_ReturnsInvalidArgument` — page 1 with one filter set, page 2 same token but different `TagNameGlob` → `InvalidArgument`. +5. `BrowseChildren_BrowseSubtreesConstraint_FiltersChildren` — fake identity accessor returns one browse-subtree glob; result is intersected. + +**Step 3: Run the tests to verify they fail** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GalaxyRepositoryGrpcServiceTests|FullyQualifiedName~GatewayGrpcScopeResolverTests"` +Expected: compile failure (handler doesn't exist yet) or runtime failures for the resolver case. + +**Step 4: Add `BrowseChildren` to the scope resolver** + +In `GatewayGrpcScopeResolver.cs` lines 23-26, extend the metadata-read clause: + +```csharp +TestConnectionRequest or +GetLastDeployTimeRequest or +DiscoverHierarchyRequest or +WatchDeployEventsRequest or +BrowseChildrenRequest => GatewayScopes.MetadataRead, +``` + +**Step 5: Implement the handler** + +In `GalaxyRepositoryGrpcService.cs`, after `DiscoverHierarchy` and before `WatchDeployEvents`, add: + +```csharp +private const int DefaultBrowsePageSize = 500; +// (MaxDiscoverPageSize is reused as MaxBrowsePageSize — same 5000 cap.) + +public override async Task BrowseChildren( + BrowseChildrenRequest request, + ServerCallContext context) +{ + await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false); + GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current; + + if (!entry.HasData) + { + throw new RpcException(new Status( + StatusCode.Unavailable, + ResolveUnavailableMessage(entry))); + } + + int pageSize = ResolveBrowsePageSize(request.PageSize); + IReadOnlyList browseSubtrees = ResolveBrowseSubtrees(); + + // Resolve the parent id once so the page-token signature can include it. + int parentId = ResolveParentIdForToken(entry, request); + string filterSignature = GalaxyDb.GalaxyBrowseProjector.ComputeFilterSignature(request, browseSubtrees, parentId); + PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature); + + GalaxyDb.GalaxyBrowseChildrenResult result = GalaxyDb.GalaxyBrowseProjector.ProjectChildren( + entry, + request, + browseSubtrees, + pageToken.Offset, + pageSize); + + if (pageToken.Offset > result.TotalChildCount) + { + throw new RpcException(new Status( + StatusCode.InvalidArgument, + "BrowseChildren page_token is outside the current children set.")); + } + + BrowseChildrenReply reply = new() + { + TotalChildCount = result.TotalChildCount, + CacheSequence = (ulong)entry.Sequence, + }; + reply.Children.Add(result.Children); + reply.ChildHasChildren.Add(result.ChildHasChildren); + + int nextOffset = pageToken.Offset + result.Children.Count; + if (nextOffset < result.TotalChildCount) + { + reply.NextPageToken = FormatPageToken(entry.Sequence, result.FilterSignature, nextOffset); + } + + return reply; +} + +private static int ResolveBrowsePageSize(int requested) +{ + if (requested < 0) + { + throw new RpcException(new Status( + StatusCode.InvalidArgument, + "BrowseChildren page_size must be greater than zero when provided.")); + } + int pageSize = requested == 0 ? DefaultBrowsePageSize : requested; + return Math.Min(pageSize, MaxDiscoverPageSize); +} + +// Lightweight parent resolver used only for signature computation. Re-throws +// NotFound consistently with the projector so the error surface matches. +private static int ResolveParentIdForToken( + GalaxyDb.GalaxyHierarchyCacheEntry entry, + BrowseChildrenRequest request) +{ + return request.ParentCase switch + { + BrowseChildrenRequest.ParentOneofCase.None => 0, + BrowseChildrenRequest.ParentOneofCase.ParentGobjectId => + request.ParentGobjectId == 0 ? 0 + : entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId) + ? request.ParentGobjectId + : throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")), + BrowseChildrenRequest.ParentOneofCase.ParentTagName => + entry.Index.ObjectViews.FirstOrDefault( + v => string.Equals(v.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId + ?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")), + BrowseChildrenRequest.ParentOneofCase.ParentContainedPath => + entry.Index.ObjectViews.FirstOrDefault( + v => string.Equals(v.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId + ?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")), + _ => 0, + }; +} +``` + +Reuse the existing `FormatPageToken` / `ParsePageToken` / `PageToken` record. They use `sequence:filterSig:offset` text encoding — that's fine for browse too. The `ParsePageToken` error messages currently say "DiscoverHierarchy"; leave them — the contract is the same and rewording them costs a wider edit without value. (If a code reviewer flags this in Task 11, generalize the message strings to "page_token is invalid/stale" then.) + +**Step 6: Run the tests** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj` +Expected: all green, including the new BrowseChildren tests. + +**Step 7: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs \ + src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs \ + src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs \ + src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs +git commit -m "grpc: implement BrowseChildren handler + metadata:read scope" +``` + +--- + +## Task 5: Dashboard browse service & lazy `BrowsePage` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 4 (different files, both depend on Tasks 2 & 3) + +**Files:** +- Create: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseService.cs` +- Create: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardBrowseService.cs` +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs` (add lazy node fields, keep existing builder) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor` +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor` +- Modify: the file that registers dashboard services in DI (find with `grep -rn "AddScoped.*Dashboard\|AddSingleton.*Dashboard" src/ZB.MOM.WW.MxGateway.Server` — register `IDashboardBrowseService` next to the others) +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs` (new file) + +**Step 1: Failing tests for the service** + +`DashboardBrowseServiceTests.cs`: + +1. `GetRoots_ReturnsRootObjects_WithHasChildrenHint` — fixture with one root area containing one child; `GetRoots(default)` → one root node, `HasChildrenHint == true`. +2. `GetChildren_ByParentGobjectId_ReturnsDirectChildren` — verify it routes through the projector and returns the same nodes the projector does. +3. `GetChildren_UnknownParent_ReturnsEmptyResultWithErrorFlag` — translate the projector's NotFound `RpcException` into a `BrowseLevelResult.NotFound` so the Blazor UI can render a friendly message instead of propagating a server exception to the circuit. (Design says project the gRPC error to the UI; this is the dashboard-friendly translation.) +4. `CacheSequence_AdvancesAfterRefresh_NewQueriesReflectIt` — bump the fake cache; subsequent calls report the new sequence. + +**Step 2: Run the tests to verify they fail** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~DashboardBrowseServiceTests` +Expected: compile failure. + +**Step 3: Implement the contracts and service** + +`IDashboardBrowseService.cs`: + +```csharp +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public interface IDashboardBrowseService +{ + BrowseLevelResult GetRoots(BrowseFilterArgs filter); + BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter); + ulong CurrentCacheSequence { get; } +} + +public sealed record BrowseFilterArgs( + string? TagNameGlob = null, + bool AlarmBearingOnly = false, + bool HistorizedOnly = false); + +public sealed record BrowseLevelResult( + IReadOnlyList Nodes, + int TotalCount, + ulong CacheSequence, + string? Error = null); +``` + +`DashboardBrowseModel.cs` — extend `DashboardBrowseNode` with three new optional fields (additive, doesn't break the existing eager-tree consumer): + +```csharp +public bool HasChildrenHint { get; init; } +public BrowseLoadState LoadState { get; set; } = BrowseLoadState.NotLoaded; +public string? LoadError { get; set; } +``` + +```csharp +public enum BrowseLoadState { NotLoaded, Loading, Loaded, Error } +``` + +Existing `HasChildren` already returns `Children.Count > 0 || Attributes.Count > 0` — leave it; the new `HasChildrenHint` is what the lazy UI consults before children have loaded. + +`DashboardBrowseService.cs`: + +```csharp +using Grpc.Core; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public sealed class DashboardBrowseService(IGalaxyHierarchyCache cache) : IDashboardBrowseService +{ + public ulong CurrentCacheSequence => (ulong)cache.Current.Sequence; + + public BrowseLevelResult GetRoots(BrowseFilterArgs filter) + => ProjectLevel(parentId: null, filter); + + public BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter) + => ProjectLevel(parentId: parentGobjectId, filter); + + private BrowseLevelResult ProjectLevel(int? parentId, BrowseFilterArgs filter) + { + GalaxyHierarchyCacheEntry entry = cache.Current; + if (!entry.HasData) + { + return new BrowseLevelResult(Array.Empty(), 0, (ulong)entry.Sequence, + Error: "Galaxy hierarchy is not loaded yet."); + } + + BrowseChildrenRequest request = new() + { + TagNameGlob = filter.TagNameGlob ?? string.Empty, + AlarmBearingOnly = filter.AlarmBearingOnly, + HistorizedOnly = filter.HistorizedOnly, + }; + if (parentId is int pid) request.ParentGobjectId = pid; + + try + { + GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( + entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: int.MaxValue); + + List nodes = new(result.Children.Count); + for (int i = 0; i < result.Children.Count; i++) + { + nodes.Add(new DashboardBrowseNode + { + Object = result.Children[i], + HasChildrenHint = result.ChildHasChildren[i], + }); + } + return new BrowseLevelResult(nodes, result.TotalChildCount, (ulong)entry.Sequence); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) + { + return new BrowseLevelResult(Array.Empty(), 0, (ulong)entry.Sequence, + Error: ex.Status.Detail); + } + } +} +``` + +Register in DI alongside the other dashboard services (find the file, add `services.AddScoped();`). + +**Step 4: Rework `BrowseTreeNodeView.razor` for lazy expand** + +Change the toggle path: + +- Show the toggle when `Node.HasChildrenHint || Node.Attributes.Count > 0` (replaces the `HasChildren` check). +- On `Toggle`, if `LoadState == NotLoaded`, set `Loading`, invoke an async load callback (new `[Parameter] public Func? OnLoadChildren`), then set `Loaded` or `Error`. +- While loading, show a small spinner glyph in place of the toggle arrow. +- Render children only when `LoadState == Loaded` (and the existing `_expanded` flag is true). +- Children render with `OnLoadChildren` passed through so nested nodes can lazy-load too. + +**Step 5: Rework `BrowsePage.razor`** + +- Inject `IDashboardBrowseService BrowseService` in place of (or alongside) the existing eager build. +- Replace the eager `BuildRoots()` call with `BrowseService.GetRoots(new BrowseFilterArgs(...))` on first render. +- Add an async method `LoadChildrenAsync(DashboardBrowseNode node)` that calls `BrowseService.GetChildren(node.Object.GobjectId, currentFilter)`, populates `node.Children`, marks `LoadState = Loaded`, and calls `StateHasChanged()`. +- Pass that as `OnLoadChildren` to the root `BrowseTreeNodeView`s. +- Subscribe to deploy events (the page already injects `IDashboardLiveDataService` — if it doesn't already expose a deploy hook, fall back to `IGalaxyDeployNotifier.SubscribeAsync` via a new injected dependency). On a sequence change, clear `_roots`, re-`GetRoots`, set a one-line banner `"Galaxy redeployed — tree refreshed."` (no toast, no modal). Banner clears on next user click. +- **Search path unchanged.** When `Search` is non-empty, keep the existing flat-list search code (it walks the full cache via `GalaxyHierarchyCache.Current.Objects`). Lazy is only for the unfiltered tree. + +**Step 6: Run all server tests** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj` +Expected: all green. + +**Step 7: Manual smoke on Windows host** + +Document only — no automation here. The plan's executor flags this for the human: + +``` +ssh windev "Restart-Service MxAccessGw" +# Then visit http://10.100.0.48:5130/browse and verify: +# - Initial render shows only root areas +# - Clicking a triangle loads that level +# - A re-deploy banner appears after a forced redeploy +``` + +**Step 8: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Server/Dashboard/ \ + src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs +git commit -m "dashboard: lazy-load BrowsePage via DashboardBrowseService" +``` + +--- + +## Task 6: Cross-language client smoke (skeleton walk) + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Tasks 7, 8, 9, 10 (different files) + +**Files:** +- Modify: `clients/dotnet//` — add one xUnit fact that opens a `GalaxyRepositoryClient`, calls `BrowseChildren` against the dev gateway only when `MXGATEWAY_API_KEY` is set, asserts `cache_sequence > 0`. Use `Skip = "..."` when env var missing. + +Only the .NET client gets a new test in this task. The other language clients pick up the new RPC automatically via the next proto-regen in their build scripts; per-language test parity is a separate follow-up. + +**Step 1: Add the test** + +Read the existing `clients/dotnet` test project layout; mirror the pattern of an existing live-only `GalaxyRepository` test if one exists. Otherwise create `clients/dotnet//Galaxy/BrowseChildrenSmokeTests.cs`. + +```csharp +[SkippableFact] +public async Task BrowseChildren_LiveGateway_ReturnsRoots() +{ + string? apiKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY"); + Skip.If(string.IsNullOrEmpty(apiKey), "Set MXGATEWAY_API_KEY to run."); + + string endpoint = Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") ?? "http://localhost:5120"; + using GrpcChannel channel = GrpcChannel.ForAddress(endpoint); + GalaxyRepository.GalaxyRepositoryClient client = new(channel); + + Metadata headers = new() { { "authorization", $"Bearer {apiKey}" } }; + BrowseChildrenReply reply = await client.BrowseChildrenAsync(new BrowseChildrenRequest(), headers); + + Assert.True(reply.CacheSequence > 0); + Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count); +} +``` + +**Step 2: Build & run** + +Run: `dotnet build clients/dotnet/MxGateway.Client.sln` +Expected: succeeds (proto regen picks up `BrowseChildren`). + +Run: `dotnet test clients/dotnet/MxGateway.Client.sln --filter FullyQualifiedName~BrowseChildrenSmokeTests` +Expected: SKIPPED (no API key). With key set + gateway running, it passes. + +**Step 3: Commit** + +```bash +git add clients/dotnet/ +git commit -m "client/dotnet: live smoke for BrowseChildren" +``` + +--- + +## Task 7: Regenerate Python/Go/Rust/Java client protos + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Tasks 6, 8, 9, 10 + +**Files:** +- Modify: `clients/python/src/mxgateway/generated/` (regen) +- Modify: `clients/go/...` (regen — check the existing build/gen script) +- Modify: `clients/rust//generated*/` (regen) +- Modify: `clients/java//build/generated/` if checked in + +**Step 1: Run each client's existing generation step** + +Read each `clients//README.md` for its codegen command. Run them in order: + +```bash +cd clients/python && python -m pip install -e ".[dev]" && python -m build # or whatever regenerates protos +cd ../rust && cargo build --workspace +cd ../go && go generate ./... +cd ../java && gradle generateProto +``` + +Expected: each succeeds; `BrowseChildren` types appear in each client's generated tree. + +**Step 2: Sanity build each** + +```bash +(cd clients/python && python -m pytest --collect-only) # imports compile +(cd clients/rust && cargo check --workspace) +(cd clients/go && go build ./...) +(cd clients/java && gradle build -x test) +``` + +**Step 3: Commit (one per language to keep the diff reviewable)** + +```bash +git add clients/python/ +git commit -m "client/python: regenerate protos for BrowseChildren" +# repeat for each language +``` + +--- + +## Task 8: Update `docs/GalaxyRepository.md` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Tasks 6, 7, 9, 10 + +**Files:** +- Modify: `docs/GalaxyRepository.md` + +**Step 1: Edit** + +- In the "RPC Surface" table (around line 33-39), add one row: + + ``` + | `BrowseChildren` | Returns the direct children of one parent object (or root objects when unset). Filters mirror `DiscoverHierarchy`. **Served from cache.** | + ``` + +- Add a new subsection after the existing `DiscoverHierarchy` description, titled `### BrowseChildren`, covering: + - The `parent` oneof and how an empty oneof means "roots". + - Filter parity with `DiscoverHierarchy` (point readers to the existing filter docs rather than duplicating). + - `child_has_children` semantics — computed against the filtered descendant set. + - Page token contract: `(cache_sequence, parent_id, filter_signature, offset)`. Stale or filter-changed token → `InvalidArgument`. Unknown parent → `NotFound`. + - Default page size 500; cap 5000. + +- Update the architecture diagram (around line 271-286) to show `GalaxyBrowseProjector` reused by both `GalaxyRepositoryGrpcService.BrowseChildren` and the new `DashboardBrowseService`. + +**Step 2: Commit** + +```bash +git add docs/GalaxyRepository.md +git commit -m "docs: document BrowseChildren RPC and lazy browse architecture" +``` + +--- + +## Task 9: Update `gateway.md` and per-client READMEs + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Tasks 6, 7, 8, 10 + +**Files:** +- Modify: `gateway.md` (one-line entry in the Galaxy RPC table) +- Modify: `clients/dotnet/README.md` +- Modify: `clients/python/README.md` +- Modify: `clients/rust/README.md` +- Modify: `clients/go/README.md` (or whatever the existing Go client README is — find with `find clients/go -iname 'README*'`) +- Modify: `clients/java/README.md` + +**Step 1: Add to `gateway.md`** + +Find the Galaxy RPC table (grep for `DiscoverHierarchy` in `gateway.md`); add a row identical in style to its neighbours describing `BrowseChildren`. + +**Step 2: Per-client `Browsing lazily` snippet** + +In each client README, add a short section under the existing Galaxy / browse docs: + +``` +### Browsing lazily + +For OPC UA-style tree UIs, use BrowseChildren to walk one level at a time. +Pass an empty request for roots; subsequent calls supply parent_gobject_id +(or parent_tag_name / parent_contained_path). Each child's matching +`child_has_children[i]` tells you whether to draw an expand triangle. +Filter fields match DiscoverHierarchy. +``` + +Adapt the code snippet to each language's idiom (match the existing examples in that README). + +**Step 3: Commit** + +```bash +git add gateway.md clients/*/README.md +git commit -m "docs: note BrowseChildren in gateway overview and client READMEs" +``` + +--- + +## Task 10: Update `docs/DesignDecisions.md` and design doc + +**Classification:** trivial +**Estimated implement time:** ~2 min +**Parallelizable with:** Tasks 6, 7, 8, 9 + +**Files:** +- Modify: `docs/DesignDecisions.md` +- Modify: `docs/plans/2026-05-28-lazy-browse-design.md` + +**Step 1: Add a `DesignDecisions.md` entry** + +Append a new bullet noting: + +> **Lazy browse stays wire-only.** The gateway continues to pull the full Galaxy hierarchy on each deploy. The `BrowseChildren` RPC and lazy dashboard render only avoid sending and DOM-materializing the full tree — they do **not** push laziness into SQL or cache loading. Reasons: snapshot persistence and the dashboard summary both depend on a fully-materialized cache; lazy SQL would dramatically increase per-click latency on a deployment-heavy box and complicate the cold-start path. + +**Step 2: Reconcile the design doc** + +In `docs/plans/2026-05-28-lazy-browse-design.md`, change the error-mapping table row for "Stale `page_token`" from `FailedPrecondition` to `InvalidArgument` and add a one-line note that this matches `DiscoverHierarchy`'s pre-existing behavior. + +**Step 3: Commit** + +```bash +git add docs/DesignDecisions.md docs/plans/2026-05-28-lazy-browse-design.md +git commit -m "docs: record lazy-browse stays wire-only; align error mapping" +``` + +--- + +## Task 11: Final integration build and verification + +**Classification:** standard +**Estimated implement time:** ~3 min +**Parallelizable with:** none (final gate) + +**Step 1: Build the whole solution** + +Run: `dotnet build src/MxGateway.sln` +Expected: 0 warnings, 0 errors (the repo treats warnings as errors). + +**Step 2: Run all gateway unit tests** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj` +Expected: all green; new tests included. + +**Step 3: Build the .NET client solution** + +Run: `dotnet build clients/dotnet/MxGateway.Client.sln` +Expected: succeeds. + +**Step 4: Self-review the diff** + +Run: `git log --oneline main..HEAD` and `git diff --stat main..HEAD`. Sanity-check that each commit corresponds to one plan task; no stray edits. + +**Step 5: Open PR (only after the user approves)** + +The executor must stop here and ask the user before pushing or opening a PR. diff --git a/docs/plans/2026-05-28-lazy-browse-implementation.md.tasks.json b/docs/plans/2026-05-28-lazy-browse-implementation.md.tasks.json new file mode 100644 index 0000000..30763b5 --- /dev/null +++ b/docs/plans/2026-05-28-lazy-browse-implementation.md.tasks.json @@ -0,0 +1,18 @@ +{ + "planPath": "docs/plans/2026-05-28-lazy-browse-implementation.md", + "tasks": [ + {"id": 6, "subject": "Task 0: Worktree & branch", "status": "pending"}, + {"id": 7, "subject": "Task 1: Add proto contract for BrowseChildren", "status": "pending", "blockedBy": [6]}, + {"id": 8, "subject": "Task 2: Extend GalaxyHierarchyIndex with ChildrenByParent","status": "pending", "blockedBy": [7]}, + {"id": 9, "subject": "Task 3: Add GalaxyBrowseProjector.ProjectChildren", "status": "pending", "blockedBy": [8]}, + {"id": 10, "subject": "Task 4: Wire BrowseChildren into gRPC service", "status": "pending", "blockedBy": [9]}, + {"id": 11, "subject": "Task 5: Dashboard browse service & lazy BrowsePage", "status": "pending", "blockedBy": [9]}, + {"id": 12, "subject": "Task 6: .NET client live smoke test", "status": "pending", "blockedBy": [10]}, + {"id": 13, "subject": "Task 7: Regenerate Python/Go/Rust/Java protos", "status": "pending", "blockedBy": [7]}, + {"id": 14, "subject": "Task 8: Update docs/GalaxyRepository.md", "status": "pending", "blockedBy": [7]}, + {"id": 15, "subject": "Task 9: Update gateway.md + per-client READMEs", "status": "pending", "blockedBy": [7, 13]}, + {"id": 16, "subject": "Task 10: Update DesignDecisions.md + reconcile design", "status": "pending", "blockedBy": [7]}, + {"id": 17, "subject": "Task 11: Final integration build and verification", "status": "pending", "blockedBy": [10, 11, 12, 13, 14, 15, 16]} + ], + "lastUpdated": "2026-05-28T16:40:54Z" +}