feat(central-ui): IFlowDebugService script entry — GetInstanceScripts / PredictScript / RunScript over the flattened configuration
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
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.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)
|
||||
|
||||
Reference in New Issue
Block a user