Compare commits

..

7 Commits

Author SHA1 Message Date
Joseph Doherty e8db6c71a8 docs(plans): mark WaitAsync compile-surface tasks complete in tasks.json 2026-06-17 11:24:03 -04:00
Joseph Doherty 8dcc55f633 test: address review feedback — cover WaitForAsync predicate overload; clarify ChildrenAccessor parity row; dedup object-method exclusion 2026-06-17 11:16:33 -04:00
Joseph Doherty b88f04ec2d fix(siteruntime): normalize routed WaitForAttribute response value for cross-process transport 2026-06-17 11:10:17 -04:00
Joseph Doherty adc8ee4afa test(scriptanalysis): parity test fails on any unmirrored runtime accessor method 2026-06-17 11:09:00 -04:00
Joseph Doherty bee295d3ee fix(central-ui): mirror WaitForAttribute on inbound-script analysis RouteTarget
Add WaitForAttribute(attributeName, targetValue, timeout, cancellationToken)
to InboundScriptHost.RouteTarget and SandboxInboundScriptHost.RouteTarget,
mirroring the shipped runtime signature in RouteHelper. Eliminates the false
CS error the editor raised against valid Route.To("X").WaitForAttribute(...)
calls in inbound API method scripts. Test asserts the call diagnoses clean
under ScriptKind.InboundApi.
2026-06-17 11:04:13 -04:00
Joseph Doherty a1186685a9 fix(scriptanalysis): mirror WaitAsync/WaitForAsync on CompileAttributeAccessor
Adds the four missing overloads (value + predicate × WaitAsync + WaitForAsync)
to CompileAttributeAccessor so template/call scripts that use Attributes.WaitAsync
or Attributes.WaitForAsync pass design-time Roslyn validation.  Covers both root
scope and composed/child scope (Children["x"].Attributes.WaitAsync) automatically
since CompileCompositionAccessor.Attributes already returns CompileAttributeAccessor.
2026-06-17 11:03:24 -04:00
Joseph Doherty dc43a3f0f6 docs(plans): add WaitAsync compile-surface mirror-gaps plan + tasks 2026-06-17 11:00:41 -04:00
10 changed files with 489 additions and 10 deletions
@@ -0,0 +1,199 @@
# WaitAsync / WaitForAttribute compile-surface & cross-process mirror gaps — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Make the already-shipped `Attributes.WaitAsync`/`WaitForAsync` (template/`call` scripts) and
`Route.To(...).WaitForAttribute(...)` (inbound scripts) actually *usable*, by closing the
compile-/analysis-surface mirror gaps that currently reject them at validation time, and to harden the
parity test so this class of bug can't recur.
**Background (why this exists):** The WaitAsync feature (spec `docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md`
+ deferred-items `docs/plans/2026-06-17-waitfor-deferred-items.md`) is merged to `origin/main` and the
**runtime** works. But scripts that call it **fail deployment** because the *compile-validation* layer —
fake "compile surface" classes that scripts are Roslyn-compiled against during validation — was never
updated to mirror the new methods. This was hit live: deploying a template script calling
`Attributes.WaitAsync(...)` to wonder-app-vd03 failed with:
> `'ScriptCompileSurface.CompileAttributeAccessor' does not contain a definition for 'WaitAsync'`
…which forced a bounded-poll-loop workaround in the DELMIA/MES receiver scripts on prod. Once this plan
ships, those scripts can be switched back to the cheaper event-driven `WaitAsync`.
**Tech Stack:** C#/.NET 10, Roslyn (`Microsoft.CodeAnalysis.CSharp.Scripting`), xUnit. No new dependencies.
**Branch/worktree:** Implement off `origin/main` (currently `c2e89e9`, which carries the WaitAsync runtime +
the routed `WaitForAttribute` runtime). Create a **fresh git worktree** off `origin/main` — do NOT use the
`~/Desktop/ScadaBridge` main checkout (it sits on `feat/ipsen-mes-movein`) and do NOT touch the existing
`.claude/worktrees/*` (other sessions are active there). E.g.:
`git worktree add .claude/worktrees/waitasync-surface origin/main -b fix/waitasync-compile-surface`.
**Verified current source state (origin/main @ c2e89e9):**
- Runtime methods EXIST:
- `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs` — `AttributeAccessor.WaitAsync` (2 overloads, lines ~110/136) + `WaitForAsync` (2 overloads, lines ~158/175), each `(..., bool requireGoodQuality = false)`. `CompositionAccessor.Attributes` returns `AttributeAccessor` (so `Children["x"].Attributes.WaitAsync(...)` uses the same type).
- `src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs` — `public async Task<bool> WaitForAttribute(string attributeName, object? targetValue, TimeSpan timeout, CancellationToken cancellationToken = default)` (line ~220). `IInstanceRouter.RouteToWaitForAttributeAsync` + the site handler `DeploymentManagerActor.RouteInboundApiWaitForAttribute` (line ~1091) already exist and work.
- `WaitResult`: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/WaitResult.cs` — `public readonly record struct WaitResult(bool Matched, object? Value, string? Quality, bool TimedOut)`.
- Mirror gaps (the bugs):
- **GAP 1 (PRIMARY):** `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs` → `CompileAttributeAccessor` (class at line ~171) has `this[]`, `GetAsync`, `SetAsync`, `Resolve` — but **no `WaitAsync`/`WaitForAsync`**. This is the compile surface for `call`/template scripts (and, via `CompileCompositionAccessor.Attributes`, for `Children["x"].Attributes` too).
- **GAP 3 (secondary):** the inbound-script *analysis* mirrors — `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs` (`RouteTarget`, line ~40) and `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs` (`RouteTarget`, line ~38) — expose `Call`/`GetAttributes`/`SetAttributes` but **no `WaitForAttribute`**. (Inbound scripts compile/run at deploy time against the real `InboundScriptContext` via `globalsType: typeof(InboundScriptContext)` in `InboundScriptExecutor`, so deploy is fine — but the CentralUI editor's static analysis uses these mirrors, so `Route.To(...).WaitForAttribute(...)` shows as an error there.)
- **GAP 4 (defensive):** `DeploymentManagerActor.RouteInboundApiWaitForAttribute` returns `RouteToWaitForAttributeResponse` with `t.Result.Value` passed straight through (NOT normalized), unlike `RouteInboundApiCall` which wraps the return in `NormalizeRoutedReturnValue(...)` (added because anon/complex types don't survive the cross-process Newtonsoft serializer). The wait `Value` is a single attribute value (scalar/string/array) and normally round-trips, but this is the same risk surface — verify and harden if needed.
- Parity test that SHOULD have caught GAP 1: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs`.
---
### Task 1: Mirror `WaitAsync`/`WaitForAsync` on the template compile surface (PRIMARY FIX)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs` (`CompileAttributeAccessor`, ~line 171)
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs` and/or `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs`
**Step 1: Write the failing test.** Add a test that a `call`-script body using each wait form **passes**
validation (compiles against the surface). Cover root scope and composed scope. Use the same harness the
existing `GetAsync`/`SetAsync` validation tests use (see `RoslynScriptCompilerTests` / `CompileSurfaceParityTests`).
Example bodies that must compile:
```csharp
await Attributes.WaitAsync("Flag", true, System.TimeSpan.FromSeconds(5));
await Attributes.WaitAsync("Flag", v => v != null, System.TimeSpan.FromSeconds(5), true);
var r = await Attributes.WaitForAsync("Flag", true, System.TimeSpan.FromSeconds(5));
await Children["LeftMESReceiver"].Attributes.WaitAsync("MoveInCompleteFlag", true, System.TimeSpan.FromSeconds(5));
```
**Step 2: Run it — expect FAIL** with "does not contain a definition for 'WaitAsync'".
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ -f` (or the SiteRuntime parity test project).
**Step 3: Add the four mirror methods** to `CompileAttributeAccessor`, signatures **identical** to the
runtime `AttributeAccessor` (ScopeAccessors.cs), bodies throwing the existing `CompileOnly` sentinel like
the sibling members:
```csharp
/// <summary>Mirrors <c>AttributeAccessor.WaitAsync</c>.</summary>
public Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>AttributeAccessor.WaitForAsync</c>.</summary>
public Task<WaitResult> WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
public Task<WaitResult> WaitForAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
```
`WaitResult` resolves via the existing `using ZB.MOM.WW.ScadaBridge.Commons.Types;` (already at the top of the
file). Ensure `using System;` (for `Func<>`/`TimeSpan`) and `System.Threading.Tasks` are present (they are).
Only `CompileAttributeAccessor` needs editing — `CompileCompositionAccessor.Attributes` already returns it, so
`Children["x"].Attributes.WaitAsync(...)` is covered automatically.
**Step 4: Run the test — expect PASS.** Also run the full ScriptAnalysis + SiteRuntime script test projects to
confirm no regressions.
**Step 5: Commit** (pathspec form): `git commit -m "fix(scriptanalysis): mirror WaitAsync/WaitForAsync on CompileAttributeAccessor" -- src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs tests/...`
---
### Task 2: Harden `CompileSurfaceParityTests` so missing mirrors fail the build
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (do after Task 1 so the new methods exist)
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs`
**Why:** This test exists to keep the compile surface in lockstep with the runtime script API, yet it did
not catch the WaitAsync omission. Close that hole.
**Steps:**
1. Read the current test to learn its parity strategy (how it maps runtime types ↔ compile-surface types).
2. Add/strengthen a reflection check: for each **public instance method** on the runtime `AttributeAccessor`
(`ScopeAccessors.cs`), assert a method of the **same name and matching parameter count/arity** exists on
`ScriptCompileSurface.CompileAttributeAccessor`. Do the same mapping for `CompositionAccessor` ↔
`CompileCompositionAccessor` and `ChildrenAccessor` ↔ `CompileChildrenAccessor` if not already covered.
(Match by name + parameter count to tolerate the `object?`-vs-mirror type differences; the goal is to fail
when a runtime method has **no** counterpart, as WaitAsync did.)
3. Run it — it must PASS now (Task 1 added the methods). Temporarily delete one mirror method locally to
confirm the test goes RED, then restore.
4. Commit (pathspec form).
---
### Task 3: Mirror `WaitForAttribute` on the inbound-script analysis surfaces
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs` (`RouteTarget`, ~line 40)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs` (`RouteTarget`, ~line 38)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs`
**Note:** Inbound deploy/runtime already works (it compiles against the real `InboundScriptContext`), so this
is purely to fix the CentralUI **editor static analysis** flagging a valid call. If, after reading the test,
you find inbound scripts are *not* statically analysed against these mirror `RouteTarget`s anywhere a user
sees the result, this task may be dropped — confirm before deciding.
**Steps:**
1. Add a `WaitForAttribute` method to each analysis `RouteTarget`, mirroring the runtime
`RouteHelper.WaitForAttribute` signature, in the same throwing/`CompileOnly` style as the sibling `Call`:
```csharp
public System.Threading.Tasks.Task<bool> WaitForAttribute(
string attributeName, object? targetValue, System.TimeSpan timeout,
System.Threading.CancellationToken cancellationToken = default) => throw new NotSupportedException(/* CompileOnly */);
```
(Match each file's existing convention for the throw/sentinel and namespacing.)
2. Add a test asserting an inbound script using `await Route.To("X").WaitForAttribute("Flag", true, System.TimeSpan.FromSeconds(5))` passes analysis.
3. Run the CentralUI analysis test project; commit (pathspec form).
---
### Task 4: Verify (and if needed normalize) the routed `WaitForAttribute` response value
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Files:**
- Inspect/Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs` (`RouteInboundApiWaitForAttribute`, ~line 1091; `NormalizeRoutedReturnValue`, ~line 999)
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/` (the routed-wait / DeploymentManager tests)
**Why:** `RouteInboundApiCall` normalizes its return value before sending it back across the Central↔Site
Akka boundary (anon/complex types are silently dropped by the cross-process serializer → caller `Ask`
hangs to timeout). `RouteInboundApiWaitForAttribute` passes `t.Result.Value` through un-normalized. The wait
value is a single attribute value and usually round-trips, so this may already be safe.
**Steps:**
1. Determine whether a `WaitForAttribute` `Value` can be a type that does NOT round-trip the configured
cross-process serializer (check what `InstanceActor` puts in `WaitForAttributeResponse.Value` — raw boxed
attribute value? a `List<...>` for array attributes? already codec-encoded?).
2. If any non-trivially-serializable shape is possible, wrap it: `NormalizeRoutedReturnValue(t.Result.Value)`
in the success branch (reuse the existing helper). If it is provably always a primitive/string/`null`,
instead add a brief code comment documenting *why* it is safe and a test asserting an array-valued wait
round-trips end-to-end (a regression guard).
3. Add/extend a test for the routed-wait response carrying a representative value across the boundary.
4. Run the SiteRuntime actor tests; commit (pathspec form).
---
## After all tasks
1. Full solution build + run the affected test projects (ScriptAnalysis, SiteRuntime, CentralUI, InboundAPI).
2. Dispatch a final reviewer over the cumulative diff.
3. Use superpowers-extended-cc:finishing-a-development-branch.
## Downstream follow-up (NOT part of this plan — do after it ships & deploys)
Once this is merged and deployed to **wonder-app-vd03**, the DELMIA/MES receiver scripts there
(`MesMoveIn`/`MesMoveOut` on T1 + `ProcessRecipeDownload` on T1, provisioned 2026-06-17) currently use a
bounded poll loop as the WaitAsync fallback. They can be switched to the event-driven form via the mgmt API
`UpdateTemplateScript`, replacing:
```csharp
bool done = false;
for (int i = 0; i < 100 && !done && !CancellationToken.IsCancellationRequested; i++) {
await System.Threading.Tasks.Task.Delay(250, CancellationToken);
done = IsTrue(await recv.Attributes.GetAsync("MoveInCompleteFlag"));
}
if (!done) return /* timeout */;
```
with:
```csharp
if (!await recv.Attributes.WaitAsync("MoveInCompleteFlag", true, System.TimeSpan.FromSeconds(30)))
return /* timeout */;
```
(and the analogous `MoveOutCompleteFlag` / `RecipeProcessedFlag` lines). Verify the deployed build includes
Task 1 first (a deploy of a WaitAsync script will otherwise fail validation again).
@@ -0,0 +1,17 @@
{
"planPath": "docs/plans/2026-06-17-waitasync-compile-surface-mirror-gaps.md",
"branch": "fix/waitasync-compile-surface",
"worktree": ".claude/worktrees/waitasync-surface",
"baseSha": "c2e89e9d40fbfd0b11b1b5124efcb835dfb9eb90",
"tasks": [
{"id": 158, "key": "WS-1", "subject": "Mirror WaitAsync/WaitForAsync on template compile surface", "classification": "small", "parallelizableWith": ["WS-3"], "status": "completed", "commit": "a118668"},
{"id": 159, "key": "WS-2", "subject": "Harden CompileSurfaceParityTests to catch missing mirrors", "classification": "standard", "parallelizableWith": [], "blockedBy": [158], "status": "completed", "commit": "adc8ee4"},
{"id": 160, "key": "WS-3", "subject": "Mirror WaitForAttribute on inbound-script analysis surfaces", "classification": "small", "parallelizableWith": ["WS-1"], "status": "completed", "commit": "bee295d"},
{"id": 161, "key": "WS-4", "subject": "Verify/normalize routed WaitForAttribute response value", "classification": "standard", "parallelizableWith": [], "status": "completed", "commit": "b88f04e"}
],
"reviewPolishCommit": "8dcc55f",
"followUps": [
{"id": 162, "subject": "normalize routed GetAttributes List values for cross-process transport (surfaced by WS-4 review)"}
],
"lastUpdated": "2026-06-17"
}
@@ -98,5 +98,21 @@ public class InboundScriptHost
IReadOnlyDictionary<string, string> attributeValues,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.CompletedTask;
/// <summary>
/// Waits until a named attribute on the target instance reaches the specified value,
/// or until the timeout elapses.
/// </summary>
/// <param name="attributeName">The name of the attribute to watch.</param>
/// <param name="targetValue">The value to wait for.</param>
/// <param name="timeout">Maximum time to wait before returning false.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to true if the attribute reached the target value within the timeout, false otherwise.</returns>
public System.Threading.Tasks.Task<bool> WaitForAttribute(
string attributeName,
object? targetValue,
System.TimeSpan timeout,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult(false);
}
}
@@ -104,6 +104,21 @@ public class SandboxInboundScriptHost
CancellationToken cancellationToken = default) =>
throw Unavailable("SetAttributes(...)");
/// <summary>
/// Always throws <see cref="ScriptSandboxException"/>; cross-site routing is unavailable in a Test Run.
/// </summary>
/// <param name="attributeName">Attribute name (included in the exception message).</param>
/// <param name="targetValue">Unused target value.</param>
/// <param name="timeout">Unused timeout.</param>
/// <param name="cancellationToken">Unused token.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/>.</returns>
public Task<bool> WaitForAttribute(
string attributeName,
object? targetValue,
TimeSpan timeout,
CancellationToken cancellationToken = default) =>
throw Unavailable($"WaitForAttribute(\"{attributeName}\")");
private ScriptSandboxException Unavailable(string operation) =>
new($"Route.To(\"{_instanceCode}\").{operation} is not available in Test Run — " +
"cross-site routing needs a deployed site reachable over the cluster transport.");
@@ -185,6 +185,14 @@ public sealed class ScriptCompileSurface
/// <summary>Mirrors <c>AttributeAccessor.Resolve</c>.</summary>
public string Resolve(string key) => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>AttributeAccessor.WaitAsync</c>.</summary>
public Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>AttributeAccessor.WaitForAsync</c>.</summary>
public Task<WaitResult> WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
public Task<WaitResult> WaitForAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
}
/// <summary>Compile-only mirror of <c>ChildrenAccessor</c>.</summary>
@@ -1108,8 +1108,19 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Ask bounded by the WAIT timeout + slack — NOT a fixed 30s (the wait legitimately blocks up to request.Timeout).
instanceActor.Ask<WaitForAttributeResponse>(inner, request.Timeout + TimeSpan.FromSeconds(5))
.ContinueWith(t => t.IsCompletedSuccessfully
// The matched value crosses the Central↔Site PROCESS boundary inside this
// response. For a List-typed attribute the Instance Actor's matched
// `WaitForAttributeResponse.Value` is a concrete generic List<T>
// (List<int>/List<double>/List<DateTime>/… built by TryCoerceListValue;
// see InstanceActor.HandleTagValueUpdate / ResolveMatchedWaiters) — a
// non-primitive shape that Akka's cross-process serializer cannot reliably
// round-trip, which would silently drop the reply and hang the caller's
// Ask. Project it through the same normalizer RouteInboundApiCall uses so
// the wire carries a plain CLR graph (List/primitive). Scalars/strings/null
// pass through unchanged.
? new RouteToWaitForAttributeResponse(
request.CorrelationId, t.Result.Matched, t.Result.Value, t.Result.Quality, t.Result.TimedOut,
request.CorrelationId, t.Result.Matched, NormalizeRoutedReturnValue(t.Result.Value),
t.Result.Quality, t.Result.TimedOut,
true, null, DateTimeOffset.UtcNow)
: new RouteToWaitForAttributeResponse(
request.CorrelationId, false, null, null, false,
@@ -644,4 +644,20 @@ public class ScriptAnalysisServiceTests
Assert.All(lines, l => Assert.Equal($"S{i}", l.Trim()));
}
}
// ── Inbound-script analysis surface ──────────────────────────────────
[Fact]
public void InboundScript_WaitForAttribute_DiagnosesClean()
{
// WaitForAttribute is a shipped inbound-script helper. The editor must
// not flag it as an error: the InboundScriptHost mirror must expose the
// method so Roslyn resolves it during static analysis.
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var ok = await Route.To(\"site-a\").WaitForAttribute(\"Flag\", true, System.TimeSpan.FromSeconds(5));",
Kind: ScriptKind.InboundApi));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
}
@@ -69,4 +69,26 @@ public class RoslynScriptCompilerTests
var diagnostics = RoslynScriptCompiler.Compile(expr, typeof(TriggerCompileSurface));
Assert.Empty(diagnostics);
}
[Fact]
public void Compile_Empty_ForWaitAsyncAndWaitForAsync()
{
// Covers all four overloads: value + predicate for both WaitAsync and
// WaitForAsync, on both root scope and composed/child scope.
const string code = """
// Root scope — value overload
var matched = await Attributes.WaitAsync("Flag", true, System.TimeSpan.FromSeconds(5));
// Root scope — predicate overload with requireGoodQuality
var matched2 = await Attributes.WaitAsync("Flag", v => v != null, System.TimeSpan.FromSeconds(5), true);
// Root scope — WaitForAsync value overload
var r = await Attributes.WaitForAsync("Flag", true, System.TimeSpan.FromSeconds(5));
// Root scope — WaitForAsync predicate overload with requireGoodQuality
var r2 = await Attributes.WaitForAsync("Flag", v => v != null, System.TimeSpan.FromSeconds(5), true);
// Composed/child scope — value overload
var childMatched = await Children["LeftMESReceiver"].Attributes.WaitAsync("MoveInCompleteFlag", true, System.TimeSpan.FromSeconds(5));
""";
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
Assert.Empty(diagnostics);
}
}
@@ -78,6 +78,30 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
return JsonSerializer.Serialize(config);
}
/// <summary>
/// Builds a config carrying a single STATIC List attribute whose canonical
/// JSON-array default the Instance Actor decodes into a typed <c>List&lt;int&gt;</c>
/// in memory (InstanceActor.PreStart / DecodeAttributeValue). Used to drive a
/// routed wait whose matched value is a collection — the shape WS-4 normalizes
/// for cross-process transport.
/// </summary>
private static string MakeConfigWithListAttributeJson(string instanceName)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Setpoints", Value = "[10,20,30]",
DataType = "List", ElementDataType = "Int32"
}
]
};
return JsonSerializer.Serialize(config);
}
/// <summary>
/// Builds a config carrying a single callable (no-trigger) script that
/// returns a constant — enough for an inbound <see cref="RouteToCallRequest"/>
@@ -445,6 +469,52 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RouteInboundApiWaitForAttribute_ListValuedMatch_ReturnsSerializerSafeValue()
{
// WS-4: a routed wait whose matched attribute is a List-typed value comes
// back from the Instance Actor as a concrete generic List<int> — a shape the
// cross-process (Newtonsoft) serializer cannot reliably round-trip, which would
// otherwise silently drop the reply and hang the caller's Ask. The handler must
// normalize the value (via the same NormalizeRoutedReturnValue projection that
// RouteInboundApiCall uses) to a plain CLR graph that survives transport while
// preserving the matched elements.
var actor = CreateDeploymentManager();
await Task.Delay(500); // empty startup
// Static List attribute "Setpoints" = [10,20,30] (decoded to List<int>, Good).
actor.Tell(new DeployInstanceCommand(
"dep-wait-list", "WaitPumpList", "sha256:wait-list",
MakeConfigWithListAttributeJson("WaitPumpList"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(1000); // let the InstanceActor spin up + decode the List default
// Encode the target the same canonical way the Instance Actor encodes the
// current List<int> for its codec-equality match (value-equality across the wire).
var encodedTarget = AttributeValueCodec.Encode(new List<int> { 10, 20, 30 });
actor.Tell(new RouteToWaitForAttributeRequest(
"wait-corr-list", "WaitPumpList", "Setpoints", encodedTarget,
TimeSpan.FromSeconds(5), DateTimeOffset.UtcNow));
var response = ExpectMsg<RouteToWaitForAttributeResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("wait-corr-list", response.CorrelationId);
Assert.True(response.Success, $"Routed wait failed: {response.ErrorMessage}");
Assert.True(response.Matched, "Expected fast-path match (List attribute already at target).");
Assert.False(response.TimedOut);
Assert.Equal("Good", response.Quality);
// The matched value must have been projected to a serializer-safe graph:
// a plain List<object?> of primitives — NOT the Instance Actor's concrete
// List<int> (which Akka's cross-process serializer drops). The JSON round-trip
// re-materializes the numbers as boxed primitives; assert numeric equality
// without pinning the exact CLR numeric type (long vs double is an artifact).
Assert.NotNull(response.Value);
var list = Assert.IsType<List<object?>>(response.Value);
Assert.Equal(
new[] { 10.0, 20.0, 30.0 },
list.Select(e => Convert.ToDouble(e, System.Globalization.CultureInfo.InvariantCulture)));
}
// ── M2.11: Debug-view routing — unknown-instance not-found signal ──
[Fact]
@@ -41,6 +41,73 @@ public class CompileSurfaceParityTests
globals: typeof(TriggerExpressionGlobals));
}
/// <summary>
/// Deeper guard than the top-level name-superset check above: for each
/// runtime script-accessor type (<see cref="AttributeAccessor"/>,
/// <see cref="CompositionAccessor"/>, <see cref="ChildrenAccessor"/>) every
/// public instance METHOD must have a same-name / same-arity counterpart on
/// its compile-surface mirror. This catches the regression that motivated this
/// test (the <c>WaitAsync</c> overloads were present on the runtime
/// <see cref="AttributeAccessor"/> but missing from
/// <see cref="ScriptCompileSurface.CompileAttributeAccessor"/>, so scripts that
/// awaited them passed the design-time gate yet would have failed at the site).
///
/// <para>
/// Matching is by NAME + PARAMETER COUNT only — the mirror uses
/// <c>object?</c>-vs-mirror-type substitutions, so exact parameter-type identity
/// is intentionally NOT required; the guard is about API presence, not type
/// equality (the design-time compile catches deeper signature drift itself).
/// To still catch a single dropped overload within an overload set that shares
/// an arity (e.g. the value-form vs predicate-form <c>WaitAsync</c>, both
/// 4-parameter), the assertion is by COUNT: the mirror must expose at least as
/// many overloads for each (name, arity) as the runtime accessor declares.
/// </para>
/// </summary>
[Theory]
[MemberData(nameof(AccessorMirrorPairs))]
public void CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities(
Type runtimeAccessor, Type compileMirror)
{
var mirrorCounts = PublicInstanceMethodArityCounts(compileMirror);
var runtimeCounts = PublicInstanceMethodArityCounts(runtimeAccessor);
var shortfalls = runtimeCounts
.Where(kvp => mirrorCounts.GetValueOrDefault(kvp.Key) < kvp.Value)
.OrderBy(kvp => kvp.Key.Name, StringComparer.Ordinal)
.ThenBy(kvp => kvp.Key.Arity)
.Select(kvp =>
$"{kvp.Key.Name}({kvp.Key.Arity} param(s)): runtime has {kvp.Value} "
+ $"overload(s), mirror has {mirrorCounts.GetValueOrDefault(kvp.Key)}")
.ToList();
Assert.True(
shortfalls.Count == 0,
$"Compile surface mirror '{compileMirror.Name}' under-covers {shortfalls.Count} "
+ $"method group(s) on runtime accessor '{runtimeAccessor.Name}': "
+ $"{string.Join("; ", shortfalls)}. "
+ "The compile-only mirror must expose a same-name / same-arity method for "
+ "every public instance method a script can call on the runtime accessor — "
+ "add the missing method(s) to the ScriptAnalysis compile surface so a "
+ "script using them cannot pass the design-time gate then fail at the site.");
}
/// <summary>
/// Runtime script-accessor type ↔ compile-surface mirror pairs guarded by
/// <see cref="CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities"/>.
/// </summary>
public static IEnumerable<object[]> AccessorMirrorPairs() =>
new[]
{
new object[] { typeof(AttributeAccessor), typeof(ScriptCompileSurface.CompileAttributeAccessor) },
new object[] { typeof(CompositionAccessor), typeof(ScriptCompileSurface.CompileCompositionAccessor) },
// ChildrenAccessor currently exposes only an indexer (no public instance
// methods), so this row guards nothing today. It is kept so that if a
// callable method is ever added to ChildrenAccessor, this Theory begins
// enforcing a mirror for it automatically — rather than silently
// re-opening the WaitAsync-class gap.
new object[] { typeof(ChildrenAccessor), typeof(ScriptCompileSurface.CompileChildrenAccessor) },
};
/// <summary>
/// Asserts that the public instance property + method member names of
/// <paramref name="surface"/> are a superset of those of
@@ -76,14 +143,6 @@ public class CompileSurfaceParityTests
{
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance;
var objectMembers = new HashSet<string>(StringComparer.Ordinal)
{
nameof(ToString),
nameof(GetHashCode),
nameof(Equals),
nameof(GetType),
};
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (var property in type.GetProperties(flags))
@@ -95,11 +154,57 @@ public class CompileSurfaceParityTests
// property itself is already counted by name above.
if (method.IsSpecialName)
continue;
if (objectMembers.Contains(method.Name))
if (ObjectMethodNames.Contains(method.Name))
continue;
names.Add(method.Name);
}
return names;
}
/// <summary>
/// Names of the inherited <see cref="object"/> instance methods that are not
/// part of any script-reachable accessor API and must be excluded from parity.
/// </summary>
private static readonly HashSet<string> ObjectMethodNames = new(StringComparer.Ordinal)
{
nameof(ToString),
nameof(GetHashCode),
nameof(Equals),
nameof(GetType),
};
/// <summary>
/// (Name, parameter count) of each public instance METHOD declared on or
/// inherited by <paramref name="type"/>, excluding inherited <see cref="object"/>
/// methods and compiler-generated special-name methods (property/indexer
/// get_/set_ accessors, operators). These are the methods a script can call
/// directly on the accessor, the surface this guard requires the mirror to cover.
/// </summary>
private static IEnumerable<(string Name, int Arity)> PublicInstanceMethodSignatures(Type type)
{
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance;
foreach (var method in type.GetMethods(flags))
{
if (method.IsSpecialName)
continue;
if (ObjectMethodNames.Contains(method.Name))
continue;
yield return (method.Name, method.GetParameters().Length);
}
}
/// <summary>
/// Number of public instance method overloads on <paramref name="type"/> per
/// (Name, parameter count) key — used to test method-arity parity against a
/// runtime accessor by count (so a single dropped overload within a shared-arity
/// overload set is caught, not just a wholly-missing method group). Built with
/// <see cref="PublicInstanceMethodSignatures"/> so the same object/special-name
/// exclusions apply on both sides.
/// </summary>
private static Dictionary<(string Name, int Arity), int> PublicInstanceMethodArityCounts(Type type) =>
PublicInstanceMethodSignatures(type)
.GroupBy(sig => sig)
.ToDictionary(g => g.Key, g => g.Count());
}