feat(scripts): reset-aware Attributes.WriteBatchAndWaitAsync overload + deploy-gate mirror (#36)

Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
This commit is contained in:
Joseph Doherty
2026-09-10 15:27:00 -04:00
parent 03c8a01dd1
commit b623fdb05e
3 changed files with 101 additions and 0 deletions
@@ -332,6 +332,19 @@ public sealed class ScriptCompileSurface
/// <param name="timeout">The maximum time to wait for the response.</param>
/// <returns><c>true</c> if the response value was observed before the timeout elapsed; otherwise <c>false</c>.</returns>
public Task<bool> WriteBatchAndWaitAsync(IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue, string responseKey, object? responseValue, TimeSpan timeout) => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors the reset-aware <c>AttributeAccessor.WriteBatchAndWaitAsync</c> overload (Gitea #36).</summary>
/// <param name="values">The set of attribute values to write as a batch.</param>
/// <param name="flagKey">The name of the flag attribute signaling the write.</param>
/// <param name="flagValue">The value written to the flag attribute.</param>
/// <param name="responseKey">The name of the attribute to observe for a response.</param>
/// <param name="responseValue">The value to wait for on the response attribute.</param>
/// <param name="timeout">The maximum time to wait for the response.</param>
/// <param name="resetKey">The attribute written first and waited on before the batch.</param>
/// <param name="resetValue">The reset value the device must echo before the batch is written.</param>
/// <param name="resetTimeout">The maximum time to wait for the reset echo; <c>null</c> means <paramref name="timeout"/>.</param>
/// <returns>The phase-aware handshake outcome.</returns>
public Task<HandshakeResult> WriteBatchAndWaitAsync(IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue, string responseKey, object? responseValue, TimeSpan timeout, string resetKey, object? resetValue, TimeSpan? resetTimeout = null) => throw new NotSupportedException(CompileOnly);
}
/// <summary>Compile-only mirror of <c>ChildrenAccessor</c>.</summary>
@@ -111,6 +111,47 @@ public class AttributeAccessor
timeout);
}
/// <summary>
/// Reset-aware handshake (Gitea #36): writes <paramref name="resetKey"/>=<paramref name="resetValue"/>,
/// waits (event-driven, bounded by <paramref name="resetTimeout"/>, default = <paramref name="timeout"/>)
/// for the device to ECHO that reset, and only then performs the ordinary
/// <see cref="WriteBatchAndWaitAsync(IReadOnlyDictionary{string, object?}, string, object?, string, object?, TimeSpan)"/>
/// legs (batch → trigger → response wait). Returns a <see cref="HandshakeResult"/> so a
/// script can tell a reset-echo timeout (<see cref="HandshakeResult.ResetFailed"/>; nothing
/// else was written) from a completion timeout. Use this for every level-triggered
/// flag handshake: without an observed reset the plain overload can be satisfied
/// instantly by the previous run's cached response value. Throws
/// <see cref="System.InvalidOperationException"/> if the reset write or the batch/trigger
/// write itself fails; never throws on either timeout.
/// </summary>
/// <param name="values">Attribute key → value to batch-write (data-sourced, one connection).</param>
/// <param name="flagKey">Trigger attribute key written AFTER the batch.</param>
/// <param name="flagValue">Value to write to the trigger.</param>
/// <param name="responseKey">Attribute key to wait on after the trigger.</param>
/// <param name="responseValue">Target value to wait for (<c>null</c> ⇒ any change).</param>
/// <param name="timeout">How long to wait for the response before giving up.</param>
/// <param name="resetKey">Attribute written FIRST (usually <paramref name="responseKey"/>); static or data-sourced.</param>
/// <param name="resetValue">Value the device must echo back before the batch is written.</param>
/// <param name="resetTimeout">How long to wait for the reset echo; <c>null</c> ⇒ <paramref name="timeout"/>.</param>
/// <returns>The phase-aware handshake outcome.</returns>
public Task<HandshakeResult> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue,
string responseKey, object? responseValue, TimeSpan timeout,
string resetKey, object? resetValue, TimeSpan? resetTimeout = null)
{
var encoded = new Dictionary<string, string?>(values.Count);
foreach (var kv in values)
encoded[Resolve(kv.Key)] = AttributeValueCodec.Encode(kv.Value);
return _ctx.WriteBatchResetAndWait(
encoded,
Resolve(flagKey), AttributeValueCodec.Encode(flagValue),
Resolve(responseKey), AttributeValueCodec.Encode(responseValue),
timeout,
Resolve(resetKey), AttributeValueCodec.Encode(resetValue),
resetTimeout ?? timeout);
}
/// <summary>
/// WaitForAttribute (spec §3-§5): waits event-driven until the attribute equals
/// <paramref name="targetValue"/> (value-equality, codec-normalized), bounded by
@@ -294,4 +294,51 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
Assert.True(result.TimedOut);
Assert.Null(result.Value);
}
// ── Reset-aware WriteBatchAndWaitAsync overload (Gitea #36) ──────────────
/// <summary>
/// The reset-aware overload must scope-resolve and codec-encode the reset key/value
/// exactly like every other accessor, must default <c>resetTimeout</c> to
/// <c>timeout</c>, and — when the reset echo never arrives — must return a
/// <see cref="HandshakeResult.ResetFailed"/> result WITHOUT writing the batch or
/// the trigger (the ExpectNoMsg at the end is that assertion).
/// </summary>
[Fact]
public async Task WriteBatchAndWaitAsync_ResetAware_ResolvesScopeAndDefaultsResetTimeout()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
// Composed scope "RightMESReceiver" — every key is prefixed before it leaves the accessor.
var acc = new AttributeAccessor(ctx, "RightMESReceiver");
var call = acc.WriteBatchAndWaitAsync(
new Dictionary<string, object?> { ["MoveOutBatchID"] = 28950 },
"MoveOutFlag", true, "MoveOutCompleteFlag", true, TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", false);
// Leg 1 — the reset write.
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
Assert.Equal("RightMESReceiver.MoveOutCompleteFlag", reset.AttributeName);
Assert.Equal(AttributeValueCodec.Encode(false), reset.Value);
probe.Reply(new SetStaticAttributeResponse(
reset.CorrelationId, "Pump1", reset.AttributeName, true, null, DateTimeOffset.UtcNow));
// Leg 2 — the reset echo wait, bounded by the defaulted resetTimeout.
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
Assert.Equal("RightMESReceiver.MoveOutCompleteFlag", resetWait.AttributeName);
Assert.Equal(AttributeValueCodec.Encode(false), resetWait.TargetValueEncoded);
Assert.Equal(TimeSpan.FromSeconds(25), resetWait.Timeout); // defaulted to `timeout`
probe.Reply(new WaitForAttributeResponse(
resetWait.CorrelationId, Matched: false, Value: null, Quality: null, TimedOut: true));
var result = await call;
Assert.True(result.ResetFailed);
Assert.False(result.Matched);
Assert.True(result.TimedOut);
// Nothing else was written: no batch, no trigger.
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
}