Merge branch 'worktree-agent-ac243fcc404f04015' into feat/flow-debugger-script-entry
This commit is contained in:
@@ -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.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
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.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.Communication;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||||
using ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug;
|
using ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug;
|
||||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||||
@@ -20,7 +24,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default <see cref="IFlowDebugService"/>: prediction runs in-process over the repositories
|
/// 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
|
/// the run/replay commands go to the per-node <c>ManagementActor</c> through
|
||||||
/// <see cref="ManagementActorHolder"/>, exactly as <see cref="DataConnectionMoveService"/>
|
/// <see cref="ManagementActorHolder"/>, exactly as <see cref="DataConnectionMoveService"/>
|
||||||
/// does, with the current Blazor principal projected to an <see cref="AuthenticatedUser"/>
|
/// does, with the current Blazor principal projected to an <see cref="AuthenticatedUser"/>
|
||||||
@@ -58,8 +66,8 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Client-side Ask wait for <see cref="RunValueChangeAsync"/>: the trigger-wait window
|
/// Client-side Ask wait for a WRITE-kind script run (<see cref="RunScriptAsync"/>): the
|
||||||
/// plus <see cref="ClientSlack"/>.
|
/// trigger-wait window plus <see cref="ClientSlack"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TimeSpan ValueChangeAskTimeoutFor(int waitSeconds)
|
public static TimeSpan ValueChangeAskTimeoutFor(int waitSeconds)
|
||||||
=> TimeSpan.FromSeconds(Math.Max(1, waitSeconds)) + ClientSlack;
|
=> 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>
|
/// <summary>Upper bound on the template parent chain the script resolver walks (cycle guard).</summary>
|
||||||
private const int MaxTemplateChain = 16;
|
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()
|
private static readonly JsonSerializerOptions ResultJsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
@@ -77,15 +92,32 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
private readonly AuthenticationStateProvider _auth;
|
private readonly AuthenticationStateProvider _auth;
|
||||||
private readonly IInboundApiRepository _inboundApi;
|
private readonly IInboundApiRepository _inboundApi;
|
||||||
private readonly ITemplateEngineRepository _templates;
|
private readonly ITemplateEngineRepository _templates;
|
||||||
|
private readonly IFlatteningPipeline _pipeline;
|
||||||
private readonly CommunicationService _comms;
|
private readonly CommunicationService _comms;
|
||||||
private readonly ILogger<FlowDebugService> _logger;
|
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>
|
/// <summary>Initializes the service.</summary>
|
||||||
public FlowDebugService(
|
public FlowDebugService(
|
||||||
ManagementActorHolder holder,
|
ManagementActorHolder holder,
|
||||||
AuthenticationStateProvider auth,
|
AuthenticationStateProvider auth,
|
||||||
IInboundApiRepository inboundApi,
|
IInboundApiRepository inboundApi,
|
||||||
ITemplateEngineRepository templates,
|
ITemplateEngineRepository templates,
|
||||||
|
IFlatteningPipeline pipeline,
|
||||||
CommunicationService comms,
|
CommunicationService comms,
|
||||||
ILogger<FlowDebugService> logger)
|
ILogger<FlowDebugService> logger)
|
||||||
{
|
{
|
||||||
@@ -93,6 +125,7 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
|
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
|
||||||
_inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi));
|
_inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi));
|
||||||
_templates = templates ?? throw new ArgumentNullException(nameof(templates));
|
_templates = templates ?? throw new ArgumentNullException(nameof(templates));
|
||||||
|
_pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
|
||||||
_comms = comms ?? throw new ArgumentNullException(nameof(comms));
|
_comms = comms ?? throw new ArgumentNullException(nameof(comms));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
@@ -161,43 +194,39 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <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);
|
var loaded = await LoadScriptsAsync(instanceId, cancellationToken);
|
||||||
if (instance == null)
|
var script = loaded?.Scripts.FirstOrDefault(s => s.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (loaded == null || script == null)
|
||||||
return PredictedFlow.Empty;
|
return PredictedFlow.Empty;
|
||||||
|
|
||||||
var scripts = await CollectEffectiveScriptsAsync(instance.TemplateId, cancellationToken);
|
var title = $"{loaded.UniqueName}.{script.CanonicalName}";
|
||||||
var triggered = scripts.Values
|
if (ScriptTriggerAttribute.TryRead(script.TriggerType, script.TriggerConfiguration, out var attribute) && attribute != null)
|
||||||
.Where(s => IsValueChangeOn(s, attributeName))
|
return PredictValueChange(loaded, attribute);
|
||||||
.OrderBy(s => s.Name, StringComparer.Ordinal)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// Root: the write itself (Tags lane), then each triggered script predicted as a site script.
|
if (ScriptTriggerAttribute.IsWriteKind(script.TriggerType))
|
||||||
var nodes = new List<PredictedNode>
|
|
||||||
{
|
{
|
||||||
new("n1", FlowStepKind.AttributeWrite, FlowLane.Tags, $"{instance.UniqueName}.{attributeName}", false,
|
// A write-kind trigger whose configuration names no attribute: nothing to write, so
|
||||||
triggered.Count == 0 ? "no value-change script on this attribute" : null),
|
// the run cannot be predicted past the script itself.
|
||||||
};
|
return new PredictedFlow(
|
||||||
var edges = new List<PredictedEdge>();
|
[new PredictedNode("n1", FlowStepKind.ScriptRun, FlowLane.Site, title, false, "trigger names no attribute")],
|
||||||
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] });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
return new PredictedFlow(nodes, edges);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,10 +239,22 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <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);
|
// The server re-resolves the script and never trusts a client-supplied trigger kind
|
||||||
return SendAndBuildAsync(command, ValueChangeAskTimeoutFor(command.WaitSeconds), cancellationToken);
|
// (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/>
|
/// <inheritdoc/>
|
||||||
@@ -366,38 +407,94 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True when the script's trigger is a value-change on <paramref name="attributeName"/> (case-insensitive).</summary>
|
/// <summary>
|
||||||
private static bool IsValueChangeOn(TemplateScript script, string attributeName)
|
/// 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)
|
if (_scriptCache.TryGetValue(instanceId, out var cached))
|
||||||
&& !string.Equals(script.TriggerType, "ValueChange", StringComparison.OrdinalIgnoreCase))
|
return cached;
|
||||||
return false;
|
|
||||||
|
|
||||||
var cfg = script.TriggerConfiguration;
|
var instance = await _templates.GetInstanceByIdAsync(instanceId, ct);
|
||||||
if (string.IsNullOrWhiteSpace(cfg))
|
if (instance == null)
|
||||||
return false;
|
return null;
|
||||||
|
|
||||||
try
|
var flattened = await _pipeline.FlattenAndValidateAsync(instance.Id, ct, validateScripts: false);
|
||||||
|
if (flattened.IsFailure)
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(cfg);
|
_logger.LogDebug("Flow Debugger could not flatten instance {InstanceId}: {Error}", instanceId, flattened.Error);
|
||||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
return null;
|
||||||
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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
private async Task<FlowDebugOutcome> SendAndBuildAsync(object command, TimeSpan timeout, CancellationToken ct)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
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);
|
public sealed record FlowOutboundActionResult(bool Success, string Message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// CentralUI facade over the Flow Debugger (design 2026-09-05 §5.5/§7.1): static shape
|
/// One script of an instance as the Flow Debugger's script picker shows it (design 2026-09-08 §5):
|
||||||
/// prediction from script syntax, and the three management commands (Live run of a method,
|
/// canonical name, trigger kind, the trigger's attribute for write kinds, the effective parameter
|
||||||
/// Live run of a value change, Replay) dispatched to the per-node <c>ManagementActor</c>
|
/// 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
|
/// 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
|
/// <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>).
|
/// 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>
|
/// <returns>The predicted flow; <see cref="PredictedFlow.Empty"/> when the method does not exist.</returns>
|
||||||
Task<PredictedFlow> PredictMethodAsync(int apiMethodId, CancellationToken cancellationToken = default);
|
Task<PredictedFlow> PredictMethodAsync(int apiMethodId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist.</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>
|
|
||||||
/// <param name="instanceId">The instance id.</param>
|
/// <param name="instanceId">The instance id.</param>
|
||||||
/// <param name="attributeName">Canonical attribute name.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</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>
|
/// <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>
|
/// <param name="apiMethodId">The method id.</param>
|
||||||
@@ -65,12 +90,13 @@ public interface IFlowDebugService
|
|||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
Task<FlowDebugOutcome> RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default);
|
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="instanceId">The instance id.</param>
|
||||||
/// <param name="attributeName">Canonical attribute name.</param>
|
/// <param name="scriptName">Canonical script name (case-insensitive).</param>
|
||||||
/// <param name="value">Value in canonical string form.</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>
|
/// <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>
|
/// <summary>Rebuilds a past flow from the central audit store.</summary>
|
||||||
/// <param name="executionId">Any execution id in the chain.</param>
|
/// <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.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
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.Communication;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||||
using ZB.MOM.WW.ScadaBridge.Security;
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
@@ -27,8 +30,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for <see cref="FlowDebugService"/> (Flow Debugger design 2026-09-05 §5.5/§7.1):
|
/// 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
|
/// the management-command bridge (holder null, error mapping, success → graph), the
|
||||||
/// prediction resolver wiring over substituted repositories, and the Retry/Discard relay
|
/// prediction resolver wiring over substituted repositories, the script entry (design
|
||||||
/// for a parked outbound node (follow-ups plan 2026-09-06 D4).
|
/// 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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <see cref="CommunicationService"/> is a concrete class with non-virtual methods, so the
|
/// <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 ActorSystem _system = ActorSystem.Create("flow-debug-service-tests");
|
||||||
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
|
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
|
||||||
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
|
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
|
||||||
|
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
|
||||||
private readonly List<ManagementEnvelope> _received = new();
|
private readonly List<ManagementEnvelope> _received = new();
|
||||||
|
|
||||||
// Relay probes: everything the scripted Site Call Audit / Notification Outbox actors receive.
|
// 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.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))));
|
||||||
comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(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>
|
/// <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]
|
[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 svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 1)));
|
||||||
var exec = Guid.NewGuid();
|
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);
|
var rp = await svc.ReplayAsync(exec);
|
||||||
|
|
||||||
Assert.True(vc.Success);
|
Assert.True(vc.Success);
|
||||||
Assert.True(rp.Success);
|
Assert.True(rp.Success);
|
||||||
Assert.Equal(2, _received.Count);
|
Assert.Equal(2, _received.Count);
|
||||||
var vcCmd = Assert.IsType<FlowDebugRunValueChangeCommand>(_received[0].Command);
|
var vcCmd = Assert.IsType<FlowDebugRunScriptCommand>(_received[0].Command);
|
||||||
Assert.Equal((7, "Speed", "42"), (vcCmd.InstanceId, vcCmd.AttributeName, vcCmd.Value));
|
Assert.Equal((7, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value));
|
||||||
var rpCmd = Assert.IsType<FlowDebugReplayCommand>(_received[1].Command);
|
var rpCmd = Assert.IsType<FlowDebugReplayCommand>(_received[1].Command);
|
||||||
Assert.Equal(exec, rpCmd.ExecutionId);
|
Assert.Equal(exec, rpCmd.ExecutionId);
|
||||||
}
|
}
|
||||||
@@ -519,22 +540,36 @@ public sealed class FlowDebugServiceTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PredictValueChange_RootsAtTheWrite_AndAppendsEachTriggeredScript()
|
public async Task GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName()
|
||||||
{
|
{
|
||||||
_templates.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
|
WireFlattened(9, "Sim01",
|
||||||
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
|
new ResolvedScript { CanonicalName = "Manual", Code = "return 1;", TriggerType = "Call", ParameterDefinitions = "{\"type\":\"object\"}", ExecutionTimeoutSeconds = 12 },
|
||||||
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
new ResolvedScript { CanonicalName = "OnFlag", Code = "x", TriggerType = "valuechange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" },
|
||||||
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
|
new ResolvedScript { CanonicalName = "Mod.Tick", Code = "x", TriggerType = "Interval", TriggerConfiguration = "{\"intervalMs\":1000}" });
|
||||||
[
|
|
||||||
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 });
|
|
||||||
|
|
||||||
var svc = CreateService(reply: null);
|
var options = await CreateService(reply: null).GetInstanceScriptsAsync(9);
|
||||||
var flow = await svc.PredictValueChangeAsync(9, "Flag");
|
|
||||||
|
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(
|
Assert.Equal(
|
||||||
[
|
[
|
||||||
@@ -542,25 +577,43 @@ public sealed class FlowDebugServiceTests : IDisposable
|
|||||||
(FlowStepKind.ScriptRun, "Sim01.OnFlag"),
|
(FlowStepKind.ScriptRun, "Sim01.OnFlag"),
|
||||||
(FlowStepKind.AttributeWrite, "Ack"),
|
(FlowStepKind.AttributeWrite, "Ack"),
|
||||||
], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList());
|
], 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]
|
[Fact]
|
||||||
public async Task PredictValueChange_NoTriggeredScript_NotesIt()
|
public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript()
|
||||||
{
|
{
|
||||||
_templates.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
|
WireFlattened(9, "Sim01",
|
||||||
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
|
new ResolvedScript { CanonicalName = "Manual", Code = """Attributes["Ack"] = true;""", TriggerType = "Call" });
|
||||||
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>([]));
|
|
||||||
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>()).Returns(new Template("Base") { Id = 10 });
|
|
||||||
|
|
||||||
var svc = CreateService(reply: null);
|
var flow = await CreateService(reply: null).PredictScriptAsync(9, "Manual");
|
||||||
var flow = await svc.PredictValueChangeAsync(9, "Flag");
|
|
||||||
|
|
||||||
var root = Assert.Single(flow.Nodes);
|
Assert.Equal(FlowStepKind.RouteCall, flow.Nodes[0].Kind);
|
||||||
Assert.Contains("no value-change script", root.Note);
|
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
|
private sealed class StubAuth : AuthenticationStateProvider
|
||||||
|
|||||||
Reference in New Issue
Block a user