Compare commits
7 Commits
c2e89e9d40
...
e8db6c71a8
| Author | SHA1 | Date | |
|---|---|---|---|
| e8db6c71a8 | |||
| 8dcc55f633 | |||
| b88f04ec2d | |||
| adc8ee4afa | |||
| bee295d3ee | |||
| a1186685a9 | |||
| dc43a3f0f6 |
@@ -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,
|
||||
|
||||
+16
@@ -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<int></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]
|
||||
|
||||
+114
-9
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user