9bb373c4a1
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
1293 lines
70 KiB
Markdown
1293 lines
70 KiB
Markdown
# Reset-Aware Handshake (`WriteBatchAndWaitAsync` overload) Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Close Gitea #36 in the repo — a `WriteBatchAndWaitAsync` overload that writes a reset value, waits for the device to echo it, and only then writes the batch + trigger and waits for the response, returning a phase-aware `HandshakeResult`; plus mirrors, predictor support, tests, docs and a rig live gate.
|
||
|
||
**Architecture:** The overload is a *composition* inside `ScriptRuntimeContext` of the four existing legs (`SetAttribute` → `WaitInternal` → batch Ask → `WaitInternal`); the `InstanceActor` waiter and every wire contract are unchanged. `AttributeAccessor` exposes it to scripts; `ScriptCompileSurface` (deploy gate), `SandboxScriptHost` (Test Run editor) and `FlowShapePredictor` (Flow Debugger prediction) mirror it. Design: `docs/plans/2026-09-10-handshake-reset-echo-design.md`.
|
||
|
||
**Tech Stack:** .NET 10 / C#, Akka.NET (TestKit + `TestProbe` in tests), Roslyn syntax walker (predictor), xunit, bash + ScadaBridge CLI for the rig seed.
|
||
|
||
**Working copy:** memory says never work in the shared primary checkout. Create a dedicated worktree once, before Task 1:
|
||
|
||
```bash
|
||
cd /Users/dohertj2/Desktop/ScadaBridge
|
||
git worktree add -b issue-36-handshake-reset ../ScadaBridge-issue36 main
|
||
```
|
||
|
||
Every `dotnet`/`git` command below runs with `cd /Users/dohertj2/Desktop/ScadaBridge-issue36` first (Bash cwd drifts — always `cd` back). Do **not** use `git stash` (memory: `refs/stash` is shared across worktrees). The rig deploy (Task 10) happens from the primary checkout after a fast-forward merge — `docker/deploy.sh` refuses to run from a worktree path.
|
||
|
||
**Test commands (targeted, per memory "targeted tests, not full suite"):**
|
||
|
||
```bash
|
||
SLN=ZB.MOM.WW.ScadaBridge.slnx
|
||
dotnet build $SLN 2>&1 | tail -3 # full build only at Task 10
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~<Class>" 2>&1 | tail -5
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Commons `HandshakePhase` + `HandshakeResult`
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** none (everything depends on it)
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/HandshakeResult.cs`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/HandshakeResultTests.cs`
|
||
- Modify: `docs/plans/2026-09-10-handshake-reset-echo-design.md` (§4 — add `ResetFailed`, see Step 3 note)
|
||
|
||
**Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||
|
||
/// <summary>
|
||
/// Gitea #36: <see cref="HandshakeResult"/> lets a script tell WHICH leg of a reset-aware
|
||
/// handshake failed without naming the <see cref="HandshakePhase"/> enum (script bodies
|
||
/// import only System/System.Collections.Generic/System.Linq/System.Threading.Tasks).
|
||
/// </summary>
|
||
public class HandshakeResultTests
|
||
{
|
||
[Fact]
|
||
public void ResetFailed_IsTrue_OnlyForAnUnmatchedResetPhase()
|
||
{
|
||
var resetTimeout = new HandshakeResult(false, null, null, true, HandshakePhase.Reset, "reset timed out");
|
||
var responseTimeout = new HandshakeResult(false, null, null, true, HandshakePhase.Response, "response timed out");
|
||
var matched = new HandshakeResult(true, true, "Good", false, HandshakePhase.Response, null);
|
||
|
||
Assert.True(resetTimeout.ResetFailed);
|
||
Assert.False(responseTimeout.ResetFailed);
|
||
Assert.False(matched.ResetFailed);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run test to verify it fails**
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~HandshakeResultTests" 2>&1 | tail -5`
|
||
Expected: build error `The type or namespace name 'HandshakeResult' could not be found`.
|
||
|
||
**Step 3: Write the types**
|
||
|
||
```csharp
|
||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||
|
||
/// <summary>
|
||
/// Which leg of a reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> handshake a
|
||
/// <see cref="HandshakeResult"/> describes (Gitea #36, design 2026-09-10 §4).
|
||
/// </summary>
|
||
public enum HandshakePhase
|
||
{
|
||
/// <summary>The reset write + reset-echo wait that precedes the batch and trigger.</summary>
|
||
Reset,
|
||
|
||
/// <summary>The batch + trigger write and the response wait that follow an observed reset echo.</summary>
|
||
Response,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Result of the reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> overload (Gitea #36):
|
||
/// like <see cref="WaitResult"/>, plus the <see cref="Phase"/> the outcome belongs to so a
|
||
/// script can report "timed out waiting for the reset echo" separately from "timed out
|
||
/// waiting for the device to complete". A reset that is never echoed means the batch and
|
||
/// trigger were NEVER written.
|
||
/// </summary>
|
||
/// <param name="Matched">
|
||
/// <see langword="true"/> when the response attribute reached its target within the timeout
|
||
/// (which implies the reset echo was observed first).
|
||
/// </param>
|
||
/// <param name="Value">The matched value of the phase's attribute; <see langword="null"/> on timeout / error.</param>
|
||
/// <param name="Quality">The attribute quality at match time; <see langword="null"/> on the non-match paths.</param>
|
||
/// <param name="TimedOut"><see langword="true"/> when the phase's wait timed out before a match.</param>
|
||
/// <param name="Phase">The leg this result describes — <see cref="HandshakePhase.Reset"/> only when that leg failed.</param>
|
||
/// <param name="ErrorMessage">Why the phase did not match, when known; <see langword="null"/> on success.</param>
|
||
public readonly record struct HandshakeResult(
|
||
bool Matched,
|
||
object? Value,
|
||
string? Quality,
|
||
bool TimedOut,
|
||
HandshakePhase Phase,
|
||
string? ErrorMessage)
|
||
{
|
||
/// <summary>
|
||
/// <see langword="true"/> when the reset leg did not match (timeout or error), i.e. the
|
||
/// device never echoed the reset value and nothing else was written. Provided so a
|
||
/// script can branch without spelling the <see cref="HandshakePhase"/> type name, which
|
||
/// is not in the script imports.
|
||
/// </summary>
|
||
public bool ResetFailed => !Matched && Phase == HandshakePhase.Reset;
|
||
}
|
||
```
|
||
|
||
Also append to design doc §4, after the `HandshakeResult` code block:
|
||
|
||
```markdown
|
||
`ResetFailed` (`!Matched && Phase == Reset`) is a computed convenience: script bodies import only
|
||
`System`, `System.Collections.Generic`, `System.Linq` and `System.Threading.Tasks`, so a script
|
||
branching on the enum would have to spell `ZB.MOM.WW.ScadaBridge.Commons.Types.HandshakePhase`.
|
||
```
|
||
|
||
**Step 4: Run test to verify it passes**
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~HandshakeResultTests" 2>&1 | tail -5`
|
||
Expected: `Passed! - Failed: 0, Passed: 1`
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/HandshakeResult.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/HandshakeResultTests.cs docs/plans/2026-09-10-handshake-reset-echo-design.md
|
||
git commit -m "feat(commons): HandshakePhase + HandshakeResult for the reset-aware handshake (#36)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `ScriptRuntimeContext.WriteBatchResetAndWait` (probe-level tests)
|
||
|
||
**Classification:** high-risk (script runtime hot path; ordering is the whole fix)
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs:964-1007` (`WriteBatchAndWait`)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs` (create)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
The probe plays the InstanceActor so message ORDER is observable. `ExpectNoMsg` after a failed reset is the assertion that matters most: the batch must never be sent.
|
||
|
||
```csharp
|
||
using Akka.Actor;
|
||
using Akka.TestKit.Xunit2;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||
|
||
/// <summary>
|
||
/// Gitea #36 / design 2026-09-10 §5: the reset-aware handshake runs its legs in a FIXED
|
||
/// order — reset write, reset-echo wait, batch + trigger, response wait — and never writes
|
||
/// the batch unless the reset echo was observed. The probe stands in for the InstanceActor
|
||
/// so the order and the "nothing after a failed reset" property are directly observable.
|
||
/// </summary>
|
||
public class WriteBatchResetAndWaitTests : TestKit
|
||
{
|
||
private const string Instance = "Plant.Reactor1";
|
||
private static readonly IReadOnlyDictionary<string, string?> Values =
|
||
new Dictionary<string, string?> { ["BatchID"] = "28950" };
|
||
|
||
private ScriptRuntimeContext CreateContext(IActorRef instanceActor) =>
|
||
new(instanceActor,
|
||
new SharedScriptLibrary(
|
||
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
|
||
NullLogger<SharedScriptLibrary>.Instance),
|
||
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5),
|
||
instanceName: Instance, logger: NullLogger.Instance);
|
||
|
||
[Fact]
|
||
public async Task Legs_RunInOrder_AndReportTheResponsePhase()
|
||
{
|
||
var probe = CreateTestProbe();
|
||
var ctx = CreateContext(probe.Ref);
|
||
|
||
var call = ctx.WriteBatchResetAndWait(
|
||
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
|
||
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
|
||
|
||
// Leg 1: the reset write, and nothing else yet.
|
||
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||
Assert.Equal("MoveOutCompleteFlag", reset.AttributeName);
|
||
Assert.Equal("False", reset.Value);
|
||
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
|
||
|
||
// Leg 2: the reset-echo wait targets the RESET value and the RESET timeout.
|
||
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||
Assert.Equal("MoveOutCompleteFlag", resetWait.AttributeName);
|
||
Assert.Equal("False", resetWait.TargetValueEncoded);
|
||
Assert.Equal(TimeSpan.FromSeconds(10), resetWait.Timeout);
|
||
Assert.False(resetWait.RequireGoodQuality);
|
||
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false));
|
||
|
||
// Leg 3: only now the batch + trigger.
|
||
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
|
||
Assert.Equal("MoveOutFlag", batch.TriggerAttribute);
|
||
Assert.Equal("True", batch.TriggerEncodedValue);
|
||
Assert.Equal("28950", batch.AttributeEncodedValues["BatchID"]);
|
||
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
|
||
|
||
// Leg 4: the response wait with the RESPONSE timeout.
|
||
var respWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||
Assert.Equal("MoveOutCompleteFlag", respWait.AttributeName);
|
||
Assert.Equal("True", respWait.TargetValueEncoded);
|
||
Assert.Equal(TimeSpan.FromSeconds(25), respWait.Timeout);
|
||
probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: true, true, "Good", TimedOut: false));
|
||
|
||
var result = await call;
|
||
Assert.True(result.Matched);
|
||
Assert.Equal(HandshakePhase.Response, result.Phase);
|
||
Assert.False(result.TimedOut);
|
||
Assert.False(result.ResetFailed);
|
||
Assert.Equal(true, result.Value);
|
||
Assert.Equal("Good", result.Quality);
|
||
Assert.Null(result.ErrorMessage);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ResetEchoTimeout_ReportsResetPhase_AndNeverWritesTheBatch()
|
||
{
|
||
var probe = CreateTestProbe();
|
||
var ctx = CreateContext(probe.Ref);
|
||
|
||
var call = ctx.WriteBatchResetAndWait(
|
||
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
|
||
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
|
||
|
||
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
|
||
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: false, null, null, TimedOut: true));
|
||
|
||
var result = await call;
|
||
Assert.False(result.Matched);
|
||
Assert.True(result.TimedOut);
|
||
Assert.Equal(HandshakePhase.Reset, result.Phase);
|
||
Assert.True(result.ResetFailed);
|
||
Assert.Contains("MoveOutCompleteFlag", result.ErrorMessage);
|
||
|
||
// The property that fixes #36: no batch, no trigger, after a reset that was never echoed.
|
||
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ResponseTimeout_ReportsResponsePhase()
|
||
{
|
||
var probe = CreateTestProbe();
|
||
var ctx = CreateContext(probe.Ref);
|
||
|
||
var call = ctx.WriteBatchResetAndWait(
|
||
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
|
||
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
|
||
|
||
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
|
||
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false));
|
||
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
|
||
var respWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: false, null, null, TimedOut: true));
|
||
|
||
var result = await call;
|
||
Assert.False(result.Matched);
|
||
Assert.True(result.TimedOut);
|
||
Assert.Equal(HandshakePhase.Response, result.Phase);
|
||
Assert.False(result.ResetFailed);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ResetWriteFailure_Throws_LikeTheBatchLeg()
|
||
{
|
||
var probe = CreateTestProbe();
|
||
var ctx = CreateContext(probe.Ref);
|
||
|
||
var call = ctx.WriteBatchResetAndWait(
|
||
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
|
||
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
|
||
|
||
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", false, "device rejected", DateTimeOffset.UtcNow));
|
||
|
||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => call);
|
||
Assert.Contains("device rejected", ex.Message);
|
||
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||
}
|
||
|
||
/// <summary>The bool overload is untouched: batch first, then the wait, no reset leg.</summary>
|
||
[Fact]
|
||
public async Task LegacyOverload_StillWritesTheBatchFirst()
|
||
{
|
||
var probe = CreateTestProbe();
|
||
var ctx = CreateContext(probe.Ref);
|
||
|
||
var call = ctx.WriteBatchAndWait(Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25));
|
||
|
||
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
|
||
var wait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new WaitForAttributeResponse(wait.CorrelationId, Matched: true, true, "Good", TimedOut: false));
|
||
|
||
Assert.True(await call);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~WriteBatchResetAndWaitTests" 2>&1 | tail -5`
|
||
Expected: build error `'ScriptRuntimeContext' does not contain a definition for 'WriteBatchResetAndWait'`.
|
||
|
||
**Step 3: Implement**
|
||
|
||
In `ScriptRuntimeContext.cs`, refactor the existing `WriteBatchAndWait` into a core that returns the full wait response, and add the reset-aware composition. Replace the method starting at line 964 (keep its XML doc, it stays accurate) with:
|
||
|
||
```csharp
|
||
public async Task<bool> WriteBatchAndWait(
|
||
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
|
||
string responseAttr, string? responseEncoded, TimeSpan timeout)
|
||
=> (await WriteBatchAndWaitCore(encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout)
|
||
.ConfigureAwait(false)).Matched;
|
||
|
||
/// <summary>
|
||
/// Reset-aware handshake (Gitea #36, design 2026-09-10 §5) backing the nine-argument
|
||
/// <c>Attributes.WriteBatchAndWaitAsync</c> overload. Legs, in a FIXED order:
|
||
/// (1) write <paramref name="resetAttr"/>=<paramref name="resetEncoded"/> through
|
||
/// <see cref="SetAttribute"/> (throws on a device-write failure, like the batch leg);
|
||
/// (2) wait event-driven for the device to ECHO the reset, bounded by
|
||
/// <paramref name="resetTimeout"/> — not matched ⇒ return a <see cref="HandshakePhase.Reset"/>
|
||
/// result and write NOTHING else; (3) the existing batch + trigger write; (4) the existing
|
||
/// response wait bounded by <paramref name="timeout"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Why the reset must be OBSERVED before the trigger is written: a subscription reports a
|
||
/// change from the last reported value, so a device that flips reset→done inside one
|
||
/// sampling interval never reports the reset — the cached previous-run "done" value would
|
||
/// then satisfy the response wait instantly (the #36 race), and an edge-triggered wait
|
||
/// would never resolve at all. Holding the trigger until the echo lands makes the existing
|
||
/// level-triggered fast-path correct again. Each leg is already a traced Flow Debugger
|
||
/// step, so a traced run shows all four.
|
||
/// </remarks>
|
||
/// <param name="encodedValues">Scope-resolved attribute name → codec-encoded value to batch-write.</param>
|
||
/// <param name="triggerAttr">Scope-resolved trigger attribute written AFTER the batch.</param>
|
||
/// <param name="triggerEncoded">Codec-encoded trigger value.</param>
|
||
/// <param name="responseAttr">Scope-resolved attribute to wait on after the trigger.</param>
|
||
/// <param name="responseEncoded">Codec-encoded response target (null ⇒ any change).</param>
|
||
/// <param name="timeout">Bound for the response wait.</param>
|
||
/// <param name="resetAttr">Scope-resolved attribute written FIRST (usually the response attribute).</param>
|
||
/// <param name="resetEncoded">Codec-encoded reset value the device must echo before anything else is written.</param>
|
||
/// <param name="resetTimeout">Bound for the reset-echo wait.</param>
|
||
/// <returns>A phase-aware result; never throws on either timeout.</returns>
|
||
public async Task<HandshakeResult> WriteBatchResetAndWait(
|
||
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
|
||
string responseAttr, string? responseEncoded, TimeSpan timeout,
|
||
string resetAttr, string? resetEncoded, TimeSpan resetTimeout)
|
||
{
|
||
// Leg 1 — reset write. A static attribute updates the cache synchronously (leg 2 then
|
||
// fast-paths); a data-sourced one goes to the device and the cache waits for the echo.
|
||
await SetAttribute(resetAttr, resetEncoded ?? string.Empty).ConfigureAwait(false);
|
||
|
||
// Leg 2 — reset echo. Quality-agnostic, like the response wait.
|
||
var reset = await WaitInternal(resetAttr, resetEncoded, null, resetTimeout, requireGoodQuality: false)
|
||
.ConfigureAwait(false);
|
||
if (!reset.Matched)
|
||
{
|
||
return new HandshakeResult(
|
||
Matched: false, reset.Value, reset.Quality, reset.TimedOut, HandshakePhase.Reset,
|
||
reset.ErrorMessage
|
||
?? (reset.TimedOut
|
||
? $"Timed out after {resetTimeout.TotalSeconds}s waiting for '{resetAttr}' to echo the reset value"
|
||
: $"Reset wait for '{resetAttr}' did not match"));
|
||
}
|
||
|
||
// Legs 3 + 4 — unchanged batch/trigger write and response wait.
|
||
var response = await WriteBatchAndWaitCore(
|
||
encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout).ConfigureAwait(false);
|
||
return new HandshakeResult(
|
||
response.Matched, response.Value, response.Quality, response.TimedOut, HandshakePhase.Response,
|
||
response.Matched
|
||
? null
|
||
: response.ErrorMessage
|
||
?? (response.TimedOut
|
||
? $"Timed out after {timeout.TotalSeconds}s waiting for '{responseAttr}'"
|
||
: $"Response wait for '{responseAttr}' did not match"));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Shared body of <see cref="WriteBatchAndWait"/> / <see cref="WriteBatchResetAndWait"/>:
|
||
/// the batch + trigger Ask (throws on failure) followed by the response wait, returning
|
||
/// the full <see cref="WaitForAttributeResponse"/> so the reset-aware caller can surface
|
||
/// value/quality/timeout.
|
||
/// </summary>
|
||
private async Task<WaitForAttributeResponse> WriteBatchAndWaitCore(
|
||
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
|
||
string responseAttr, string? responseEncoded, TimeSpan timeout)
|
||
{
|
||
// <existing body of the old WriteBatchAndWait, verbatim, from `var cid = …` through the
|
||
// `if (!batchResp.Success) throw …;` — then replace the last line with:>
|
||
return await WaitInternal(responseAttr, responseEncoded, null, timeout, requireGoodQuality: false)
|
||
.ConfigureAwait(false);
|
||
}
|
||
```
|
||
|
||
Move the old method's XML `<summary>` onto the public bool method (unchanged text) and keep the existing body inside `WriteBatchAndWaitCore` byte-for-byte except for the final line. `HandshakeResult`/`HandshakePhase` live in `ZB.MOM.WW.ScadaBridge.Commons.Types`, already imported by this file (it uses `WaitResult`).
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~WriteBatchResetAndWaitTests|FullyQualifiedName~ScriptRuntimeContextTraceEmissionTests" 2>&1 | tail -5`
|
||
Expected: all pass (the trace-emission suite proves the refactored legacy path is unchanged).
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs
|
||
git commit -m "feat(site-runtime): WriteBatchResetAndWait — reset write + echo wait gate the batch/trigger (#36)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: End-to-end regression test over the real `InstanceActor` (issue item 3)
|
||
|
||
**Classification:** high-risk (pins the actual #36 race against the real waiter + DCL ingest path)
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 4, Task 5, Task 6 (test-only file; needs Task 2)
|
||
|
||
**Files:**
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs` (create)
|
||
|
||
**Step 1: Write the tests**
|
||
|
||
Model on `InstanceActorWaitForAttributeTests` (fixture) + `InstanceActorSetAttributeTests` (DCL probe answering `WriteTagRequest`). Two data-sourced Booleans on one connection, cache driven to the codec-`True` a previous run leaves behind, then the handshake through a real `ScriptRuntimeContext`.
|
||
|
||
```csharp
|
||
using Akka.Actor;
|
||
using Akka.TestKit;
|
||
using Akka.TestKit.Xunit2;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using System.Text.Json;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||
|
||
/// <summary>
|
||
/// Gitea #36 end-to-end over the REAL <see cref="InstanceActor"/> waiter and DCL ingest path:
|
||
/// the cache still holds the previous run's <c>CompleteFlag = True</c>, the script's reset
|
||
/// write has NOT echoed back yet. The legacy overload fast-paths on the stale value (the
|
||
/// defect, pinned as documentation); the reset-aware overload holds the batch + trigger until
|
||
/// the reset echo lands, then matches only on the device's fresh <c>True</c>.
|
||
/// </summary>
|
||
public class HandshakeResetEchoTests : TestKit, IDisposable
|
||
{
|
||
private const string Conn = "PLC";
|
||
private const string TriggerTag = "ns=3;s=MoveOutFlag";
|
||
private const string CompleteTag = "ns=3;s=MoveOutCompleteFlag";
|
||
private const string BatchTag = "ns=3;s=MoveOutBatchID";
|
||
|
||
private readonly SiteStorageService _storage;
|
||
private readonly ScriptCompilationService _compilationService;
|
||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||
private readonly TestLocalDb _localDb;
|
||
|
||
public HandshakeResetEchoTests()
|
||
{
|
||
_localDb = TestLocalDb.CreateTemp("handshake-reset-echo");
|
||
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
|
||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
|
||
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||
}
|
||
|
||
void IDisposable.Dispose()
|
||
{
|
||
Shutdown();
|
||
var path = _localDb.Path;
|
||
_localDb.Dispose();
|
||
TestLocalDb.DeleteFiles(path);
|
||
}
|
||
|
||
private static FlattenedConfiguration Config() => new()
|
||
{
|
||
InstanceUniqueName = "Z28061",
|
||
Attributes =
|
||
[
|
||
new ResolvedAttribute { CanonicalName = "MoveOutFlag", Value = "false", DataType = "Boolean", DataSourceReference = TriggerTag, BoundDataConnectionName = Conn },
|
||
new ResolvedAttribute { CanonicalName = "MoveOutCompleteFlag", Value = "false", DataType = "Boolean", DataSourceReference = CompleteTag, BoundDataConnectionName = Conn },
|
||
new ResolvedAttribute { CanonicalName = "MoveOutBatchID", Value = "0", DataType = "Int32", DataSourceReference = BatchTag, BoundDataConnectionName = Conn },
|
||
]
|
||
};
|
||
|
||
private (IActorRef Actor, TestProbe Dcl, ScriptRuntimeContext Ctx) Boot()
|
||
{
|
||
var dcl = CreateTestProbe();
|
||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||
"Z28061", JsonSerializer.Serialize(Config()), _storage, _compilationService, _sharedScriptLibrary,
|
||
null, new SiteRuntimeOptions(), NullLogger<InstanceActor>.Instance, dcl.Ref)));
|
||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||
|
||
// The previous run's completion is still in the cache: codec "True", exactly what the
|
||
// response wait targets.
|
||
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
|
||
|
||
var ctx = new ScriptRuntimeContext(
|
||
actor, _sharedScriptLibrary, currentCallDepth: 0, maxCallDepth: 10,
|
||
askTimeout: TimeSpan.FromSeconds(5), instanceName: "Z28061", logger: NullLogger.Instance);
|
||
return (actor, dcl, ctx);
|
||
}
|
||
|
||
private static IReadOnlyDictionary<string, string?> Values(int batchId)
|
||
=> new Dictionary<string, string?> { ["MoveOutBatchID"] = AttributeValueCodec.Encode(batchId) };
|
||
|
||
/// <summary>The defect, as documentation: the legacy overload answers from the stale cache.</summary>
|
||
[Fact]
|
||
public async Task LegacyOverload_FastPathsOnThePreviousRunsCachedTrue()
|
||
{
|
||
var (_, dcl, ctx) = Boot();
|
||
|
||
var call = ctx.WriteBatchAndWait(
|
||
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
|
||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5));
|
||
|
||
var batch = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
|
||
dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||
|
||
// No device update at all — and the wait still "matches".
|
||
Assert.True(await call);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ResetAwareOverload_HoldsTheBatchUntilTheResetEchoes_ThenMatchesTheFreshTrue()
|
||
{
|
||
var (actor, dcl, ctx) = Boot();
|
||
|
||
var call = ctx.WriteBatchResetAndWait(
|
||
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
|
||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
|
||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false), TimeSpan.FromSeconds(10));
|
||
|
||
// Leg 1 reaches the device as a plain tag write; the DCL acks it but the device has
|
||
// not echoed anything yet.
|
||
var reset = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
|
||
Assert.Equal(CompleteTag, reset.TagPath);
|
||
dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||
|
||
// The cache still says True → the reset wait must NOT fast-path, and no batch may go out.
|
||
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||
Assert.False(call.IsCompleted);
|
||
|
||
// The device echoes the reset.
|
||
actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow));
|
||
|
||
// Only now the batch + trigger.
|
||
var batch = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
|
||
Assert.Equal(TriggerTag, batch.TriggerTagPath);
|
||
Assert.Equal(true, batch.TriggerValue is bool b ? b : bool.Parse(batch.TriggerValue!.ToString()!));
|
||
Assert.True(batch.Values.ContainsKey(BatchTag));
|
||
dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||
|
||
// Still pending: the cache now holds False.
|
||
Assert.False(call.IsCompleted);
|
||
|
||
// The device completes THIS request.
|
||
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
|
||
|
||
var result = await call;
|
||
Assert.True(result.Matched);
|
||
Assert.Equal(HandshakePhase.Response, result.Phase);
|
||
Assert.Equal(true, result.Value);
|
||
Assert.Equal("Good", result.Quality);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ResetAwareOverload_NoEcho_TimesOutInResetPhase_AndNeverWritesTheBatch()
|
||
{
|
||
var (_, dcl, ctx) = Boot();
|
||
|
||
var call = ctx.WriteBatchResetAndWait(
|
||
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
|
||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
|
||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false), TimeSpan.FromMilliseconds(700));
|
||
|
||
var reset = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
|
||
dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||
|
||
var result = await call;
|
||
Assert.False(result.Matched);
|
||
Assert.True(result.TimedOut);
|
||
Assert.True(result.ResetFailed);
|
||
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||
}
|
||
}
|
||
```
|
||
|
||
If `batch.TriggerValue` arrives as the encoded string rather than a bool (the batch handler passes `request.TriggerEncodedValue` through for non-list attributes), simplify that assertion to `Assert.Equal(AttributeValueCodec.Encode(true), batch.TriggerValue?.ToString())` — check the first run's failure message and adjust; do not weaken the ordering assertions.
|
||
|
||
**Step 2: Run tests**
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~HandshakeResetEchoTests" 2>&1 | tail -8`
|
||
Expected: 3 passed. (These are green-on-arrival because Task 2 is in; their value is the pin. If `LegacyOverload_FastPaths…` FAILS, the cache seeding is wrong — the codec form of the seeded value must equal `AttributeValueCodec.Encode(true)`.)
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs
|
||
git commit -m "test(site-runtime): pin #36 — reset-aware handshake holds the batch until the reset echo lands"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `AttributeAccessor` overload + `ScriptCompileSurface` mirror
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 3, Task 5, Task 6
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs:99-112` (after `WriteBatchAndWaitAsync`)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs:326-334` (after the batch mirror)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs` (existing; no edit)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs` (add one test)
|
||
|
||
**Step 1: Add the accessor overload first, then run parity — it must FAIL**
|
||
|
||
In `ScopeAccessors.cs`, directly after the existing `WriteBatchAndWaitAsync`:
|
||
|
||
```csharp
|
||
/// <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);
|
||
}
|
||
```
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~CompileSurfaceParityTests" 2>&1 | tail -8`
|
||
Expected: FAIL — the arity test reports `WriteBatchAndWaitAsync` with 9 parameters missing from `CompileAttributeAccessor`. (If it passes, the parity test is weaker than documented — stop and report.)
|
||
|
||
**Step 2: Add the compile-surface mirror**
|
||
|
||
In `ScriptCompileSurface.cs`, after the existing `WriteBatchAndWaitAsync` mirror inside `CompileAttributeAccessor`:
|
||
|
||
```csharp
|
||
/// <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);
|
||
```
|
||
|
||
**Step 3: Add a scope-resolution test to `ScopeAccessorTests`**
|
||
|
||
Follow the file's existing probe pattern (see its other `WriteBatchAndWaitAsync`/`WaitAsync` tests around lines 170–300 for the accessor construction). The assertion that matters: keys are scope-resolved and `resetTimeout` defaults to `timeout`.
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void WriteBatchAndWaitAsync_ResetAware_ResolvesScopeAndDefaultsResetTimeout()
|
||
{
|
||
var probe = CreateTestProbe();
|
||
// <construct the composition-scoped AttributeAccessor for prefix "RightMESReceiver"
|
||
// exactly as the neighbouring WriteBatchAndWaitAsync test does>
|
||
var call = accessor.WriteBatchAndWaitAsync(
|
||
new Dictionary<string, object?> { ["MoveOutBatchID"] = 28950 },
|
||
"MoveOutFlag", true, "MoveOutCompleteFlag", true, TimeSpan.FromSeconds(25),
|
||
"MoveOutCompleteFlag", false);
|
||
|
||
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, "X", reset.AttributeName, true, null, DateTimeOffset.UtcNow));
|
||
|
||
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||
Assert.Equal("RightMESReceiver.MoveOutCompleteFlag", resetWait.AttributeName);
|
||
Assert.Equal(TimeSpan.FromSeconds(25), resetWait.Timeout); // defaulted to `timeout`
|
||
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: false, null, null, TimedOut: true));
|
||
|
||
var result = call.GetAwaiter().GetResult();
|
||
Assert.True(result.ResetFailed);
|
||
}
|
||
```
|
||
|
||
**Step 4: Run tests**
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~CompileSurfaceParityTests|FullyQualifiedName~ScopeAccessorTests" 2>&1 | tail -5`
|
||
Expected: all pass.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs
|
||
git commit -m "feat(scripts): reset-aware Attributes.WriteBatchAndWaitAsync overload + deploy-gate mirror (#36)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `SandboxScriptHost` mirror + Central UI compile-clean test
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 3, Task 4, Task 6
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs:462-465` (after the batch stand-in)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs:752-770` (`InstanceScript_BatchAndWaitHelpers_DiagnoseClean`)
|
||
|
||
**Step 1: Extend the failing test**
|
||
|
||
In `InstanceScript_BatchAndWaitHelpers_DiagnoseClean`, add the nine-argument form to `code` and use its result:
|
||
|
||
```csharp
|
||
"var hs = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5), \"Done\", false, System.TimeSpan.FromSeconds(2));\n" +
|
||
"var hs2 = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5), \"Done\", false);\n" +
|
||
"return done && hit && pred && res.Matched && hs.Matched && !hs2.ResetFailed;";
|
||
```
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~InstanceScript_BatchAndWaitHelpers_DiagnoseClean" 2>&1 | tail -5`
|
||
Expected: FAIL with a `CS1501` (no overload takes 8/9 arguments) marker.
|
||
|
||
**Step 2: Add the sandbox stand-in**
|
||
|
||
After the existing `WriteBatchAndWaitAsync` in `SandboxScriptHost.cs`:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Sandbox stand-in for the reset-aware <c>AttributeAccessor.WriteBatchAndWaitAsync</c>
|
||
/// overload (Gitea #36): present for editor/compile parity, throws
|
||
/// <see cref="ScriptSandboxException"/> when run in Test Run (no device batch-write transport).
|
||
/// </summary>
|
||
/// <param name="values">The attribute values to write as a batch.</param>
|
||
/// <param name="flagKey">The flag attribute key to set to signal completion.</param>
|
||
/// <param name="flagValue">The value to set on the flag attribute.</param>
|
||
/// <param name="responseKey">The attribute key to observe for the device's response.</param>
|
||
/// <param name="responseValue">The response value to wait for.</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>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</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 NotInSandbox(nameof(WriteBatchAndWaitAsync));
|
||
```
|
||
|
||
`HandshakeResult` is in `ZB.MOM.WW.ScadaBridge.Commons.Types`; add the `using` if the file lacks it (it references `WaitResult`, so it is likely present).
|
||
|
||
**Step 3: Run test**
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~ScriptAnalysisServiceTests" 2>&1 | tail -5`
|
||
Expected: all pass.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
|
||
git commit -m "feat(central-ui): Test Run sandbox mirrors the reset-aware WriteBatchAndWaitAsync (#36)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `FlowShapePredictor` — nine-argument form predicts the reset legs
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 3, Task 4, Task 5
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs:370-378` (`case "WriteBatchAndWaitAsync"`)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs`
|
||
|
||
**Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
/// <summary>Gitea #36: the reset-aware overload predicts the reset write + echo wait ahead of the batch.</summary>
|
||
[Fact]
|
||
public void ResetAwareWriteBatchAndWait_PredictsResetWriteAndEchoWaitBeforeTheBatch()
|
||
{
|
||
const string body = """
|
||
var data = new System.Collections.Generic.Dictionary<string, object?> { { "MoveOutBatchID", 1 } };
|
||
var hs = await Children["RightMESReceiver"].Attributes.WriteBatchAndWaitAsync(
|
||
data, "MoveOutFlag", true, "MoveOutCompleteFlag", true, System.TimeSpan.FromSeconds(25),
|
||
"MoveOutCompleteFlag", false, System.TimeSpan.FromSeconds(10));
|
||
if (hs.ResetFailed) return new { WasSuccessful = false };
|
||
""";
|
||
|
||
var flow = FlowShapePredictor.PredictSiteScript("MesMoveOut", body);
|
||
|
||
Assert.Equal(
|
||
[
|
||
(FlowStepKind.ScriptRun, FlowLane.Site, "MesMoveOut"),
|
||
(FlowStepKind.AttributeWrite, FlowLane.Tags, "MoveOutCompleteFlag"),
|
||
(FlowStepKind.AttributeWait, FlowLane.Site, "MoveOutCompleteFlag"),
|
||
(FlowStepKind.AttributeWrite, FlowLane.Tags, "batch + MoveOutFlag"),
|
||
(FlowStepKind.AttributeWait, FlowLane.Site, "MoveOutCompleteFlag"),
|
||
], flow.Nodes.Select(n => (n.Kind, n.Lane, n.Title)).ToList());
|
||
Assert.Equal("reset", flow.Nodes[1].Note);
|
||
Assert.Equal("reset echo", flow.Nodes[2].Note);
|
||
}
|
||
|
||
/// <summary>Named reset arguments resolve by name, not position.</summary>
|
||
[Fact]
|
||
public void ResetAwareWriteBatchAndWait_NamedArguments_ResolveTheResetKey()
|
||
{
|
||
const string body = """
|
||
var hs = await Attributes.WriteBatchAndWaitAsync(data, "Trig", true, "Done", true, t,
|
||
resetKey: "Done", resetValue: false);
|
||
""";
|
||
|
||
var flow = FlowShapePredictor.PredictSiteScript("S", body);
|
||
|
||
Assert.Equal("Done", flow.Nodes[1].Title);
|
||
Assert.Equal(FlowStepKind.AttributeWrite, flow.Nodes[1].Kind);
|
||
Assert.Equal(5, flow.Nodes.Count);
|
||
}
|
||
```
|
||
|
||
Check the `PredictedFlow`/node type: if `Note` is not a public property on the node record, assert on the count + titles only and drop the two `Note` asserts (look at `FlowStepKind`/`PredictedNode` in `Commons/Types/Debugging`).
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests --filter "FullyQualifiedName~ResetAwareWriteBatchAndWait" 2>&1 | tail -5`
|
||
Expected: FAIL — 3 nodes instead of 5.
|
||
|
||
**Step 2: Implement**
|
||
|
||
Replace the `case "WriteBatchAndWaitAsync":` block:
|
||
|
||
```csharp
|
||
case "WriteBatchAndWaitAsync":
|
||
{
|
||
// (batch, triggerAttr, triggerValue, waitAttr, waitValue, timeout
|
||
// [, resetAttr, resetValue, resetTimeout]) — Gitea #36: the reset-aware
|
||
// overload writes + waits on the reset attribute BEFORE the batch.
|
||
var (trigger, triggerDependent) = LiteralOrExpr(args.ElementAtOrDefault(1)?.Expression);
|
||
var (wait, waitDependent) = LiteralOrExpr(args.ElementAtOrDefault(3)?.Expression);
|
||
var resetArg = ArgAt(args, 6, "resetKey");
|
||
if (resetArg is not null)
|
||
{
|
||
var (reset, resetDependent) = LiteralOrExpr(resetArg.Expression);
|
||
_b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify(reset), resetDependent, "reset");
|
||
_b.AddNode(FlowStepKind.AttributeWait, FlowLane.Site, Qualify(reset), resetDependent, "reset echo");
|
||
}
|
||
_b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify($"batch + {trigger}"), triggerDependent, null);
|
||
_b.AddNode(FlowStepKind.AttributeWait, FlowLane.Site, Qualify(wait), waitDependent, null);
|
||
return true;
|
||
}
|
||
```
|
||
|
||
and add beside `LiteralOrExpr`:
|
||
|
||
```csharp
|
||
/// <summary>An argument by name (<c>name: expr</c>) or, failing that, by position among the unnamed arguments.</summary>
|
||
private static ArgumentSyntax? ArgAt(SeparatedSyntaxList<ArgumentSyntax> args, int index, string name)
|
||
{
|
||
var named = args.FirstOrDefault(a => a.NameColon?.Name.Identifier.ValueText == name);
|
||
if (named is not null)
|
||
return named;
|
||
var positional = args.ElementAtOrDefault(index);
|
||
return positional is { NameColon: null } ? positional : null;
|
||
}
|
||
```
|
||
|
||
(`args` is `node.ArgumentList.Arguments`, a `SeparatedSyntaxList<ArgumentSyntax>` — see line 317.) Update the `<summary>` list in the class doc (line ~46) to mention the reset legs.
|
||
|
||
**Step 3: Run tests**
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests --filter "FullyQualifiedName~FlowShapePredictorTests" 2>&1 | tail -5`
|
||
Expected: all pass, including the unchanged Delmia six-argument expectation.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs
|
||
git commit -m "feat(flow-debugger): predict the reset write + echo wait of the reset-aware handshake (#36)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Trace emission — a traced reset-aware run emits four ordered step rows
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 8, Task 9 (needs Task 2)
|
||
|
||
**Files:**
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs` (append after `NestedStepScopes_IgnoreTraceDiagnostics_AndUnwind`)
|
||
|
||
**Step 1: Write the test**
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Gitea #36: the reset-aware handshake is four traced steps in leg order — reset write,
|
||
/// reset-echo wait, batch write, response wait — each with its own row id, with no new
|
||
/// audit kinds (the Flow Debugger projection needs no change).
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task WriteBatchResetAndWait_WhenTraced_EmitsFourStepRowsInLegOrder()
|
||
{
|
||
var probe = CreateTestProbe();
|
||
var collector = new FlowTraceCollector();
|
||
var ctx = CreateContext(probe.Ref, new CapturingAuditWriter(), traced: true, collector);
|
||
|
||
var call = ctx.WriteBatchResetAndWait(
|
||
new Dictionary<string, string?> { ["Recipe"] = "R-17" },
|
||
"Trigger", "1", "Ack", "1", TimeSpan.FromSeconds(3),
|
||
"Ack", "0", TimeSpan.FromSeconds(2));
|
||
|
||
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||
Assert.True(reset.Trace);
|
||
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, InstanceName, "Ack", true, null, DateTimeOffset.UtcNow));
|
||
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, "0", "Good", TimedOut: false));
|
||
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
|
||
var respWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||
probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: true, "1", "Good", TimedOut: false));
|
||
Assert.True((await call).Matched);
|
||
|
||
var rows = collector.Snapshot().Select(Row).ToList();
|
||
Assert.Equal(
|
||
[
|
||
(AuditKind.AttributeWrite, "Ack"),
|
||
(AuditKind.AttributeWait, "Ack"),
|
||
(AuditKind.AttributeWrite, "Trigger"),
|
||
(AuditKind.AttributeWait, "Ack"),
|
||
], rows.Select(r => (r.Kind, r.Target)).ToList());
|
||
Assert.Equal(4, collector.Snapshot().Select(e => e.EventId).Distinct().Count());
|
||
}
|
||
```
|
||
|
||
`Target` is whatever `AuditRowValues` calls the attribute-name column that the existing tests assert on (`extra.WaitAttribute` / the write row's target) — match the neighbouring tests' property name.
|
||
|
||
**Step 2: Run**
|
||
|
||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~WriteBatchResetAndWait_WhenTraced" 2>&1 | tail -5`
|
||
Expected: PASS (green-on-arrival pin; if the order differs, Task 2's leg order is wrong — fix Task 2, not the test).
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs
|
||
git commit -m "test(site-runtime): traced reset-aware handshake emits four ordered step rows (#36)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Docs, register row, rig comment, CLAUDE.md
|
||
|
||
**Classification:** small (doc-only, several files)
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 7, Task 9
|
||
|
||
**Files:**
|
||
- Modify: `docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md` (§5, before line 228 `## 6`)
|
||
- Modify: `docs/requirements/Component-ScriptAnalysis.md:182,231`
|
||
- Modify: `docs/api-specs/Delmia-Recipe-Download-API.md` (§4, after line 140's code block)
|
||
- Modify: `docs/plans/2026-06-16-ipsen-mes-movein-design.md:67` (after item 4)
|
||
- Modify: `docker/seed-delmia-sim.sh:10-12` (header comment)
|
||
- Modify: `docs/plans/2026-07-08-deferred-work-register.md` (append row 119)
|
||
- Modify: `CLAUDE.md:74` (after the "Explicit supervision strategies" bullet)
|
||
|
||
**Step 1: Spec §5 addendum** — append as the last bullet of §5:
|
||
|
||
```markdown
|
||
- **Handshake reset ordering (Gitea #36, design `2026-09-10-handshake-reset-echo-design.md`).**
|
||
The fast-path above is correct ONLY once the caller's reset has been observed. A script that
|
||
writes `CompleteFlag=false` and immediately calls the batch helper races the device echo: the
|
||
cache still holds the previous run's `true`, the wait fast-paths in 0 ms, and the script reads
|
||
stale results. An edge-triggered wait is NOT the fix (a subscription can swallow the reset when
|
||
the device flips inside one sampling interval); the reset must be echoed BEFORE the trigger is
|
||
written. Use the reset-aware `Attributes.WriteBatchAndWaitAsync(…, resetKey, resetValue,
|
||
resetTimeout)` overload, which returns a `HandshakeResult` (`ResetFailed` ⇒ nothing else was
|
||
written).
|
||
```
|
||
|
||
**Step 2: Component-ScriptAnalysis.md** — in line 182's parenthetical list add "including the reset-aware nine-argument overload" after `Attributes.WriteBatchAndWaitAsync`; in line 231 after `…Attributes.WriteBatchAndWaitAsync` add "(the reset-aware overload additionally yields a reset `AttributeWrite` + `AttributeWait` pair ahead of the batch)".
|
||
|
||
**Step 3: Delmia API spec** — after the §4 script code block (line ~145) add:
|
||
|
||
```markdown
|
||
> **Known race (Gitea #36, 2026-09-10).** Step 2's `RecipeProcessedFlag = false` is a device
|
||
> write whose echo has not arrived when step 3's wait registers, so the wait can be satisfied by
|
||
> the PREVIOUS download's cached `true`. The runtime fix is the reset-aware overload —
|
||
> `WriteBatchAndWaitAsync(data, "RecipeDownloadFlag", true, "RecipeProcessedFlag", true, 25 s,
|
||
> "RecipeProcessedFlag", false, 10 s)` returning a `HandshakeResult` — which replaces steps 2–3.
|
||
> The deployed prod/QA script bodies are NOT yet ported (deferred-work register row 119).
|
||
```
|
||
|
||
**Step 4: Ipsen design** — after item 4 (line 67 block) add item `4a.`:
|
||
|
||
```markdown
|
||
4a. **Handshake ordering (Gitea #36, 2026-09-10):** the reset of `<X>CompleteFlag` must be
|
||
ECHOED by the Galaxy object before `<X>Flag` is raised, otherwise the completion wait
|
||
fast-paths on the previous request's cached `true`. Port the receiver scripts onto the
|
||
reset-aware `WriteBatchAndWaitAsync` overload (`docs/plans/2026-09-10-handshake-reset-echo-design.md`
|
||
§4) and drop the `Task.Delay(1000)`.
|
||
```
|
||
|
||
**Step 5: seed-delmia-sim.sh** — extend the header comment lines 10–12:
|
||
|
||
```bash
|
||
# * ProcessRecipeDownload (Call) — prod's handshake ported to per-attribute
|
||
# SetAsync/WaitAsync (WriteBatchAndWaitAsync rejects static attributes). The #36
|
||
# reset-echo race cannot occur here: a STATIC write updates the cache synchronously,
|
||
# so the reset is "echoed" before the trigger is written. The data-sourced proof of
|
||
# the reset-aware overload is docker/seed-handshake-demo.sh.
|
||
```
|
||
|
||
**Step 6: Register row 119** — append to the table:
|
||
|
||
```markdown
|
||
| 119 | **Port the QA/prod MES receiver scripts onto the reset-aware `WriteBatchAndWaitAsync` overload (Gitea #36)** *(Medium)*. Template scripts CvdReactor MesMoveIn (13) / MesMoveOut (14) / MesSampleData (36) and base MESReceiver MoveIn/MoveOut (15, 16, 25, 26, 37, 38) still reset `<X>CompleteFlag` with a plain write and rely on `Task.Delay(1000)`; QA carries a config-only `WaitAsync(false, 10 s)` patch on 13/14 (deployment record 110). Rewrite: `var hs = await rx.Attributes.WriteBatchAndWaitAsync(values, "<X>Flag", true, "<X>CompleteFlag", true, 25 s, "<X>CompleteFlag", false, 10 s); if (!hs.Matched) return new { WasSuccessful=false, ErrorText = hs.ResetFailed ? "Timeout waiting for <X>CompleteFlag to reset" : "Timeout waiting for <X> to complete", BatchID=0 };` and delete the delay. Requires the #36 build on the target central + sites first (the overload does not exist on older sites → deploy-gate compile failure, by design). | Gitea #36, design `2026-09-10-handshake-reset-echo-design.md` §8 | Repo-only scope was the user's decision for the #36 session; the script bodies live only in the QA/prod databases. | Next QA deployment of a build ≥ this change (write a deployment record; then prod). |
|
||
```
|
||
|
||
**Step 7: CLAUDE.md** — after line 74 insert:
|
||
|
||
```markdown
|
||
- **Level-triggered flag handshakes must observe the reset echo before raising the trigger (Gitea #36, 2026-09-10).** `Attributes.WriteBatchAndWaitAsync` has a reset-aware overload — `(values, flagKey, flagValue, responseKey, responseValue, timeout, resetKey, resetValue, resetTimeout?)` → `HandshakeResult` (`Matched`/`TimedOut`/`Phase`/`ResetFailed`) — that writes the reset, waits event-driven for the device to echo it, and only then writes the batch + trigger and waits for the response. The six-argument bool overload is unchanged but **unsafe for a repeated handshake**: the waiter's current-value fast-path answers from the previous run's cached response value while the script's reset write is still in flight (QA measured 0 ms matches and stale `BatchID`s). An edge-triggered wait was rejected — a subscription reports changes from the last *reported* value, so a device flipping reset→done inside one sampling interval never reports the reset and such a wait never resolves. Composition only: `ScriptRuntimeContext.WriteBatchResetAndWait` chains the existing `SetAttribute` → waiter → batch → waiter legs, so no actor/message/proto change, and a traced run shows all four steps. Mirrored on `ScriptCompileSurface`, `SandboxScriptHost` and `FlowShapePredictor`. The QA/prod MES script port is register row 119. Design: `docs/plans/2026-09-10-handshake-reset-echo-design.md`.
|
||
```
|
||
|
||
**Step 8: Commit**
|
||
|
||
```bash
|
||
git add docs CLAUDE.md docker/seed-delmia-sim.sh
|
||
git commit -m "docs: reset-aware handshake — spec note, component doc, Delmia/Ipsen notes, register row 119, CLAUDE.md (#36)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Rig live gate — `docker/seed-handshake-demo.sh`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min (authoring; the run itself is Task 10)
|
||
**Parallelizable with:** Task 7, Task 8
|
||
|
||
**Files:**
|
||
- Create: `docker/seed-handshake-demo.sh`
|
||
|
||
**Step 1: Write the seed script**
|
||
|
||
Modelled on `docker/seed-live-probe.sh` (CLI path/args conventions from `docker/seed-delmia-sim.sh`). Two writable, non-simulated Boolean OPC UA nodes (`ns=3;s=Motor.Running`, `ns=3;s=Pump.Running` — both `CurrentReadOrWrite`; see `docs/test_infra/test_infra_opcua.md`) plus one Double payload node, on site-a connection id 1.
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
set -euo pipefail
|
||
|
||
# Seed a DATA-SOURCED flag handshake on site-a to prove the reset-aware
|
||
# Attributes.WriteBatchAndWaitAsync overload (Gitea #36) against a real OPC UA
|
||
# subscription — the reset echo must round-trip through the device before the
|
||
# trigger is written, and a SECOND run must not fast-path on the first run's
|
||
# cached Complete=true.
|
||
#
|
||
# What it creates (site-a / site id 1, connection id 1 "OPC PLC Simulator"):
|
||
# - Template HandshakeDemo
|
||
# * Trigger (Boolean) <- ns=3;s=Motor.Running writable, NOT simulated (holds what is written)
|
||
# * Complete (Boolean) <- ns=3;s=Pump.Running writable, NOT simulated
|
||
# * Payload (Double) <- ns=3;s=Motor.Speed writable, NOT simulated
|
||
# * Handshake (Call) — the reset-aware overload; returns the HandshakeResult fields
|
||
# * Device (ValueChange on Trigger) — plays the PLC: on Trigger==true sets Complete=true
|
||
# and clears Trigger (so the next run's trigger is a fresh edge)
|
||
# - Instance handshake-demo-1, bound + deployed
|
||
#
|
||
# Not idempotent (names must be unique). To re-seed:
|
||
# scadabridge instance delete --id <id> && scadabridge template delete --id <id>
|
||
# Prerequisites: infra (scadabridge-opcua) up, docker/deploy.sh, docker/seed-sites.sh.
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
|
||
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
|
||
SITE_ID="${SITE_ID:-1}"
|
||
CONNECTION_ID="${CONNECTION_ID:-1}"
|
||
INSTANCE_NAME="${INSTANCE_NAME:-handshake-demo-1}"
|
||
|
||
json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; }
|
||
|
||
echo "1/5 Creating template HandshakeDemo..."
|
||
TEMPLATE_ID=$("$CLI" template create "${A[@]}" --name HandshakeDemo \
|
||
--description "Gitea #36 live gate: reset-aware WriteBatchAndWaitAsync over real OPC UA flags" | json_id)
|
||
echo " id=$TEMPLATE_ID"
|
||
|
||
echo "2/5 Adding data-sourced attributes..."
|
||
"$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Trigger --data-type Boolean --data-source "ns=3;s=Motor.Running" --description "Handshake trigger (writable, not simulated)" > /dev/null
|
||
"$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Complete --data-type Boolean --data-source "ns=3;s=Pump.Running" --description "Handshake completion flag (writable, not simulated)" > /dev/null
|
||
"$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Payload --data-type Double --data-source "ns=3;s=Motor.Speed" --description "Batch payload" > /dev/null
|
||
|
||
echo "3/5 Adding the Handshake (Call) script — reset-aware overload..."
|
||
read -r -d '' HANDSHAKE_CODE <<'CS' || true
|
||
// Gitea #36: reset Complete=false, WAIT for the device to echo it, then batch+trigger, then wait
|
||
// for Complete=true. Run this twice: the second run is the one the plain overload got wrong.
|
||
var values = new Dictionary<string, object?> { ["Payload"] = 42.5 };
|
||
var hs = await Attributes.WriteBatchAndWaitAsync(
|
||
values, "Trigger", true, "Complete", true, TimeSpan.FromSeconds(20),
|
||
"Complete", false, TimeSpan.FromSeconds(10));
|
||
Log.Info($"handshake matched={hs.Matched} phase={hs.Phase} timedOut={hs.TimedOut} resetFailed={hs.ResetFailed}");
|
||
return new { Matched = hs.Matched, Phase = hs.Phase.ToString(), TimedOut = hs.TimedOut, ResetFailed = hs.ResetFailed, Error = hs.ErrorMessage ?? "" };
|
||
CS
|
||
"$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name Handshake --trigger-type Call \
|
||
--return-def '{"type":"object","properties":{"Matched":{"type":"boolean"},"Phase":{"type":"string"},"TimedOut":{"type":"boolean"},"ResetFailed":{"type":"boolean"},"Error":{"type":"string"}},"required":["Matched","Phase"]}' \
|
||
--code "$HANDSHAKE_CODE" > /dev/null
|
||
|
||
echo "4/5 Adding the Device (ValueChange on Trigger) script — plays the PLC..."
|
||
read -r -d '' DEVICE_CODE <<'CS' || true
|
||
// Plays the device: on the rising edge of Trigger, complete the request and clear the trigger
|
||
// so the NEXT run's Trigger=true is a fresh change on the subscription. Its own Trigger=false
|
||
// write re-triggers this script, which returns at the guard.
|
||
var t = await Attributes.GetAsync("Trigger");
|
||
var ts = t?.ToString();
|
||
if (!((t is bool b && b) || ts == "1" || string.Equals(ts, "true", StringComparison.OrdinalIgnoreCase))) return null;
|
||
await Attributes.SetAsync("Complete", true);
|
||
await Attributes.SetAsync("Trigger", false);
|
||
return null;
|
||
CS
|
||
"$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name Device --trigger-type ValueChange \
|
||
--trigger-config '{"attributeName":"Trigger"}' --code "$DEVICE_CODE" > /dev/null
|
||
|
||
echo "5/5 Creating, binding and deploying instance $INSTANCE_NAME..."
|
||
INSTANCE_ID=$("$CLI" instance create "${A[@]}" --name "$INSTANCE_NAME" --template-id "$TEMPLATE_ID" --site-id "$SITE_ID" | json_id)
|
||
"$CLI" instance set-bindings "${A[@]}" --id "$INSTANCE_ID" \
|
||
--bindings "[[\"Trigger\",$CONNECTION_ID],[\"Complete\",$CONNECTION_ID],[\"Payload\",$CONNECTION_ID]]" > /dev/null
|
||
"$CLI" instance deploy "${A[@]}" --id "$INSTANCE_ID" > /dev/null
|
||
echo " instance id=$INSTANCE_ID"
|
||
|
||
echo ""
|
||
echo "=== Done. Live gate (run it TWICE — the second run is the #36 case): ==="
|
||
echo " $CLI flow-debug run-script --instance-id $INSTANCE_ID --script Handshake --show-logs ${A[*]}"
|
||
echo "Expect on BOTH runs: Matched=true, Phase=Response; the trace shows Complete write →"
|
||
echo "Complete wait (reset echo, >0 ms on the 2nd run) → batch+Trigger write → Complete wait."
|
||
```
|
||
|
||
`chmod +x docker/seed-handshake-demo.sh`. Verify the `template script add` flag names against `docker/seed-delmia-sim.sh` (they are the same verbs) and that the Boolean `--value` is not required for data-sourced attributes (the live-probe seed omits it).
|
||
|
||
**Step 2: Shell-check**
|
||
|
||
Run: `bash -n docker/seed-handshake-demo.sh && echo OK`
|
||
Expected: `OK`
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add docker/seed-handshake-demo.sh
|
||
git commit -m "docker: seed-handshake-demo.sh — live gate for the reset-aware handshake over real OPC UA flags (#36)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Integration — full build, suites, merge, rig deploy + live gate, Gitea comment
|
||
|
||
**Classification:** high-risk (deploys the rig; merges to main)
|
||
**Estimated implement time:** ~5 min of commands + rig rebuild wall-time
|
||
**Parallelizable with:** none (last)
|
||
|
||
**Files:** none new (verification + merge)
|
||
|
||
**Step 1: Full build + the touched suites, in the worktree**
|
||
|
||
```bash
|
||
cd /Users/dohertj2/Desktop/ScadaBridge-issue36
|
||
dotnet build ZB.MOM.WW.ScadaBridge.slnx 2>&1 | grep -E "error|Warn|Build succeeded" | tail -5
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests 2>&1 | tail -3
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests 2>&1 | tail -3
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests 2>&1 | tail -3
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~ScriptAnalysis" 2>&1 | tail -3
|
||
```
|
||
|
||
Expected: `0 Error(s)`; every suite `Failed: 0`. Known pre-existing flakes (memory): none in these suites except register row 118 (a CentralUI modal test outside the filter).
|
||
|
||
**Step 2: Review the whole diff, then fast-forward main**
|
||
|
||
```bash
|
||
git diff main...HEAD --stat
|
||
cd /Users/dohertj2/Desktop/ScadaBridge && git status --short # must be clean
|
||
git merge --ff-only issue-36-handshake-reset
|
||
git log --oneline -12
|
||
```
|
||
|
||
**Step 3: Rebuild the rig from the primary checkout and run the live gate**
|
||
|
||
```bash
|
||
cd /Users/dohertj2/Desktop/ScadaBridge
|
||
bash docker/deploy.sh 2>&1 | tail -5
|
||
curl -sf http://localhost:9000/health/ready && echo READY
|
||
dotnet build src/ZB.MOM.WW.ScadaBridge.CLI 2>&1 | tail -1 # the seed uses the Debug CLI binary
|
||
bash docker/seed-handshake-demo.sh
|
||
```
|
||
|
||
Then run the printed `flow-debug run-script` command **twice** and record both outputs. Pass criteria:
|
||
|
||
- Run 1 and run 2 both return `Matched=true`, `Phase=Response`, `ResetFailed=false`.
|
||
- Run 2's trace shows the reset-echo `AttributeWait` step with a non-zero duration (the device's echo, through the subscription), followed by the batch write and a satisfied response wait.
|
||
- Negative control: temporarily undeploy nothing — instead run `python infra/tools/opcua_tool.py write --node "ns=3;s=Pump.Running" --value true --type Boolean` to plant a stale `true`, then run the script once more: still `Phase=Response`, `Matched=true` (the reset leg clears it first). If the OPC UA tool is not available, skip and say so.
|
||
|
||
If the seed fails on a CLI flag, fix the seed script (commit on main) and re-run; if the handshake fails, STOP — do not tune the script; report the trace.
|
||
|
||
**Step 4: Record the gate and close out**
|
||
|
||
- Append a short *Live gate 2026-09-10* section to `docs/plans/2026-09-10-handshake-reset-echo-design.md` with the two run results (durations of the four steps on run 2).
|
||
- Commit: `git commit -am "docs(#36): rig live gate results for the reset-aware handshake"`.
|
||
- Clean up: `git worktree remove ../ScadaBridge-issue36 && git branch -d issue-36-handshake-reset`.
|
||
- Comment on the Gitea issue (do NOT close — the QA/prod script port, register row 119, is still open):
|
||
|
||
```bash
|
||
tea comment 36 "Runtime fix landed on main (<sha>): reset-aware Attributes.WriteBatchAndWaitAsync overload (values, flagKey, flagValue, responseKey, responseValue, timeout, resetKey, resetValue, resetTimeout?) → HandshakeResult; the reset is written and its echo awaited BEFORE the batch/trigger. Edge-triggered wait rejected (design §2). Mirrors + predictor + tests (HandshakeResetEchoTests pins the stale-cache case over the real InstanceActor) + rig live gate docker/seed-handshake-demo.sh (two consecutive runs Phase=Response). Remaining: port the QA/prod MES scripts 13/14/15/16/25/26/36/37/38 — deferred-work register row 119. Design: docs/plans/2026-09-10-handshake-reset-echo-design.md."
|
||
```
|
||
|
||
- Do **not** push; report to the user with the merge SHA, the gate results, and the push/QA-deploy options (memory: QA/prod deployments are the user's call).
|
||
|
||
---
|
||
|
||
## Task dependency summary
|
||
|
||
| Task | Depends on | Parallel group |
|
||
|---|---|---|
|
||
| 1 Commons types | — | — |
|
||
| 2 Context composition | 1 | — |
|
||
| 3 Real-actor regression test | 2 | A (with 4, 5, 6) |
|
||
| 4 Accessor + compile-surface mirror | 2 | A |
|
||
| 5 Sandbox mirror + UI test | 1 | A |
|
||
| 6 Predictor | — (1 for the test's `ResetFailed` reference) | A |
|
||
| 7 Trace-emission test | 2 | B (with 8, 9) |
|
||
| 8 Docs / register / CLAUDE.md | — | B |
|
||
| 9 Rig seed script | 4 (script uses the overload) | B |
|
||
| 10 Integration + rig + Gitea | all | — |
|
||
|
||
Groups A and B are file-disjoint; if executed by parallel subagents, pre-create one worktree per task (memory: `isolation:worktree` agents share an index) and cherry-pick into `issue-36-handshake-reset` in task order.
|