From d0a0661f6a49161416cc71107a2d3807ff18a56e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 20:00:05 -0400 Subject: [PATCH] docs(plan): historian within-timestamp paging (#400) + AbCip/TwinCAT UDT member-paths implementation plan + tasks --- ...06-17-historian-paging-udt-member-paths.md | 411 ++++++++++++++++++ ...rian-paging-udt-member-paths.md.tasks.json | 19 + 2 files changed, 430 insertions(+) create mode 100644 docs/plans/2026-06-17-historian-paging-udt-member-paths.md create mode 100644 docs/plans/2026-06-17-historian-paging-udt-member-paths.md.tasks.json diff --git a/docs/plans/2026-06-17-historian-paging-udt-member-paths.md b/docs/plans/2026-06-17-historian-paging-udt-member-paths.md new file mode 100644 index 00000000..8ac7fcda --- /dev/null +++ b/docs/plans/2026-06-17-historian-paging-udt-member-paths.md @@ -0,0 +1,411 @@ +# Historian within-timestamp paging (#400) + AbCip/TwinCAT UDT member-paths — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task. + +**Goal:** Close two `stillpending.md` §2 items — page raw history *within* a single oversized +tie-cluster timestamp instead of failing loudly (#400), and make AbCip + TwinCAT UDT/struct +members individually discoverable + readable. + +**Architecture:** Three disjoint workstreams in one branch (parallelizable implementers). +WS-A adds a pure `HistoryPaging.SliceTieCluster` decision + a bounded within-timestamp +over-fetch in `ServeRawPaged` + a `MaxTieClusterOverfetch` knob. WS-B (AbCip) fans out +controller-discovered UDTs into atomic-member Variables by fetching the cached UDT shape +(mirroring the existing pre-declared fan-out). WS-B (TwinCAT) extracts a pure recursive +symbol-leaf expander and feeds the real ADS symbol tree through it. + +**Tech Stack:** .NET 10, OPC UA Foundation UA-.NETStandard, xUnit + Shouldly, libplctag +(AbCip), TwinCAT.Ads. No EF migration, no Commons wire/proto change, no bUnit. + +**Design:** `docs/plans/2026-06-17-historian-paging-udt-member-paths-design.md` (committed +`ad66ecc9`). Branch `feat/stillpending-historian-paging-udt-members` off master `c402872c`. + +**Hard rules (every task):** stage by explicit path, never `git add .`; never stage +`sql_login.txt` / `src/Server/.../pki/` / `pending.md` / `current.md` / +`docker-dev/docker-compose.yml` / `stillpending.md`; never echo/commit secrets; no +force-push; no `--no-verify`; **NO EF migration, NO Commons wire/proto change, NO bUnit**. +Run build/test with `dangerouslyDisableSandbox: true` (the sandbox blocks the rig + some +local resources). Each implementer commits only the paths in its own `Files:` block. + +--- + +### Task 1 (WS-A): #400 within-timestamp tie-cluster paging + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 2, Task 3 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs` (add `SliceTieCluster`) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs:1881-1892` (replace the stuck-branch loud-fail; add a `MaxTieClusterOverfetch` property near the `HistorianDataSource` property at `:156-179`) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs` (add `MaxTieClusterOverfetch` option + a `Validate()` warning when ≤ 0) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs:199-202` (wire the option onto the node manager at `StartAsync`, mirroring `SetHistorianDataSource`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs` (pure `SliceTieCluster` tests) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs` (oversized-tie-cluster integration test; reuse the existing `SeriesHistorianDataSource` fake + `ReadRaw`/`MakeSeries` harness) + +**Context.** `ServeRawPaged` (`OtOpcUaNodeManager.cs:1812-1928`) synthesises Raw paging over a +single-shot `(start,end,cap)` backend. The tie-safe cursor is `HistoryContinuationState(Tagname, +NextStartUtc=T, EndUtc, BoundarySkipCount=skip, NumValuesPerNode)`. The "stuck" branch at +`:1881-1892` fires when an inbound resume returns a FULL backend page that the boundary-tie trim +empties entirely — i.e. more ties share `T` than `cap`. Today it logs + returns +`BadHistoryOperationUnsupported`. We replace that with a bounded within-timestamp over-fetch. + +The backend (`HistorianDataSource.ReadRawAsync:405`) treats `(start,end)` inclusive both ends and +honors an **explicit** cap; **`cap==0` falls back to `MaxValuesPerRead`** (`:431`), so the +over-fetch MUST pass an explicit cap. The existing `SeriesHistorianDataSource` test fake already +filters `>= start && <= end` then `Take(cap)`, so `(T,T,bigCap)` returns the whole T-cluster. + +**Step 1: Write the failing pure-helper tests.** In `HistoryPagingTests.cs` add `SliceTieCluster` +tests. The helper signature: + +```csharp +// In HistoryPaging.cs — pure, no SDK. +// Given a fully-fetched tie cluster at boundary T, decide which slice to serve this page and the +// next cursor. clusterCount = total ties at T; skip = ties already emitted on prior pages; +// cap = client NumValuesPerNode (> 0 on this path); endUtc = window upper bound (inclusive). +public static void SliceTieCluster( + int clusterCount, int skip, uint cap, DateTime boundaryT, DateTime endUtc, + out int sliceStart, out int sliceCount, + out DateTime? nextStartUtc, out int nextSkip) +{ + sliceStart = Math.Min(skip, clusterCount); + sliceCount = Math.Min((int)cap, clusterCount - sliceStart); + var emitted = sliceStart + sliceCount; // ties emitted through this page + if (emitted < clusterCount) // more ties remain at T → stay in the cluster + { + nextStartUtc = boundaryT; + nextSkip = emitted; + } + else // cluster drained → advance one tick past T + { + var next = boundaryT.AddTicks(1); + if (next <= endUtc) { nextStartUtc = next; nextSkip = 0; } + else { nextStartUtc = null; nextSkip = 0; } // T was the window end → terminal + } +} +``` + +Tests (each `[Fact]`, Shouldly): +- mid-cluster: `clusterCount=10, skip=2, cap=3` → `sliceStart=2, sliceCount=3, nextStartUtc=T, nextSkip=5`. +- last full slice that exactly drains: `clusterCount=6, skip=3, cap=3` → drained → `nextStartUtc=T+1tick, nextSkip=0` (T < endUtc). +- short final slice but window remains: `clusterCount=5, skip=3, cap=10` → `sliceCount=2`, drained → `nextStartUtc=T+1tick` (emit CP even though the slice is short). +- drained at window end: same but `endUtc == T` → `nextStartUtc=null` (terminal). +- self-heal: `skip >= clusterCount` (e.g. `clusterCount=4, skip=4`) → `sliceCount=0`, drained → advance/terminal. + +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter FullyQualifiedName~HistoryPagingTests` — expected FAIL (method missing). + +**Step 2: Implement `SliceTieCluster`** (code above) in `HistoryPaging.cs`. Rerun → PASS. + +**Step 3: Add the `MaxTieClusterOverfetch` option + node-manager property + Host wiring.** +- `ServerHistorianOptions.cs`: `public int MaxTieClusterOverfetch { get; init; } = 65536;` with XML doc explaining it bounds the within-timestamp over-fetch; in `Validate()` (when `Enabled`) add a warning if `MaxTieClusterOverfetch <= 0`. +- `OtOpcUaNodeManager.cs`: near the `HistorianDataSource` property (`:156-179`) add + `public int MaxTieClusterOverfetch { get; set; } = 65536;` (plain settable, default matches the option). +- `OtOpcUaServerHostedService.cs:199-202`: after `_server.SetHistorianDataSource(...)`, set the node-manager value from the bound `ServerHistorianOptions` (inject/resolve the options the same way the historian source is resolved; if the server exposes the node manager via `_server.NodeManager`, set `MaxTieClusterOverfetch` there — match the existing accessor pattern). Keep the default if options are absent. + +**Step 4: Write the failing integration test** in `NodeManagerHistoryReadPagingTests.cs`. Add a +`MakeSeriesWithTie` helper (or inline) producing e.g. 3 distinct-timestamp samples, then a tie +cluster of 5 samples all at one `SourceTimestampUtc`, then 2 more distinct — total 10, with the +5-way tie larger than a `cap=2`. Drive the full `do/while` resume loop (as the existing +`Raw_pages_full_series_across_continuation_points` test does) and assert the union is all 10 +samples in order, no dup/skip, terminating. Add a second test: set `nm.MaxTieClusterOverfetch = 3` +and a 5-way tie → the resume hits the cluster-exceeds-bound backstop → that page returns +`BadHistoryOperationUnsupported` (today's loud behavior preserved for absurd clusters). +Run → FAIL (stuck branch still loud-fails the first test). + +**Step 5: Rewrite the stuck branch** in `ServeRawPaged` (`:1881-1892`). Replace the loud-fail with: + +```csharp +if (inboundCp is { Length: > 0 } && backendFull && samples.Count == 0) +{ + // Oversized tie cluster at `startUtc` (= boundary T). Over-fetch the whole cluster with an + // explicit bounded cap (cap==0 would fall back to the backend's MaxValuesPerRead), then serve + // cap-at-a-time and advance past T when drained. A cluster beyond the safety bound preserves the + // historical loud fail. See docs/Historian.md + HistoryPaging.SliceTieCluster. + var overfetchCap = (uint)(MaxTieClusterOverfetch + 1); // +1 to detect overflow + var cluster = HistorianDataSource + .ReadRawAsync(tagname, startUtc, startUtc, overfetchCap, CancellationToken.None) + .GetAwaiter().GetResult().Samples; + + if (cluster.Count > MaxTieClusterOverfetch) + { + // unchanged loud fail (keep the existing Utils.LogError text) → BadHistoryOperationUnsupported + ... + return; + } + + HistoryPaging.SliceTieCluster(cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc, + out var sliceStart, out var sliceCount, out var nextStart, out var nextSkip); + var sliceList = new List(sliceCount); + for (var k = sliceStart; k < sliceStart + sliceCount; k++) sliceList.Add(cluster[k]); + + byte[]? clusterCp = null; + if (nextStart is DateTime ns) + clusterCp = _historyContinuationStore.Save(session, + new HistoryContinuationState(tagname, ns, endUtc, nextSkip, numValuesPerNode)); + + results[handle.Index] = new SdkHistoryReadResult + { + StatusCode = sliceList.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good, + HistoryData = new ExtensionObject(ToHistoryDataFromSamples(sliceList)), + ContinuationPoint = clusterCp, + }; + errors[handle.Index] = ServiceResult.Good; + return; +} +``` + +Rerun Step-4 tests → PASS. Then run the FULL paging suite to confirm no regression: +`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter FullyQualifiedName~NodeManagerHistoryRead`. + +**Step 6: Commit.** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs \ + src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs \ + src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs \ + src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs +git commit -m "feat(historian): page within oversized tie clusters (#400) instead of loud-failing" +``` + +**Acceptance:** oversized-tie Raw read pages out the full cluster + the rest of the window with no +dup/skip and terminates; cluster > `MaxTieClusterOverfetch` still loud-fails; existing paging +tests green; no contract/wire/proto/EF change. + +--- + +### Task 2 (WS-B AbCip): controller-discovered UDT member expansion + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 3 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:977-1011` (the controller-discovered branch in `DiscoverAsync`; reference the pre-declared fan-out at `:949-971`) +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs` + +**Context.** `DiscoverAsync` (`:929-1013`) already fans out **pre-declared** UDT tags into a member +sub-folder with one Variable per atomic member (`:949-971`), sourcing members from +`tag.Members`. But the **controller-discovered** branch (`:981-1011`) emits a discovered Structure +tag as a SINGLE Variable whose `DriverDataType` is the `Structure → String` placeholder +(`AbCipDataTypeExtensions.cs:33`) — never expanded, because `AbCipDiscoveredTag` carries no +`Members`. The driver already has the machinery to get them: `FetchUdtShapeAsync(deviceHostAddress, +templateInstanceId, ct)` (`:103-120`) reads + caches an `AbCipUdtShape` (member list with +name/offset/atomic type) via the CIP Template Object reader, and `AbCipTemplateCache` holds it. + +**Fix.** In the discovered-tag loop, when `discovered.DataType == AbCipDataType.Structure`, fetch +the UDT shape and fan out its atomic members into a member sub-folder under the Discovered folder — +mirroring the pre-declared fan-out at `:949-971`. Atomic members only; a nested-struct member +recurses (bounded depth, e.g. const `MaxUdtDepth = 8`); a member type with no atomic +`DriverDataType` (or an unresolvable shape) is skipped (never mis-emitted). Dedup member full-names +within a device folder. A discovered Structure with no resolvable shape falls back to the **current** +single-Variable behavior (no regression). + +> **Member-read resolution.** Pre-declared member tags are registered in `_tagsByName` at init +> (`:197-218`) so member reads resolve. For discovered members, equipment-tag reads resolve via the +> `EquipmentTagRefResolver` over the authored TagConfig (FullName = `Parent.Member`), and the read +> planner (`AbCipUdtReadPlanner`) groups members of one parent. Confirm a discovered member-path read +> resolves to the member's atomic type in a test; if resolution needs the member def registered, +> register it the same way `:197-218` does (mirror, don't invent). Keep the bare-container read at +> `:515-522` returning `BadNotSupported` (unchanged — members are the addressable surface). + +**Step 1: Failing test** in `AbCipDriverDiscoveryTests.cs`. Use the existing fake enumerator + +fake template reader pattern (see `AbCipFetchUdtShapeTests.cs` / `AbCipDriverDiscoveryTests.cs` for +how the fake `IAbCipTemplateReader` / `IAbCipTagEnumerator` are wired). Enumerate one discovered +Structure tag (`Motor1`, type Structure) whose template shape has atomic members `Speed` (Real) + +`Running` (Bool) + a nested struct `Status` with member `Code` (DInt). Build the address space via a +capturing `IAddressSpaceBuilder` test double (reuse the one the existing discovery tests use). Assert: +the Discovered folder contains a `Motor1` sub-folder with member Variables `Motor1.Speed` (Float32), +`Motor1.Running` (Boolean), and `Motor1.Status.Code` (Int32) — NOT a single `Motor1` String node. +Assert the depth cap drops members deeper than `MaxUdtDepth`. Run → FAIL. + +**Step 2: Implement** the discovered-UDT fan-out in `AbCipDriver.cs:981-1011` (mirror `:949-971`; +add a small recursive local function over `AbCipUdtShape.Members` with a depth guard + a +visited-set/dedup). Rerun → PASS. + +**Step 3:** Run the full AbCip suite to confirm no discovery/array regression: +`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`. + +**Step 4: Commit.** +```bash +git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs \ + tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs +git commit -m "feat(abcip): expand controller-discovered UDTs into addressable member variables" +``` + +**Acceptance:** a browsed UDT surfaces its atomic members (incl. nested-struct leaves up to the depth +cap) as individually addressable Variables with correct atomic types; unresolvable shapes degrade to +the prior single-Variable behavior; member-path read resolves; AbCip suite green; no proto/EF change. + +--- + +### Task 3 (WS-B TwinCAT): discovered struct/UDT member expansion + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 2 + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolExpander.cs` (pure recursion helper + a minimal symbol-node abstraction) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs:319-372` (`BrowseSymbolsAsync` adapts `ISymbol`→the abstraction + feeds the expander; `MapSymbolType` stays) +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolExpanderTests.cs` (new, pure) + +**Context.** `AdsTwinCATClient.BrowseSymbolsAsync` (`:319-339`) iterates `loader.Symbols` and emits +one `TwinCATDiscoveredSymbol(InstancePath, mappedType, readOnly, arrayLength)` per top-level symbol; +`MapSymbolType` returns `null` for a Struct (`:360+`), and the driver drops null-typed symbols, so +UDT/FB instances + their members never surface. **The real `ISymbol`/`SymbolLoaderFactory` is +non-injectable** (the `FakeTwinCATClient` operates one level ABOVE — it yields ready-made +`TwinCATDiscoveredSymbol`s, see `FakeTwinCATClient.BrowseSymbolsAsync`), so the recursion must live in +a **pure helper** over a minimal abstraction to be unit-testable, then adapt the real `ISymbol` to it +(same discipline as the bit-RMW driver-level RMW). + +**Step 1: Failing pure-expander tests** in `TwinCATSymbolExpanderTests.cs`. Define the abstraction + +helper in the new `TwinCATSymbolExpander.cs`: + +```csharp +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// Minimal view over an ADS symbol the expander needs (adapter wraps the real ISymbol). +public interface ITwinCATSymbolNode +{ + string InstancePath { get; } + bool IsStruct { get; } // Category == Struct (has members) + (TwinCATDataType? Type, int? ArrayLength) Mapped { get; } // null Type ⇒ unsupported atomic leaf + IReadOnlyList Children { get; } // struct members (empty for atomics) + bool ReadOnly { get; } +} + +public static class TwinCATSymbolExpander +{ + public const int MaxDepth = 8; + + // Walk roots → atomic leaves. A struct recurses its Children; an atomic leaf with a non-null + // mapped Type is yielded; unsupported leaves (null Type) and depth-exceeded nodes are dropped. + // Dedup by InstancePath (neutralizes Flat-vs-tree double-listing). Yields TwinCATDiscoveredSymbol. + public static IEnumerable ExpandLeaves(IEnumerable roots); +} +``` + +Tests (fake `ITwinCATSymbolNode` tree): a struct `MAIN.Motor1` with atomic members `Speed`(Real) ++ `Running`(Bool) and a nested struct `Status` with `Code`(DInt) → yields exactly +`MAIN.Motor1.Speed`, `MAIN.Motor1.Running`, `MAIN.Motor1.Status.Code` (full InstancePaths, mapped +atomic types); a top-level atomic `MAIN.Counter`(DInt) → yields itself; an unsupported leaf (null +mapped Type, e.g. a pointer) → dropped; depth beyond `MaxDepth` → dropped; duplicate InstancePath → +emitted once. Run → FAIL. + +**Step 2: Implement** `TwinCATSymbolExpander` (recursive, depth-guarded, `HashSet` dedup). +Rerun → PASS. + +**Step 3: Wire the real client.** In `AdsTwinCATClient.BrowseSymbolsAsync` (`:319-339`), add a private +adapter `sealed class AdsSymbolNode : ITwinCATSymbolNode` wrapping `ISymbol` (`InstancePath`; +`IsStruct` = `DataType?.Category == DataTypeCategory.Struct`; `Mapped` = `MapSymbolType(DataType)`; +`Children` = `symbol.SubSymbols.Select(s => new AdsSymbolNode(s))`; `ReadOnly` = `!IsSymbolWritable`). +Replace the per-symbol emit with `foreach (var ds in TwinCATSymbolExpander.ExpandLeaves( +loader.Symbols.Select(s => new AdsSymbolNode(s)))) { ct.ThrowIfCancellationRequested(); yield return ds; }`. +Keep the cancellation semantics (throw, not yield-break). This block is operator-gated for live (no +local ADS); the pure expander is the unit proof. Build the driver: +`dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT`. + +**Step 4:** Run the full TwinCAT suite (confirm the existing browser/array tests still pass — the +fake-driven `DiscoverAsync` tests are unaffected since the fake yields `TwinCATDiscoveredSymbol`s +directly): `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests`. + +**Step 5: Commit.** +```bash +git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolExpander.cs \ + src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs \ + tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolExpanderTests.cs +git commit -m "feat(twincat): expand discovered struct/UDT symbols into addressable member leaves" +``` + +**Acceptance:** the pure expander yields atomic leaves of a struct tree (incl. nested, depth-capped, +deduped), drops unsupported leaves; the real `BrowseSymbolsAsync` feeds the ADS symbol tree through +it; TwinCAT suite green; no contract/proto/EF change. The pre-declared-Structure-tag reject at +`TwinCATDriverFactoryExtensions.cs:89` stays (its message points to discovery — now satisfied). + +--- + +### Task 4: Docs + plan-record §2 clear + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** none (after T1–T3) + +**Files:** +- Modify: `docs/Historian.md` (the "Paging limitation — oversized tie clusters" block at `:144-153`) +- Modify: `docs/drivers/AbCip.md` (UDT member-path discovery section) +- Modify: `docs/drivers/TwinCAT.md` (discovered struct/UDT member section) + +**Steps.** +1. `docs/Historian.md`: rewrite the paging-limitation block — the server now pages *within* a single + timestamp via a bounded over-fetch (`ServerHistorian:MaxTieClusterOverfetch`, default 65536); + a cluster beyond the bound still fails `BadHistoryOperationUnsupported` (the remedy is a larger + `MaxTieClusterOverfetch` or `NumValuesPerNode`). Note the within-cluster page may be short yet + still carry a continuation point. +2. `docs/drivers/AbCip.md`: document that controller-discovered UDTs now surface atomic members + (incl. nested, depth-capped) as addressable Variables; the bare container stays `BadNotSupported`; + member writes deferred. +3. `docs/drivers/TwinCAT.md`: document discovered struct/UDT member expansion (atomic leaves, + depth cap, unsupported leaves dropped); pre-declared Structure tags still rejected → use discovery. +4. **Do NOT stage `stillpending.md`** — the §2 lines are cleared via this plan record only. + +**Commit.** +```bash +git add docs/Historian.md docs/drivers/AbCip.md docs/drivers/TwinCAT.md +git commit -m "docs: within-timestamp tie-cluster paging + AbCip/TwinCAT UDT member discovery" +``` + +**Acceptance:** docs match the shipped behavior; no overclaim (member writes/whole-UDT explicitly +deferred); `stillpending.md` untouched. + +--- + +### Task 5: Full build + tests + final integration review + +**Classification:** standard +**Estimated implement time:** ~4 min (build/test) + review +**Parallelizable with:** none + +**Steps.** +1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect 0 errors. +2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests` · + `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests` · + `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests` — all green. +3. Dispatch the **final integration reviewer** over the whole branch diff + (`git diff c402872c..HEAD`). Checks: (a) `SliceTieCluster` advance-past-T is lossless + the + over-fetch passes an explicit cap (never 0); (b) the `MaxTieClusterOverfetch` backstop preserves + the loud fail; (c) AbCip discovered-member fan-out mirrors the pre-declared path + degrades on + unresolvable shape + reads resolve; (d) the TwinCAT expander dedups + depth-caps + drops + unsupported leaves, and the adapter maps `ISymbol` faithfully; (e) no Commons/wire/proto/EF + change, no bUnit; (f) no never-stage files staged. +4. Apply any actionable findings via a FRESH implementer (no SendMessage in this env), re-review. + +**Commit** any fixes by explicit path. + +--- + +### Task 6: Live `/run` best-effort + finish branch + memory + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Steps.** +1. **WS-A** is unit+integration-proven (the oversized-tie integration test is the canonical proof; a + live anomaly needs the AVEVA historian on `10.100.0.48` — infra-gated). Record honestly. +2. **WS-B AbCip** best-effort: a local `ab_server` ControlLogix (`--plc=ControlLogix`) has only + partial UDT support; attempt a member-discovery smoke and record the result (likely unit-proven). +3. **WS-B TwinCAT** is operator-gated (Windows-only ADS) — unit-proven via the expander. +4. Use **superpowers-extended-cc:finishing-a-development-branch**: verify tests, then **merge to + master + push** (the user's standing finish choice — no re-ask), delete the branch. +5. Update `.tasks.json` with final state + live results + any review follow-ups. +6. Update memory: `project_stillpending_backlog.md` (top summary line + paragraph; drop #400 + + AbCip/TwinCAT UDT member-paths from REMAINING) and `MEMORY.md` line 27. + +**Acceptance:** `master = origin/master` at the merge commit; tests green; memory + bookkeeping current. + +--- + +## Dependency graph + +`{T1 ∥ T2 ∥ T3} → T4 → T5 → T6` — T1/T2/T3 touch disjoint projects (OpcUaServer/Runtime/Host vs +AbCip vs TwinCAT) ⇒ dispatch their implementers concurrently. diff --git a/docs/plans/2026-06-17-historian-paging-udt-member-paths.md.tasks.json b/docs/plans/2026-06-17-historian-paging-udt-member-paths.md.tasks.json new file mode 100644 index 00000000..a57aff9d --- /dev/null +++ b/docs/plans/2026-06-17-historian-paging-udt-member-paths.md.tasks.json @@ -0,0 +1,19 @@ +{ + "planPath": "docs/plans/2026-06-17-historian-paging-udt-member-paths.md", + "designPath": "docs/plans/2026-06-17-historian-paging-udt-member-paths-design.md", + "designCommit": "ad66ecc9", + "baseMaster": "c402872c", + "branch": "feat/stillpending-historian-paging-udt-members", + "scope": "Bundle two stillpending.md §2 items. WS-A (#400): page Raw history WITHIN an oversized tie-cluster timestamp via a bounded within-timestamp over-fetch (HistoryPaging.SliceTieCluster pure helper + MaxTieClusterOverfetch knob + ServeRawPaged stuck-branch rewrite) instead of the loud BadHistoryOperationUnsupported. WS-B AbCip: fan out controller-discovered UDTs into atomic-member Variables via the cached UDT shape (mirror the pre-declared fan-out). WS-B TwinCAT: pure TwinCATSymbolExpander recursion (over a minimal ITwinCATSymbolNode abstraction) fed by the real ADS ISymbol tree in BrowseSymbolsAsync. NO Commons/wire/proto/EF change; NO bUnit. Live: WS-A unit+integration-proven (historian infra-gated); AbCip best-effort (ab_server partial UDT); TwinCAT operator-gated.", + "dependencyGraph": "{T1 ∥ T2 ∥ T3} → T4 → T5 → T6 (T1 OpcUaServer/Runtime/Host ∥ T2 AbCip ∥ T3 TwinCAT touch disjoint projects)", + "tasks": [ + {"id": 1, "subject": "WS-A #400: HistoryPaging.SliceTieCluster + MaxTieClusterOverfetch option + ServeRawPaged within-timestamp over-fetch + tests", "classification": "standard", "parallelizableWith": [2, 3], "status": "pending"}, + {"id": 2, "subject": "WS-B AbCip: controller-discovered UDT member expansion (fetch shape + fan out atomic members, bounded depth) + tests", "classification": "standard", "parallelizableWith": [1, 3], "status": "pending"}, + {"id": 3, "subject": "WS-B TwinCAT: pure TwinCATSymbolExpander recursion + real BrowseSymbolsAsync ISymbol adapter + tests", "classification": "standard", "parallelizableWith": [1, 2], "status": "pending"}, + {"id": 4, "subject": "Docs (Historian.md paging-limitation→resolved + AbCip.md/TwinCAT.md UDT member sections) + §2 clear via plan record only", "classification": "small", "status": "pending", "blockedBy": [1, 2, 3]}, + {"id": 5, "subject": "Full build + OpcUaServer/AbCip/TwinCAT tests + final integration review", "classification": "standard", "status": "pending", "blockedBy": [4]}, + {"id": 6, "subject": "Live /run best-effort + finish branch (merge to master + push) + memory", "classification": "standard", "status": "pending", "blockedBy": [5]} + ], + "executionState": "PLANNED — ready for subagent-driven execution.", + "lastUpdated": "2026-06-17" +}