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:
Joseph Doherty
2026-09-08 01:14:40 -04:00
parent 3d5759613f
commit 5b185c93ff
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>