Merge branch 'worktree-agent-ac243fcc404f04015' into feat/flow-debugger-script-entry

This commit is contained in:
Joseph Doherty
2026-09-08 01:15:04 -04:00
3 changed files with 286 additions and 110 deletions
@@ -9,8 +9,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
@@ -20,7 +24,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Default <see cref="IFlowDebugService"/>: prediction runs in-process over the repositories
/// (method script + instance → template chain → script bodies, DB-only, nothing executes);
/// (method script + instance → template chain → script bodies, DB-only, nothing executes),
/// while the script entry (design 2026-09-08 §2) resolves an instance's scripts through the
/// DeploymentManager's <see cref="IFlatteningPipeline"/> so the picker, the prediction and the
/// run all see the same canonical names, trigger kinds and effective parameter definitions the
/// runner will;
/// the run/replay commands go to the per-node <c>ManagementActor</c> through
/// <see cref="ManagementActorHolder"/>, exactly as <see cref="DataConnectionMoveService"/>
/// does, with the current Blazor principal projected to an <see cref="AuthenticatedUser"/>
@@ -58,8 +66,8 @@ public sealed class FlowDebugService : IFlowDebugService
}
/// <summary>
/// Client-side Ask wait for <see cref="RunValueChangeAsync"/>: the trigger-wait window
/// plus <see cref="ClientSlack"/>.
/// Client-side Ask wait for a WRITE-kind script run (<see cref="RunScriptAsync"/>): the
/// trigger-wait window plus <see cref="ClientSlack"/>.
/// </summary>
public static TimeSpan ValueChangeAskTimeoutFor(int waitSeconds)
=> TimeSpan.FromSeconds(Math.Max(1, waitSeconds)) + ClientSlack;
@@ -67,6 +75,13 @@ public sealed class FlowDebugService : IFlowDebugService
/// <summary>Upper bound on the template parent chain the script resolver walks (cycle guard).</summary>
private const int MaxTemplateChain = 16;
/// <summary>
/// Ask-sizing fallback for a call-kind script that declares no timeout of its own — mirrors
/// <c>SiteRuntimeOptions.ScriptExecutionTimeoutSeconds</c>, the site default the run would
/// actually be bounded by (the UI has no site options to read it from).
/// </summary>
private const int DefaultScriptTimeoutSeconds = 30;
private static readonly JsonSerializerOptions ResultJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -77,15 +92,32 @@ public sealed class FlowDebugService : IFlowDebugService
private readonly AuthenticationStateProvider _auth;
private readonly IInboundApiRepository _inboundApi;
private readonly ITemplateEngineRepository _templates;
private readonly IFlatteningPipeline _pipeline;
private readonly CommunicationService _comms;
private readonly ILogger<FlowDebugService> _logger;
/// <summary>
/// Flattened script lists by instance id. The service is scoped (one per Blazor circuit
/// interaction), so this costs at most one flatten per instance the operator selects,
/// and the picker, the prediction and the run all read the same snapshot. The raw
/// <see cref="ResolvedScript"/>s ride along because prediction needs the code bodies,
/// which the picker's projection deliberately does not carry.
/// </summary>
private readonly Dictionary<int, InstanceScripts> _scriptCache = new();
/// <summary>One instance's flattened scripts: the unique name the site keys on, the raw resolved scripts, and the picker projection.</summary>
private sealed record InstanceScripts(
string UniqueName,
IReadOnlyList<ResolvedScript> Scripts,
IReadOnlyList<FlowScriptOption> Options);
/// <summary>Initializes the service.</summary>
public FlowDebugService(
ManagementActorHolder holder,
AuthenticationStateProvider auth,
IInboundApiRepository inboundApi,
ITemplateEngineRepository templates,
IFlatteningPipeline pipeline,
CommunicationService comms,
ILogger<FlowDebugService> logger)
{
@@ -93,6 +125,7 @@ public sealed class FlowDebugService : IFlowDebugService
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
_inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi));
_templates = templates ?? throw new ArgumentNullException(nameof(templates));
_pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
_comms = comms ?? throw new ArgumentNullException(nameof(comms));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -161,43 +194,39 @@ public sealed class FlowDebugService : IFlowDebugService
}
/// <inheritdoc/>
public async Task<PredictedFlow> PredictValueChangeAsync(int instanceId, string attributeName, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default)
=> (await LoadScriptsAsync(instanceId, cancellationToken))?.Options ?? [];
/// <inheritdoc/>
public async Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default)
{
var instance = await _templates.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
var loaded = await LoadScriptsAsync(instanceId, cancellationToken);
var script = loaded?.Scripts.FirstOrDefault(s => s.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase));
if (loaded == null || script == null)
return PredictedFlow.Empty;
var scripts = await CollectEffectiveScriptsAsync(instance.TemplateId, cancellationToken);
var triggered = scripts.Values
.Where(s => IsValueChangeOn(s, attributeName))
.OrderBy(s => s.Name, StringComparer.Ordinal)
.ToList();
var title = $"{loaded.UniqueName}.{script.CanonicalName}";
if (ScriptTriggerAttribute.TryRead(script.TriggerType, script.TriggerConfiguration, out var attribute) && attribute != null)
return PredictValueChange(loaded, attribute);
// Root: the write itself (Tags lane), then each triggered script predicted as a site script.
var nodes = new List<PredictedNode>
if (ScriptTriggerAttribute.IsWriteKind(script.TriggerType))
{
new("n1", FlowStepKind.AttributeWrite, FlowLane.Tags, $"{instance.UniqueName}.{attributeName}", false,
triggered.Count == 0 ? "no value-change script on this attribute" : null),
};
var edges = new List<PredictedEdge>();
foreach (var script in triggered)
{
var sub = FlowShapePredictor.PredictSiteScript($"{instance.UniqueName}.{script.Name}", script.Code ?? string.Empty);
var offset = nodes.Count;
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var n in sub.Nodes)
{
var id = "n" + (nodes.Count + 1);
map[n.Id] = id;
nodes.Add(n with { Id = id });
}
if (sub.Nodes.Count > 0)
edges.Add(new PredictedEdge("n1", nodes[offset].Id, false));
foreach (var e in sub.Edges)
edges.Add(e with { From = map[e.From], To = map[e.To] });
// A write-kind trigger whose configuration names no attribute: nothing to write, so
// the run cannot be predicted past the script itself.
return new PredictedFlow(
[new PredictedNode("n1", FlowStepKind.ScriptRun, FlowLane.Site, title, false, "trigger names no attribute")],
[]);
}
// Call kinds: the debugger invokes the script directly through the traced routed-call
// path, so the root is a Central-lane RouteCall and the site script hangs off it across
// the cluster boundary — the same shape a `Route.To(instance).Call(script)` renders.
var nodes = new List<PredictedNode>
{
new("n1", FlowStepKind.RouteCall, FlowLane.Central, title, false, null),
};
var edges = new List<PredictedEdge>();
AppendSubFlow(FlowShapePredictor.PredictSiteScript(title, script.Code ?? string.Empty), nodes, edges, crossesBoundary: true);
return new PredictedFlow(nodes, edges);
}
@@ -210,10 +239,22 @@ public sealed class FlowDebugService : IFlowDebugService
}
/// <inheritdoc/>
public Task<FlowDebugOutcome> RunValueChangeAsync(int instanceId, string attributeName, string value, CancellationToken cancellationToken = default)
public async Task<FlowDebugOutcome> RunScriptAsync(
int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default)
{
var command = new FlowDebugRunValueChangeCommand(instanceId, attributeName, value);
return SendAndBuildAsync(command, ValueChangeAskTimeoutFor(command.WaitSeconds), cancellationToken);
// The server re-resolves the script and never trusts a client-supplied trigger kind
// (design 2026-09-08 §2); the kind is read here only to size the Ask wait — a call kind
// is bounded by the script's own execution timeout, a write kind by the trigger-wait
// window. An unresolvable script falls back to the write-kind wait, which is the longer
// of the two for a default-timeout script.
var command = new FlowDebugRunScriptCommand(instanceId, scriptName, parameters, value);
var option = (await GetInstanceScriptsAsync(instanceId, cancellationToken))
.FirstOrDefault(o => o.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase));
var timeout = option is { IsWriteKind: false }
? RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? DefaultScriptTimeoutSeconds)
: ValueChangeAskTimeoutFor(command.WaitSeconds);
return await SendAndBuildAsync(command, timeout, cancellationToken);
}
/// <inheritdoc/>
@@ -366,38 +407,94 @@ public sealed class FlowDebugService : IFlowDebugService
return result;
}
/// <summary>True when the script's trigger is a value-change on <paramref name="attributeName"/> (case-insensitive).</summary>
private static bool IsValueChangeOn(TemplateScript script, string attributeName)
/// <summary>
/// Loads an instance's scripts from its flattened configuration (design 2026-09-08 §2) —
/// canonical names, effective trigger kinds and effective parameter definitions, i.e. exactly
/// what the site's Instance Actor keys on — memoised for the life of this scoped service.
/// Returns null when the instance does not exist or the flatten failed; a flatten failure is
/// a debug-level note, never a page error, because the picker simply has nothing to offer.
/// </summary>
private async Task<InstanceScripts?> LoadScriptsAsync(int instanceId, CancellationToken ct)
{
if (!string.Equals(script.TriggerType, "valuechange", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(script.TriggerType, "ValueChange", StringComparison.OrdinalIgnoreCase))
return false;
if (_scriptCache.TryGetValue(instanceId, out var cached))
return cached;
var cfg = script.TriggerConfiguration;
if (string.IsNullOrWhiteSpace(cfg))
return false;
var instance = await _templates.GetInstanceByIdAsync(instanceId, ct);
if (instance == null)
return null;
try
var flattened = await _pipeline.FlattenAndValidateAsync(instance.Id, ct, validateScripts: false);
if (flattened.IsFailure)
{
using var doc = JsonDocument.Parse(cfg);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return false;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (prop.NameEquals("attribute") || prop.NameEquals("Attribute")
|| prop.NameEquals("attributeName") || prop.NameEquals("AttributeName"))
{
return prop.Value.ValueKind == JsonValueKind.String
&& string.Equals(prop.Value.GetString(), attributeName, StringComparison.OrdinalIgnoreCase);
}
}
}
catch (JsonException)
{
// Malformed trigger config — not something prediction should fail over.
_logger.LogDebug("Flow Debugger could not flatten instance {InstanceId}: {Error}", instanceId, flattened.Error);
return null;
}
return false;
var scripts = flattened.Value.Configuration.Scripts
.OrderBy(s => s.CanonicalName, StringComparer.OrdinalIgnoreCase)
.ToList();
var options = scripts
.Select(s => new FlowScriptOption(
s.CanonicalName,
s.TriggerType,
ScriptTriggerAttribute.TryRead(s.TriggerType, s.TriggerConfiguration, out var a) ? a : null,
s.ParameterDefinitions,
s.ExecutionTimeoutSeconds))
.ToList();
var loaded = new InstanceScripts(instance.UniqueName, scripts, options);
_scriptCache[instanceId] = loaded;
return loaded;
}
/// <summary>
/// Predicts the flow a write to <paramref name="attributeName"/> would trigger: the write
/// itself on the Tags lane, then every script whose write-kind trigger names that attribute
/// predicted as a site script. Conditional triggers are included alongside ValueChange —
/// both name a single monitored attribute and both really fire on the write.
/// </summary>
private static PredictedFlow PredictValueChange(InstanceScripts loaded, string attributeName)
{
var triggered = loaded.Scripts
.Where(s => ScriptTriggerAttribute.TryRead(s.TriggerType, s.TriggerConfiguration, out var a)
&& string.Equals(a, attributeName, StringComparison.OrdinalIgnoreCase))
.ToList();
var nodes = new List<PredictedNode>
{
new("n1", FlowStepKind.AttributeWrite, FlowLane.Tags, $"{loaded.UniqueName}.{attributeName}", false,
triggered.Count == 0 ? "no value-change script on this attribute" : null),
};
var edges = new List<PredictedEdge>();
foreach (var script in triggered)
{
var sub = FlowShapePredictor.PredictSiteScript($"{loaded.UniqueName}.{script.CanonicalName}", script.Code ?? string.Empty);
AppendSubFlow(sub, nodes, edges, crossesBoundary: false);
}
return new PredictedFlow(nodes, edges);
}
/// <summary>
/// Appends a predicted sub-flow to <paramref name="nodes"/>/<paramref name="edges"/>,
/// renumbering its node ids into the outer sequence and linking its first node to the root
/// (<c>n1</c>).
/// </summary>
private static void AppendSubFlow(PredictedFlow sub, List<PredictedNode> nodes, List<PredictedEdge> edges, bool crossesBoundary)
{
var offset = nodes.Count;
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var n in sub.Nodes)
{
var id = "n" + (nodes.Count + 1);
map[n.Id] = id;
nodes.Add(n with { Id = id });
}
if (sub.Nodes.Count > 0)
edges.Add(new PredictedEdge("n1", nodes[offset].Id, crossesBoundary));
foreach (var e in sub.Edges)
edges.Add(e with { From = map[e.From], To = map[e.To] });
}
private async Task<FlowDebugOutcome> SendAndBuildAsync(object command, TimeSpan timeout, CancellationToken ct)
@@ -1,6 +1,7 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
@@ -34,9 +35,32 @@ public sealed record FlowDebugOutcome(FlowGraph? Graph, FlowDebugRunResult? Raw,
public sealed record FlowOutboundActionResult(bool Success, string Message);
/// <summary>
/// CentralUI facade over the Flow Debugger (design 2026-09-05 §5.5/§7.1): static shape
/// prediction from script syntax, and the three management commands (Live run of a method,
/// Live run of a value change, Replay) dispatched to the per-node <c>ManagementActor</c>
/// One script of an instance as the Flow Debugger's script picker shows it (design 2026-09-08 §5):
/// canonical name, trigger kind, the trigger's attribute for write kinds, the effective parameter
/// definitions for call kinds, and the script's own execution timeout (client Ask sizing).
/// </summary>
/// <param name="CanonicalName">Path-qualified script name from the flattened configuration (<c>Module.Script</c> for a composed member).</param>
/// <param name="TriggerType">The effective trigger type, or null when the script has none.</param>
/// <param name="TriggerAttribute">The monitored attribute of a write-kind trigger; null for every other kind.</param>
/// <param name="ParameterDefinitions">JSON parameter definitions the Inputs card renders for a call kind; null = none.</param>
/// <param name="ExecutionTimeoutSeconds">The script's own execution timeout, or null to use the site default.</param>
public sealed record FlowScriptOption(
string CanonicalName, string? TriggerType, string? TriggerAttribute,
string? ParameterDefinitions, int? ExecutionTimeoutSeconds)
{
/// <summary>True when the debugger fires this script by writing <see cref="TriggerAttribute"/>.</summary>
public bool IsWriteKind => ScriptTriggerAttribute.IsWriteKind(TriggerType);
/// <summary>Picker label suffix: <c>ValueChange: Level</c>, <c>Call</c>, <c>no trigger</c>…</summary>
public string TriggerLabel => string.IsNullOrWhiteSpace(TriggerType) ? "no trigger"
: TriggerAttribute is { } a ? $"{TriggerType}: {a}" : TriggerType!;
}
/// <summary>
/// CentralUI facade over the Flow Debugger (design 2026-09-05 §5.5/§7.1, script entry
/// 2026-09-08 §5): the instance's script list read from its flattened configuration, static
/// shape prediction from script syntax, and the three management commands (Live run of a
/// method, Live run of a script, Replay) dispatched to the per-node <c>ManagementActor</c>
/// through the in-process <c>ManagementActorHolder</c> — the SAME Ask path the HTTP
/// <c>/management</c> endpoint uses, so the server stays the single enforcer of the Deployer
/// gate and the active-node refusal (<c>STANDBY_NODE</c>).
@@ -49,15 +73,16 @@ public interface IFlowDebugService
/// <returns>The predicted flow; <see cref="PredictedFlow.Empty"/> when the method does not exist.</returns>
Task<PredictedFlow> PredictMethodAsync(int apiMethodId, CancellationToken cancellationToken = default);
/// <summary>
/// Predicts the flow a value change on <paramref name="attributeName"/> would trigger: the
/// instance's template scripts whose value-change trigger names that attribute, each predicted
/// as a site script.
/// </summary>
/// <summary>The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist.</summary>
/// <param name="instanceId">The instance id.</param>
/// <param name="attributeName">Canonical attribute name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<PredictedFlow> PredictValueChangeAsync(int instanceId, string attributeName, CancellationToken cancellationToken = default);
Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>Predicts the flow of running <paramref name="scriptName"/>: the write + spawned script for a write kind, a RouteCall root + the script for a call kind.</summary>
/// <param name="instanceId">The instance id.</param>
/// <param name="scriptName">Canonical script name (case-insensitive).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default);
/// <summary>Runs an inbound API method with tracing on the active central node and returns the flow.</summary>
/// <param name="apiMethodId">The method id.</param>
@@ -65,12 +90,13 @@ public interface IFlowDebugService
/// <param name="cancellationToken">Cancellation token.</param>
Task<FlowDebugOutcome> RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default);
/// <summary>Writes an attribute through the traced path and returns the flow the change triggered.</summary>
/// <summary>Runs a script by name (write kinds take <paramref name="value"/>, call kinds <paramref name="parameters"/>) and returns the flow.</summary>
/// <param name="instanceId">The instance id.</param>
/// <param name="attributeName">Canonical attribute name.</param>
/// <param name="value">Value in canonical string form.</param>
/// <param name="scriptName">Canonical script name (case-insensitive).</param>
/// <param name="parameters">Call kinds: flat JSON object of parameters, or null.</param>
/// <param name="value">Write kinds: the new value for the trigger's attribute, in canonical string form.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<FlowDebugOutcome> RunValueChangeAsync(int instanceId, string attributeName, string value, CancellationToken cancellationToken = default);
Task<FlowDebugOutcome> RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default);
/// <summary>Rebuilds a past flow from the central audit store.</summary>
/// <param name="executionId">Any execution id in the chain.</param>
@@ -17,7 +17,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.Security;
@@ -27,8 +30,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Tests for <see cref="FlowDebugService"/> (Flow Debugger design 2026-09-05 §5.5/§7.1):
/// the management-command bridge (holder null, error mapping, success → graph), the
/// prediction resolver wiring over substituted repositories, and the Retry/Discard relay
/// for a parked outbound node (follow-ups plan 2026-09-06 D4).
/// prediction resolver wiring over substituted repositories, the script entry (design
/// 2026-09-08 §5 — script list, prediction and run over the flattened configuration), and
/// the Retry/Discard relay for a parked outbound node (follow-ups plan 2026-09-06 D4).
/// </summary>
/// <remarks>
/// <see cref="CommunicationService"/> is a concrete class with non-virtual methods, so the
@@ -48,6 +52,7 @@ public sealed class FlowDebugServiceTests : IDisposable
private readonly ActorSystem _system = ActorSystem.Create("flow-debug-service-tests");
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly List<ManagementEnvelope> _received = new();
// Relay probes: everything the scripted Site Call Audit / Notification Outbox actors receive.
@@ -80,7 +85,21 @@ public sealed class FlowDebugServiceTests : IDisposable
comms.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))));
comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))));
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, comms, NullLogger<FlowDebugService>.Instance);
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger<FlowDebugService>.Instance);
}
/// <summary>
/// Wires the instance row plus the flattened configuration its scripts are read from
/// (script entry, design 2026-09-08 §2 — the picker and the prediction both go through
/// <see cref="IFlatteningPipeline"/>, never the raw template chain).
/// </summary>
private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts)
{
_templates.GetInstanceByIdAsync(instanceId, Arg.Any<CancellationToken>())
.Returns(new Instance(uniqueName) { Id = instanceId, TemplateId = 10, SiteId = 1 });
var cfg = new FlattenedConfiguration { InstanceUniqueName = uniqueName, TemplateId = 10, SiteId = 1, Scripts = scripts };
_pipeline.FlattenAndValidateAsync(instanceId, Arg.Any<CancellationToken>(), false, null)
.Returns(Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult())));
}
/// <summary>An External-lane step as <c>FlowStepProjector</c> would project it (positional per <see cref="FlowStep"/>).</summary>
@@ -337,19 +356,21 @@ public sealed class FlowDebugServiceTests : IDisposable
}
[Fact]
public async Task RunValueChange_AndReplay_SendTheirCommands()
public async Task RunWriteKindScript_AndReplay_SendTheirCommands()
{
WireFlattened(7, "Sim01",
new ResolvedScript { CanonicalName = "OnSpeed", Code = "x", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Speed\"}" });
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 1)));
var exec = Guid.NewGuid();
var vc = await svc.RunValueChangeAsync(7, "Speed", "42");
var vc = await svc.RunScriptAsync(7, "OnSpeed", null, "42");
var rp = await svc.ReplayAsync(exec);
Assert.True(vc.Success);
Assert.True(rp.Success);
Assert.Equal(2, _received.Count);
var vcCmd = Assert.IsType<FlowDebugRunValueChangeCommand>(_received[0].Command);
Assert.Equal((7, "Speed", "42"), (vcCmd.InstanceId, vcCmd.AttributeName, vcCmd.Value));
var vcCmd = Assert.IsType<FlowDebugRunScriptCommand>(_received[0].Command);
Assert.Equal((7, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value));
var rpCmd = Assert.IsType<FlowDebugReplayCommand>(_received[1].Command);
Assert.Equal(exec, rpCmd.ExecutionId);
}
@@ -519,22 +540,36 @@ public sealed class FlowDebugServiceTests : IDisposable
}
[Fact]
public async Task PredictValueChange_RootsAtTheWrite_AndAppendsEachTriggeredScript()
public async Task GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName()
{
_templates.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
[
new TemplateScript("OnFlag", """Attributes["Ack"] = true;""") { Id = 1, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Flag"}""" },
new TemplateScript("OnOther", "return 1;") { Id = 2, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Other"}""" },
new TemplateScript("Manual", "return 1;") { Id = 3, TemplateId = 10, TriggerType = "call" },
]));
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>())
.Returns(new Template("Base") { Id = 10 });
WireFlattened(9, "Sim01",
new ResolvedScript { CanonicalName = "Manual", Code = "return 1;", TriggerType = "Call", ParameterDefinitions = "{\"type\":\"object\"}", ExecutionTimeoutSeconds = 12 },
new ResolvedScript { CanonicalName = "OnFlag", Code = "x", TriggerType = "valuechange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" },
new ResolvedScript { CanonicalName = "Mod.Tick", Code = "x", TriggerType = "Interval", TriggerConfiguration = "{\"intervalMs\":1000}" });
var svc = CreateService(reply: null);
var flow = await svc.PredictValueChangeAsync(9, "Flag");
var options = await CreateService(reply: null).GetInstanceScriptsAsync(9);
Assert.Equal(["Manual", "Mod.Tick", "OnFlag"], options.Select(o => o.CanonicalName).ToList());
var onFlag = options.Single(o => o.CanonicalName == "OnFlag");
Assert.True(onFlag.IsWriteKind); Assert.Equal("Flag", onFlag.TriggerAttribute); Assert.Equal("valuechange: Flag", onFlag.TriggerLabel);
var manual = options.Single(o => o.CanonicalName == "Manual");
Assert.False(manual.IsWriteKind); Assert.Equal(12, manual.ExecutionTimeoutSeconds); Assert.Equal("Call", manual.TriggerLabel);
Assert.False(options.Single(o => o.CanonicalName == "Mod.Tick").IsWriteKind);
}
[Fact]
public async Task GetInstanceScripts_UnknownInstance_IsEmpty()
{
Assert.Empty(await CreateService(reply: null).GetInstanceScriptsAsync(404));
}
[Fact]
public async Task PredictScript_WriteKind_RootsAtTheWrite_AndAppendsTheScript()
{
WireFlattened(9, "Sim01",
new ResolvedScript { CanonicalName = "OnFlag", Code = """Attributes["Ack"] = true;""", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" });
var flow = await CreateService(reply: null).PredictScriptAsync(9, "OnFlag");
Assert.Equal(
[
@@ -542,25 +577,43 @@ public sealed class FlowDebugServiceTests : IDisposable
(FlowStepKind.ScriptRun, "Sim01.OnFlag"),
(FlowStepKind.AttributeWrite, "Ack"),
], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList());
Assert.Equal(2, flow.Edges.Count);
Assert.Equal(("n1", "n2"), (flow.Edges[0].From, flow.Edges[0].To));
Assert.Equal(("n2", "n3"), (flow.Edges[1].From, flow.Edges[1].To));
}
[Fact]
public async Task PredictValueChange_NoTriggeredScript_NotesIt()
public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript()
{
_templates.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>([]));
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>()).Returns(new Template("Base") { Id = 10 });
WireFlattened(9, "Sim01",
new ResolvedScript { CanonicalName = "Manual", Code = """Attributes["Ack"] = true;""", TriggerType = "Call" });
var svc = CreateService(reply: null);
var flow = await svc.PredictValueChangeAsync(9, "Flag");
var flow = await CreateService(reply: null).PredictScriptAsync(9, "Manual");
var root = Assert.Single(flow.Nodes);
Assert.Contains("no value-change script", root.Note);
Assert.Equal(FlowStepKind.RouteCall, flow.Nodes[0].Kind);
Assert.Equal(FlowLane.Central, flow.Nodes[0].Lane);
Assert.Equal("Sim01.Manual", flow.Nodes[0].Title);
Assert.Equal(FlowStepKind.ScriptRun, flow.Nodes[1].Kind);
Assert.True(flow.Edges[0].CrossesBoundary);
}
[Fact]
public async Task PredictScript_UnknownScript_IsEmpty()
{
WireFlattened(9, "Sim01");
Assert.Same(PredictedFlow.Empty, await CreateService(reply: null).PredictScriptAsync(9, "Nope"));
}
[Fact]
public async Task RunScript_SendsTheCommand_WithTheScriptShape()
{
WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call", ExecutionTimeoutSeconds = 40 });
var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson)));
var outcome = await svc.RunScriptAsync(9, "Manual", null, null);
Assert.True(outcome.Success, outcome.Error);
var env = Assert.Single(_received);
var cmd = Assert.IsType<FlowDebugRunScriptCommand>(env.Command);
Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName));
}
private sealed class StubAuth : AuthenticationStateProvider