Merge branch 'worktree-agent-a6faa2ddb571f8394' into fix/flow-debugger-arch-review

This commit is contained in:
Joseph Doherty
2026-09-06 14:22:32 -04:00
3 changed files with 227 additions and 2 deletions
@@ -3,14 +3,20 @@ using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
@@ -213,6 +219,151 @@ public class ScriptRunShedTests : TestKit, IDisposable
Assert.Equal(4, alarm.UnderlyingActor.RunsInFlight);
}, TimeSpan.FromSeconds(10));
}
// ---------------------------------------------------------------------------------------
// Arch-review F7: a TRACED run refused by either guard (the in-flight cap, or
// MinTimeBetweenRuns) used to emit nothing at all, so the Flow Debugger reported
// NO_TRIGGER_WITHIN_WINDOW — "no value-change trigger on this attribute, or no device echo"
// — which is wrong in both cases and sends the operator to check the wrong things. Each
// guard now leaves a Failed ScriptTrace/ScriptRun row naming the guard that refused it.
// ---------------------------------------------------------------------------------------
/// <summary>Audit writer fake: records every emitted row and can wait for one matching a predicate.</summary>
private sealed class CapturingAuditWriter : IAuditWriter
{
private readonly List<AuditEvent> _events = new();
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (_events) _events.Add(evt);
return Task.CompletedTask;
}
public AuditEvent[] Snapshot()
{
lock (_events) return _events.ToArray();
}
public AuditEvent? WaitFor(Func<AuditEvent, bool> predicate, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
lock (_events)
{
var hit = _events.FirstOrDefault(predicate);
if (hit is not null) return hit;
}
Thread.Sleep(25);
}
return null;
}
}
private static IServiceProvider AuditServices(ISiteEventLogger siteLog, IAuditWriter writer)
{
var services = new ServiceCollection();
services.AddSingleton(siteLog);
services.AddSingleton(writer);
return services.BuildServiceProvider();
}
private static ResolvedScript ValueChangeScript(string name, TimeSpan? minTimeBetweenRuns = null) => new()
{
CanonicalName = name,
TriggerType = "valuechange",
TriggerConfiguration = "{\"attribute\":\"Status\"}",
MinTimeBetweenRuns = minTimeBetweenRuns
};
private static bool IsFailedScriptRun(AuditEvent evt) =>
evt.Action == "ScriptTrace.ScriptRun" && evt.AsRow().Status.ToString() == "Failed";
[Fact]
public void TracedRun_ShedAtTheCap_EmitsAFailedScriptRunRow_ParentedToTheWriter()
{
var siteLog = new FakeSiteEventLogger();
var writer = new CapturingAuditWriter();
var health = new SiteHealthCollector();
var instance = CreateTestProbe().Ref;
var options = Options();
var actor = ActorOfAsTestActorRef<ScriptActor>(
Props.Create(() => new ScriptActor(
"Hot", "Inst1", instance, BlockingScript(),
ValueChangeScript("Hot"),
_sharedLibrary, options, NullLogger<ScriptActor>.Instance,
null, null, health, AuditServices(siteLog, writer), _scheduler, null)),
"shed-traced-" + Guid.NewGuid().ToString("N"));
// Fill the cap with four untraced runs, all blocked in their bodies.
for (var i = 0; i < 4; i++)
actor.Tell(new ScriptCallRequest("Hot", null, 0, $"corr-{i}"), ActorRefs.NoSender);
AwaitAssert(() => Assert.Equal(4, actor.UnderlyingActor.RunsInFlight), TimeSpan.FromSeconds(15));
// The fifth trigger is a TRACED value change stamped by its writing execution.
var parent = Guid.NewGuid();
actor.Tell(new AttributeValueChanged(
"Inst1", "Status", "Status", "Running", "Good", DateTimeOffset.UtcNow,
SourceExecutionId: parent, Trace: true));
var evt = writer.WaitFor(IsFailedScriptRun, TimeSpan.FromSeconds(10));
Assert.NotNull(evt);
var row = evt!.AsRow();
Assert.Equal(parent, row.ParentExecutionId);
Assert.NotNull(row.ExecutionId);
Assert.StartsWith("shed: MaxConcurrentRunsPerScript", row.ErrorMessage);
Assert.Equal("Hot", evt.Target);
Assert.Equal("Inst1", row.SourceInstanceId);
// The trigger detail survives, so the debugger can still name the attribute.
var extra = ScriptTraceExtra.TryParse(row.Extra)!;
Assert.Equal("valuechange", extra.TriggerKind);
Assert.Equal("Status", extra.TriggerAttribute);
// Still exactly four in flight — the shed run was never launched.
Assert.Equal(4, actor.UnderlyingActor.RunsInFlight);
}
[Fact]
public void TracedRun_SuppressedByMinTimeBetweenRuns_EmitsAFailedScriptRunRow()
{
var siteLog = new FakeSiteEventLogger();
var writer = new CapturingAuditWriter();
var health = new SiteHealthCollector();
var instance = CreateTestProbe().Ref;
var options = Options();
var actor = ActorOfAsTestActorRef<ScriptActor>(
Props.Create(() => new ScriptActor(
"Throttled", "Inst1", instance, CompileRaw("return null;"),
ValueChangeScript("Throttled", TimeSpan.FromMinutes(10)),
_sharedLibrary, options, NullLogger<ScriptActor>.Instance,
null, null, health, AuditServices(siteLog, writer), _scheduler, null)),
"throttled-traced-" + Guid.NewGuid().ToString("N"));
// First traced change runs (and emits a Delivered row); the second, back to back,
// is inside MinTimeBetweenRuns and never launches.
var parent = Guid.NewGuid();
for (var i = 0; i < 2; i++)
{
actor.Tell(new AttributeValueChanged(
"Inst1", "Status", "Status", $"Run{i}", "Good", DateTimeOffset.UtcNow,
SourceExecutionId: parent, Trace: true));
}
var evt = writer.WaitFor(IsFailedScriptRun, TimeSpan.FromSeconds(10));
Assert.NotNull(evt);
var row = evt!.AsRow();
Assert.Equal(parent, row.ParentExecutionId);
Assert.StartsWith("suppressed: MinTimeBetweenRuns", row.ErrorMessage);
// The first run still produced its ordinary Delivered row — the guard row is additive.
AwaitAssert(() => Assert.Contains(
writer.Snapshot(),
e => e.Action == "ScriptTrace.ScriptRun" && e.AsRow().Status.ToString() == "Delivered"),
TimeSpan.FromSeconds(10));
}
}
/// <summary>Test hook used to hold script runs in flight while the cap is exercised.</summary>