feat(testrun): route WaitForAttribute in the Test-Run sandbox (deferred #14)

Closes deferred-work register item #14 with full fidelity. The Central UI Test-Run
sandbox previously threw ScriptSandboxException for Attributes.WaitAsync/WaitForAsync;
now the value-equality forms route to the bound deployed instance over the same
cross-site path inbound Route.To().WaitForAttribute() uses.

- Commons: additive trailing RouteToWaitForAttributeRequest.RequireGoodQuality
  (default false; message-evolution safe) so quality-gated waits route too.
- SiteRuntime: DeploymentManagerActor's routed-wait handler now threads
  request.RequireGoodQuality into the InstanceActor WaitForAttributeRequest
  (previously hard-coded to default).
- CentralUI sandbox: ISandboxInstanceGateway.WaitForAttributeAsync +
  SandboxInstanceGateway impl (via CommunicationService.RouteToWaitForAttributeAsync);
  SandboxAttributeAccessor value-equality WaitAsync/WaitForAsync route through it
  (codec-encoded target, scope-resolved name). Predicate overloads stay unsupported
  (an in-process lambda can't be routed) and throw a clearer, dedicated message.
- Tests: sandbox accessor routing + predicate/unbound rejection (CentralUI);
  site-handler RequireGoodQuality threading (SiteRuntime). Register + the historical
  waitfor-deferred plan doc updated.

Full slnx build 0/0; CentralUI 59, SiteRuntime routed-wait 4, InboundAPI wait 32 green.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 09:29:38 -04:00
parent 4cd21b342b
commit 22136d36d9
8 changed files with 216 additions and 31 deletions
@@ -1,4 +1,5 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
@@ -74,4 +75,22 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
$"CallScript(\"{canonicalScriptName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
return response.ReturnValue;
}
/// <inheritdoc />
public async Task<WaitResult> WaitForAttributeAsync(
string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct)
{
// Reuse the inbound-API cross-site wait route (RouteToWaitForAttributeAsync) — the
// same real event-driven waiter Route.To().WaitForAttribute() uses. ParentExecutionId
// is null for the sandbox path (no inbound execution to link).
var request = new RouteToWaitForAttributeRequest(
Guid.NewGuid().ToString(), _instanceUniqueName, canonicalName,
targetValueEncoded, timeout, DateTimeOffset.UtcNow,
ParentExecutionId: null, RequireGoodQuality: requireGoodQuality);
var response = await _comms.RouteToWaitForAttributeAsync(_siteId, request, _runToken);
if (!response.Success)
throw new ScriptSandboxException(
$"WaitForAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
return new WaitResult(response.Matched, response.Value, response.Quality, response.TimedOut);
}
}
@@ -114,6 +114,21 @@ public interface ISandboxInstanceGateway
/// <returns>The script result, or null if none.</returns>
Task<object?> CallScriptAsync(
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct);
/// <summary>
/// Blocks until the named attribute reaches a target value (value-equality,
/// codec-encoded), optionally quality-gated, or the timeout elapses — routed to the
/// bound deployed instance cross-site. Value-equality only: a script-side predicate
/// cannot be routed, so the sandbox predicate overloads stay unsupported.
/// </summary>
/// <param name="canonicalName">The (already scope-resolved) canonical attribute name.</param>
/// <param name="targetValueEncoded">The <c>AttributeValueCodec</c>-encoded target value, or null for "any change".</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="requireGoodQuality">When true, only a Good-quality value satisfies the wait.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The wait outcome (matched flag, matched value, quality, timed-out flag).</returns>
Task<WaitResult> WaitForAttributeAsync(
string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct);
}
/// <summary>
@@ -212,6 +227,26 @@ public class SandboxInstanceContext
"bind one in Test Run to call sibling scripts.");
return _gateway.CallScriptAsync(scriptName, ScriptArgs.Normalize(parameters), CancellationToken.None);
}
/// <summary>
/// Routes a value-equality attribute wait to the bound deployed instance cross-site.
/// Needs a bound instance (the site owns the event-driven waiter); throws when unbound.
/// </summary>
/// <param name="canonicalName">The (already scope-resolved) canonical attribute name.</param>
/// <param name="targetValueEncoded">The codec-encoded target value, or null for "any change".</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="requireGoodQuality">When true, only a Good-quality value satisfies the wait.</param>
/// <returns>The wait outcome.</returns>
public Task<WaitResult> WaitForAttribute(
string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"WaitForAttribute(\"{canonicalName}\") needs a deployed instance — " +
"bind one in Test Run to exercise the event-driven attribute waiter.");
return _gateway.WaitForAttributeAsync(
canonicalName, targetValueEncoded, timeout, requireGoodQuality, CancellationToken.None);
}
}
/// <summary>
@@ -342,20 +377,23 @@ public class SandboxAttributeAccessor
=> throw NotInSandbox(nameof(WriteBatchAndWaitAsync));
/// <summary>
/// Sandbox stand-in for <c>AttributeAccessor.WaitAsync</c> (value-equality form);
/// see <see cref="WriteBatchAndWaitAsync"/>.
/// Value-equality attribute wait — routed to the bound deployed instance cross-site
/// (the same real event-driven waiter a site uses). Needs a bound instance; throws
/// <see cref="ScriptSandboxException"/> when the Test Run has no instance bound.
/// </summary>
/// <param name="key">The attribute key to observe.</param>
/// <param name="targetValue">The value to wait for.</param>
/// <param name="targetValue">The value to wait for (codec-normalized), or null for "any change".</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
public Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
=> throw NotInSandbox(nameof(WaitAsync));
/// <returns>True when the attribute reached the target within the timeout; false on timeout.</returns>
public async Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
=> (await _ctx.WaitForAttribute(
Resolve(key), AttributeValueCodec.Encode(targetValue), timeout, requireGoodQuality)).Matched;
/// <summary>
/// Sandbox stand-in for <c>AttributeAccessor.WaitAsync</c> (predicate form);
/// see <see cref="WriteBatchAndWaitAsync"/>.
/// Predicate-form attribute wait. Unsupported in Test Run because a script-side
/// predicate (an in-process lambda) cannot be evaluated on the remote site — only the
/// value-equality overloads route. Throws a clearly-labelled <see cref="ScriptSandboxException"/>.
/// </summary>
/// <param name="key">The attribute key to observe.</param>
/// <param name="predicate">The predicate the attribute value must satisfy.</param>
@@ -363,23 +401,25 @@ public class SandboxAttributeAccessor
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
=> throw NotInSandbox(nameof(WaitAsync));
=> throw PredicateWaitNotRoutable(nameof(WaitAsync));
/// <summary>
/// Sandbox stand-in for <c>AttributeAccessor.WaitForAsync</c> (value-equality form);
/// see <see cref="WriteBatchAndWaitAsync"/>.
/// Value-equality attribute wait returning the full <see cref="WaitResult"/> — routed
/// to the bound deployed instance cross-site. Needs a bound instance; throws
/// <see cref="ScriptSandboxException"/> when the Test Run has no instance bound.
/// </summary>
/// <param name="key">The attribute key to observe.</param>
/// <param name="targetValue">The value to wait for.</param>
/// <param name="targetValue">The value to wait for (codec-normalized), or null for "any change".</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
/// <returns>The wait outcome (matched flag, matched value, quality, timed-out flag).</returns>
public Task<WaitResult> WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
=> throw NotInSandbox(nameof(WaitForAsync));
=> _ctx.WaitForAttribute(Resolve(key), AttributeValueCodec.Encode(targetValue), timeout, requireGoodQuality);
/// <summary>
/// Sandbox stand-in for <c>AttributeAccessor.WaitForAsync</c> (predicate form);
/// see <see cref="WriteBatchAndWaitAsync"/>.
/// Predicate-form attribute wait returning the full <see cref="WaitResult"/>.
/// Unsupported in Test Run for the same reason as the predicate <see cref="WaitAsync(string, Func{object?, bool}, TimeSpan, bool)"/>
/// overload — an in-process predicate cannot be routed to the remote site.
/// </summary>
/// <param name="key">The attribute key to observe.</param>
/// <param name="predicate">The predicate the attribute value must satisfy.</param>
@@ -387,12 +427,18 @@ public class SandboxAttributeAccessor
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
public Task<WaitResult> WaitForAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
=> throw NotInSandbox(nameof(WaitForAsync));
=> throw PredicateWaitNotRoutable(nameof(WaitForAsync));
private static ScriptSandboxException NotInSandbox(string member) =>
new($"{member}(...) drives live device tags and the site's event-driven " +
"attribute waiter, which aren't available in the central Test Run sandbox — " +
"deploy to a site to exercise batch-write/wait handshakes.");
private static ScriptSandboxException PredicateWaitNotRoutable(string member) =>
new($"{member}(...) with a predicate can't run in Test Run — a script-side " +
"predicate is an in-process lambda that cannot be evaluated on the remote " +
"site. Use the value-equality overload (which routes to the bound instance), " +
"or deploy to a site to exercise predicate waits.");
}
/// <summary>
@@ -87,9 +87,10 @@ public record RouteToSetAttributesResponse(
/// <summary>
/// Request to block until a remote instance attribute reaches a target value
/// (spec §6 — <c>Route.To("inst").WaitForAttribute(name, targetValue, timeout)</c>).
/// Value-equality ONLY across the wire: <see cref="TargetValueEncoded"/> carries the
/// canonical <c>AttributeValueCodec</c>-encoded target; there is no predicate and no
/// quality flag in the comparison. The site evaluates equality and either matches or
/// Value-equality across the wire: <see cref="TargetValueEncoded"/> carries the
/// canonical <c>AttributeValueCodec</c>-encoded target; there is no predicate (a
/// cross-process lambda cannot be routed). <see cref="RequireGoodQuality"/> optionally
/// gates the match on Good quality. The site evaluates the match and either matches or
/// times out.
/// </summary>
/// <param name="ParentExecutionId">
@@ -99,6 +100,12 @@ public record RouteToSetAttributesResponse(
/// so the inbound→site execution-tree link survives the wait path. Additive trailing
/// member — null for the Central UI sandbox path or for callers built before the field existed.
/// </param>
/// <param name="RequireGoodQuality">
/// Quality-gated ("Good"-only) mode (spec §4.2): when true, a value reaching the target at
/// Bad/Uncertain quality is NOT a match — the site holds the wait until the value satisfies
/// the target at Good quality (or times out). Additive trailing member; defaults to
/// <see langword="false"/> (quality-agnostic) so older callers and the wire default are unchanged.
/// </param>
public record RouteToWaitForAttributeRequest(
string CorrelationId,
string InstanceUniqueName,
@@ -106,7 +113,8 @@ public record RouteToWaitForAttributeRequest(
string? TargetValueEncoded,
TimeSpan Timeout,
DateTimeOffset Timestamp,
Guid? ParentExecutionId = null);
Guid? ParentExecutionId = null,
bool RequireGoodQuality = false);
/// <summary>
/// Response from a remote attribute wait. <see cref="Success"/>/<see cref="ErrorMessage"/>
@@ -1756,10 +1756,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// Spec §6 (WD-2b): unpacks a routed <see cref="RouteToWaitForAttributeRequest"/>
/// (inbound-API <c>Route.To().WaitForAttribute()</c>) into the deployed
/// Instance Actor's site-local <see cref="WaitForAttributeRequest"/> and relays
/// the result back. Value-equality only across the wire — the predicate is null
/// and <c>RequireGoodQuality</c> is left at its default. The Ask is bounded by the
/// wait timeout plus slack (NOT a fixed 30s), since the wait legitimately blocks
/// for up to <see cref="RouteToWaitForAttributeRequest.Timeout"/>.
/// the result back. Value-equality across the wire — the predicate is null (a
/// cross-process lambda cannot be routed), but <c>RequireGoodQuality</c> is honored
/// from <see cref="RouteToWaitForAttributeRequest.RequireGoodQuality"/>. The Ask is
/// bounded by the wait timeout plus slack (NOT a fixed 30s), since the wait
/// legitimately blocks for up to <see cref="RouteToWaitForAttributeRequest.Timeout"/>.
/// </summary>
private void RouteInboundApiWaitForAttribute(RouteToWaitForAttributeRequest request)
{
@@ -1773,10 +1774,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
var sender = Sender;
// Routed waits are value-equality only (predicate null); RequireGoodQuality left at default.
// Routed waits are value-equality (predicate null — a cross-process lambda
// cannot be routed); RequireGoodQuality is honored from the request.
var inner = new WaitForAttributeRequest(
request.CorrelationId, request.InstanceUniqueName, request.AttributeName,
request.TargetValueEncoded, null, request.Timeout, DateTimeOffset.UtcNow);
request.TargetValueEncoded, null, request.Timeout, DateTimeOffset.UtcNow,
request.RequireGoodQuality);
// 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))