From cd15426b21ca4a62e70fed0357b9e1bf9f673d8c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 08:58:05 -0400 Subject: [PATCH] =?UTF-8?q?docs(siteruntime):=20plan=20for=20WaitAsync=20d?= =?UTF-8?q?eferred=20items=20(WaitForAsync,=20quality-gated,=20routed=20?= =?UTF-8?q?=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-17-waitfor-deferred-items.md | 226 ++++++++++++++++++ ...06-17-waitfor-deferred-items.md.tasks.json | 10 + 2 files changed, 236 insertions(+) create mode 100644 docs/plans/2026-06-17-waitfor-deferred-items.md create mode 100644 docs/plans/2026-06-17-waitfor-deferred-items.md.tasks.json diff --git a/docs/plans/2026-06-17-waitfor-deferred-items.md b/docs/plans/2026-06-17-waitfor-deferred-items.md new file mode 100644 index 00000000..696a87f6 --- /dev/null +++ b/docs/plans/2026-06-17-waitfor-deferred-items.md @@ -0,0 +1,226 @@ +# WaitAsync Deferred Optional Items — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (subagent-driven) to implement this plan task-by-task. + +**Goal:** Implement the three items deferred from the WaitAsync spec (`docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md`): §3 `WaitForAsync`/`WaitResult` richer overload, §4.2 quality-gated ("Good"-only) matching, and §6 inbound/routed `Route.To(...).WaitForAttribute` variant. + +**Architecture:** Builds on the shipped core (`b89d69a`→`04e97f4`). Two of the items (§3, §4.2) are site-local enrichments of the existing `Attributes` script surface + `InstanceActor` waiter; no new actor protocol shapes beyond an additive `RequireGoodQuality` field. The third (§6) mirrors the existing `Route.To(...).GetAttributes` cross-cluster path end-to-end (`RouteTarget` → `IInstanceRouter` → `CommunicationService` → `SiteCommunicationActor` → `DeploymentManagerActor` → `InstanceActor`), value-equality only across the wire, with the cluster Ask bounded by the *wait* timeout rather than the generic integration timeout. + +**Tech Stack:** C#/.NET 10, Akka.NET 1.5, xUnit + Akka.TestKit + NSubstitute. + +**Branch/worktree:** `waitfor-attr-helper` at `/Users/dohertj2/Desktop/ScadaBridge/.claude/worktrees/waitfor-attr-helper` (off local main; carries the core feature). Implementers do NOT create worktrees, commit **pathspec form** (`git commit -m "…" -- `), do NOT push, do NOT touch main. Targeted builds/tests per task; full-solution build only in WD-3. + +--- + +## Naming / shared shapes + +- New script return type `WaitResult` (Commons): `public readonly record struct WaitResult(bool Matched, object? Value, string? Quality, bool TimedOut);` +- `WaitForAttributeRequest` gains a trailing additive field `bool RequireGoodQuality = false` (site-local request). `RequireGoodQuality` semantics: a match requires the value test to pass **and** `string.Equals(quality, "Good", StringComparison.Ordinal)`. +- Routed contract (value-equality only, no predicate, no quality flag across the wire — §6 says value-equality only): `RouteToWaitForAttributeRequest` / `RouteToWaitForAttributeResponse` (Commons `Messages/InboundApi`). +- The `WaitForAttributeResponse.Quality` field is already `string?` (null on timeout/error). + +--- + +## Execution waves + +- **Wave 1 (parallel, disjoint files):** WD-1 ∥ WD-2a. (2 concurrent committers; post-wave HEAD-presence check.) +- **Wave 2:** WD-2b (after WD-2a). +- **Wave 3:** WD-3 (after WD-1, WD-2a, WD-2b). + +WD-1 must add `RequireGoodQuality` ONLY as a **trailing defaulted** ctor param of `WaitForAttributeRequest`, so WD-2b's `new WaitForAttributeRequest(...)` (built in wave 2) compiles regardless. + +--- + +### Task WD-1: Site-local `WaitForAsync` + `WaitResult` + quality-gated mode (§3 + §4.2) + +**Classification:** high-risk (modifies the `InstanceActor` single-threaded match evaluation + an additive message-contract field) +**Estimated implement time:** ~5 min +**Parallelizable with:** WD-2a + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/WaitResult.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/WaitForAttribute.cs` (add trailing `bool RequireGoodQuality = false` to `WaitForAttributeRequest`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs` (thread `RequireGoodQuality` into `PendingWait` + both match sites) +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs` (add `WaitAttributeFull` returning `WaitResult`; add `requireGoodQuality` param) +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs` (add `WaitForAsync` overloads + `requireGoodQuality` optional param on `WaitAsync`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs` + `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs` + +**Steps (TDD):** + +1. **`WaitResult`** — add the readonly record struct above. + +2. **`WaitForAttributeRequest`** — add trailing `bool RequireGoodQuality = false`. Keep the `Func<>` predicate field as-is. Update the XML-doc. + +3. **`InstanceActor`** — add `bool RequireGoodQuality` to the `PendingWait` record. At BOTH match sites build the effective match as: + ```csharp + // fast-path (HandleWaitForAttribute): quality from _attributeQualities.GetValueOrDefault(name, ) + // resolve loop (ResolveMatchedWaiters): quality from changed.Quality + bool QualityOk(string? q) => !requireGoodQuality || string.Equals(q, "Good", StringComparison.Ordinal); + bool matched = QualityOk(quality) && test(value); // keep test() inside its existing try/catch + ``` + Store `RequireGoodQuality` on the `PendingWait` so the resolve loop knows it. Keep the throwing-predicate guard (the `QualityOk && test` must still be inside the existing try/catch). The fast-path quality-fail when `requireGoodQuality` is just a non-match → register + schedule timeout as normal (do NOT fast-reply matched). + +4. **`ScriptRuntimeContext`** — refactor: a private `Task WaitInternal(name, encoded, predicate, timeout, requireGoodQuality)` that does the token-bounded `Ask` (keep the existing `AskTimeoutException → ...` handling; on AskTimeout return a synthetic `WaitForAttributeResponse(.., Matched:false, TimedOut:true)`). Then: + ```csharp + public async Task WaitAttribute(string name, string? enc, Func? pred, TimeSpan t, bool requireGoodQuality = false) + => (await WaitInternal(name, enc, pred, t, requireGoodQuality)).Matched; + public async Task WaitAttributeFull(string name, string? enc, Func? pred, TimeSpan t, bool requireGoodQuality = false) + { var r = await WaitInternal(...); return new WaitResult(r.Matched, r.Value, r.Quality, r.TimedOut); } + ``` + (Note: `WaitAttribute`'s existing `AskTimeoutException → return false` must be preserved — fold it into `WaitInternal` returning a non-matched/timed-out response, OR catch in both. Do NOT catch `OperationCanceledException`/`TaskCanceledException`.) + +5. **`AttributeAccessor`** — add `requireGoodQuality` optional param to both existing `WaitAsync` overloads, and add two `WaitForAsync` overloads: + ```csharp + public Task WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false) + => _ctx.WaitAttributeFull(Resolve(key), AttributeValueCodec.Encode(targetValue), null, timeout, requireGoodQuality); + public Task WaitForAsync(string key, Func predicate, TimeSpan timeout, bool requireGoodQuality = false) + => _ctx.WaitAttributeFull(Resolve(key), null, predicate, timeout, requireGoodQuality); + ``` + XML-doc: `requireGoodQuality:true` ignores Bad/Uncertain-quality transients. + +6. **Tests** (extend existing files): (a) `WaitForAsync` returns a populated `WaitResult` on match (Value+Quality) and on timeout (`Matched:false, TimedOut:true`). (b) quality-gated: a value reaching target at **Bad** quality does NOT match when `requireGoodQuality:true` (stays pending → times out), but DOES match when `false`; and matches when it reaches target at Good quality. Cover both fast-path (already-at-target-but-Bad) and change-match. (c) scope resolution still applied for `WaitForAsync`. + +7. Build `Commons` + `SiteRuntime` + the SiteRuntime test project; run `--filter "FullyQualifiedName~WaitForAttribute|FullyQualifiedName~WaitAsync|FullyQualifiedName~WaitForAsync"` and the `~InstanceActor|~ScopeAccessor` regression filter. All green. + +8. Commit (pathspec). + +--- + +### Task WD-2a: Routed contract + central path (§6, part 1) + +**Classification:** high-risk (cross-cluster message contract + `IInstanceRouter` surface) +**Estimated implement time:** ~5 min +**Parallelizable with:** WD-1 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs` (add the two records) +- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/IInstanceRouter.cs` (add method) +- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/CommunicationServiceInstanceRouter.cs` (delegate) +- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs` (`RouteTarget.WaitForAttribute`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs` (`RouteToWaitForAttributeAsync` — **wait-timeout-aware** Ask) +- Modify (compile-break fixes — interface gained a member): `tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs` (`BridgingInstanceRouter`) and the inline `IInstanceRouter` double in `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs` + +**Steps (TDD):** + +1. **Commons records** (mirror `RouteToGetAttributes*`, value-equality only): + ```csharp + public record RouteToWaitForAttributeRequest( + string CorrelationId, string InstanceUniqueName, string AttributeName, + string? TargetValueEncoded, TimeSpan Timeout, DateTimeOffset Timestamp, + Guid? ParentExecutionId = null); + public record RouteToWaitForAttributeResponse( + string CorrelationId, bool Matched, object? Value, string? Quality, bool TimedOut, + bool Success, string? ErrorMessage, DateTimeOffset Timestamp); + ``` + (`Success`/`ErrorMessage` = routing-level outcome, e.g. instance-not-found; `Matched`/`TimedOut`/`Value`/`Quality` = wait outcome.) + +2. **`IInstanceRouter`** — add `Task RouteToWaitForAttributeAsync(string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken);`. **Update all 3 implementers** (prod `CommunicationServiceInstanceRouter` + the 2 test doubles listed above; the test doubles can return a canned response / throw NotImplemented only if never exercised — prefer a sane canned response). + +3. **`CommunicationServiceInstanceRouter`** — delegate to `_communicationService.RouteToWaitForAttributeAsync(...)`. + +4. **`RouteHelper.RouteTarget`** — add (mirror `GetAttributes`, throw on `!Success`): + ```csharp + public async Task WaitForAttribute(string attributeName, object? targetValue, TimeSpan timeout, CancellationToken cancellationToken = default) + { + var token = Effective(cancellationToken); + var siteId = await ResolveSiteAsync(token); + var request = new RouteToWaitForAttributeRequest(Guid.NewGuid().ToString(), _instanceCode, + attributeName, AttributeValueCodec.Encode(targetValue), timeout, DateTimeOffset.UtcNow, _parentExecutionId); + var response = await _instanceRouter.RouteToWaitForAttributeAsync(siteId, request, token); + if (!response.Success) throw new InvalidOperationException(response.ErrorMessage ?? "Remote attribute wait failed"); + return response.Matched; + } + ``` + (`AttributeValueCodec` is in Commons.Types — add the using if needed.) + +5. **`CommunicationService.RouteToWaitForAttributeAsync`** — mirror `RouteToGetAttributesAsync` BUT bound the Ask by the wait timeout, not the generic integration timeout: + ```csharp + var envelope = new SiteEnvelope(siteId, request); + var askTimeout = request.Timeout + _options.IntegrationTimeout; // slack beyond the wait + return await GetActor().Ask(envelope, askTimeout, cancellationToken); + ``` + +6. **Test** (`RouteHelperTests`): with a substitute `IInstanceRouter` returning a canned `RouteToWaitForAttributeResponse(Matched:true,...)`, `Route.To("x").WaitForAttribute("Flag", true, 30s)` returns true; `Success:false` → throws `InvalidOperationException`; the encoded target equals `AttributeValueCodec.Encode(true)`. + +7. Build `Commons` + `InboundAPI` + `Communication` + the two affected test projects; run `--filter "FullyQualifiedName~RouteHelper"` + a build of AuditLog.Tests/InboundAPI.Tests to confirm the interface-addition compiles. Commit (pathspec). + +--- + +### Task WD-2b: Site unpacking + handler (§6, part 2) + +**Classification:** high-risk (actor handler crossing into `InstanceActor`; Ask-timeout correctness) +**Estimated implement time:** ~4 min +**Parallelizable with:** none +**blockedBy:** WD-2a + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs` (add `Receive(msg => _deploymentManagerProxy.Forward(msg));` next to the other RouteTo forwards ~line 145) +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs` (`Receive(RouteInboundApiWaitForAttribute);` + handler) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs` + +**Steps (TDD):** + +1. **`SiteCommunicationActor`** — add the `Receive`/Forward line. + +2. **`DeploymentManagerActor.RouteInboundApiWaitForAttribute`** — mirror `RouteInboundApiGetAttributes`: + ```csharp + private void RouteInboundApiWaitForAttribute(RouteToWaitForAttributeRequest request) + { + if (!_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor)) + { + Sender.Tell(new RouteToWaitForAttributeResponse(request.CorrelationId, false, null, null, false, + false, $"Instance '{request.InstanceUniqueName}' not found on this site.", DateTimeOffset.UtcNow)); + return; + } + var sender = Sender; + var inner = new WaitForAttributeRequest(request.CorrelationId, request.InstanceUniqueName, + request.AttributeName, request.TargetValueEncoded, null /*predicate*/, request.Timeout, + DateTimeOffset.UtcNow /*, RequireGoodQuality defaults false */); + // Ask bounded by the WAIT timeout + slack (NOT a fixed 30s). + instanceActor.Ask(inner, request.Timeout + TimeSpan.FromSeconds(5)) + .ContinueWith(t => t.IsCompletedSuccessfully + ? new RouteToWaitForAttributeResponse(request.CorrelationId, t.Result.Matched, t.Result.Value, + t.Result.Quality, t.Result.TimedOut, true, null, DateTimeOffset.UtcNow) + : new RouteToWaitForAttributeResponse(request.CorrelationId, false, null, null, false, false, + t.Exception?.GetBaseException().Message ?? "Attribute wait timed out", DateTimeOffset.UtcNow)) + .PipeTo(sender); + } + ``` + (`WaitForAttributeRequest` lives in Commons `Messages/Instance` — add the using. Build with both the trailing-`RequireGoodQuality` and pre-field signatures in mind; passing 7 positional args + default is fine.) + +3. **Test** (`DeploymentManagerActorTests`, mirror the routed get-attributes test): deploy/register an instance whose attribute already equals the target → `RouteToWaitForAttributeRequest` → `RouteToWaitForAttributeResponse(Success:true, Matched:true)`; unknown instance → `Success:false`. + +4. Build `Communication` + `SiteRuntime` + SiteRuntime test project; run `--filter "FullyQualifiedName~DeploymentManagerActor"`. Commit (pathspec). + +--- + +### Task WD-3: Integration — docs + full verification + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none +**blockedBy:** WD-1, WD-2a, WD-2b + +**Files:** +- Modify: `docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md` (mark §3 `WaitForAsync`/`WaitResult`, §4.2 quality-gated mode, and §6 routed variant as IMPLEMENTED; note Test-Run sandbox parity excluded) +- Modify: `docs/requirements/Component-SiteRuntime.md` (script-surface note: `Attributes.WaitForAsync` + `requireGoodQuality`) and `docs/requirements/Component-InboundAPI.md` (`Route.To(...).WaitForAttribute`) — brief, only if those docs enumerate the script surface +- (No new component, no migration, no docker config change) + +**Steps:** + +1. Update the spec doc + component docs as above. +2. **Full-solution build:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — 0 errors. +3. **Targeted test sweep** across everything touched: + `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/... --filter "FullyQualifiedName~WaitForAttribute|FullyQualifiedName~WaitAsync|FullyQualifiedName~WaitForAsync|FullyQualifiedName~DeploymentManagerActor"`, + `dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/... --filter "FullyQualifiedName~RouteHelper"`, + and a build of `tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests` + `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests` to confirm no compile/regression from the interface addition. +4. `git diff` review; commit (pathspec). + +--- + +## Out of scope (explicit) + +- Routed `WaitForAttribute` is NOT wired into the CentralUI Test-Run sandbox (`ISandboxInstanceGateway`/`SandboxInstanceGateway`); production inbound scripts get it. Follow-up if Test-Run parity is wanted. +- No predicate or quality flag across the wire (§6 is value-equality only, per spec). +- No docker redeploy (no cluster-runtime config change; additive script surface only). diff --git a/docs/plans/2026-06-17-waitfor-deferred-items.md.tasks.json b/docs/plans/2026-06-17-waitfor-deferred-items.md.tasks.json new file mode 100644 index 00000000..e3f85161 --- /dev/null +++ b/docs/plans/2026-06-17-waitfor-deferred-items.md.tasks.json @@ -0,0 +1,10 @@ +{ + "planPath": "docs/plans/2026-06-17-waitfor-deferred-items.md", + "tasks": [ + {"id": 1, "subject": "WD-1: site-local WaitForAsync + WaitResult + quality-gated mode (§3+§4.2)", "classification": "high-risk", "status": "pending", "parallelizableWith": [2]}, + {"id": 2, "subject": "WD-2a: routed contract + central path (§6 part 1)", "classification": "high-risk", "status": "pending", "parallelizableWith": [1]}, + {"id": 3, "subject": "WD-2b: site unpacking + DeploymentManager handler (§6 part 2)", "classification": "high-risk", "status": "pending", "blockedBy": [2]}, + {"id": 4, "subject": "WD-3: integration — docs + full verification", "classification": "standard", "status": "pending", "blockedBy": [1, 2, 3]} + ], + "lastUpdated": "2026-06-17" +}