feat(management): FlowDebugRunner.RunScriptAsync — write-kind vs direct traced call by trigger kind (flow debugger script entry)
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -12,6 +12,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
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.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
using ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
@@ -39,6 +41,18 @@ public sealed class FlowDebugRunner
|
||||
/// <summary>Error code when the method / instance / site does not exist.</summary>
|
||||
public const string NotFoundCode = "NOT_FOUND";
|
||||
|
||||
/// <summary>Error code when a direct script call (design 2026-09-08 §4.4) fails at the site.</summary>
|
||||
public const string CallFailedCode = "CALL_FAILED";
|
||||
|
||||
/// <summary>Error code when a Live run exceeds the method's / script's timeout plus <see cref="RunGrace"/>.</summary>
|
||||
public const string TimeoutCode = "TIMEOUT";
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for a direct script call whose script declares none — mirrors
|
||||
/// <c>SiteRuntimeOptions.ScriptExecutionTimeoutSeconds</c>'s default (the runner has no site options).
|
||||
/// </summary>
|
||||
public const int DefaultScriptTimeoutSeconds = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Arch-review F2: <see cref="InboundScriptExecutor"/>'s code for a routed call refused
|
||||
/// because the target site is outside the operator's site scope. InboundAPI cannot
|
||||
@@ -145,7 +159,7 @@ public sealed class FlowDebugRunner
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
result = new InboundScriptResult(false, null,
|
||||
$"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace", "TIMEOUT");
|
||||
$"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace", TimeoutCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +232,127 @@ public sealed class FlowDebugRunner
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live run of a site script by name (design 2026-09-08 §4): resolves the script's effective
|
||||
/// definition from the flattened instance configuration and branches on its trigger kind —
|
||||
/// write kinds go through <see cref="WriteAndPollAsync"/>, every other kind is a traced
|
||||
/// <see cref="IInstanceRouter.RouteToCallAsync"/> with one operator-attributed
|
||||
/// <see cref="AuditKind.RouteCall"/> root row. Calling <see cref="IInstanceRouter"/> directly
|
||||
/// rather than through <see cref="RouteHelper"/> is deliberate: the helper emits its own
|
||||
/// always-on RouteCall row with no operator actor, which would give the graph two roots.
|
||||
/// </summary>
|
||||
public async Task<FlowDebugRunResult> RunScriptAsync(
|
||||
IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sp);
|
||||
ArgumentNullException.ThrowIfNull(cmd);
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
if (string.IsNullOrWhiteSpace(cmd.ScriptName))
|
||||
throw new ManagementCommandException("ScriptName is required.", ValidationFailedCode);
|
||||
EnsureActiveNode(sp);
|
||||
|
||||
var instance = await sp.GetRequiredService<ITemplateEngineRepository>()
|
||||
.GetInstanceByIdAsync(cmd.InstanceId, ct).ConfigureAwait(false)
|
||||
?? throw new ManagementCommandException($"Instance with ID {cmd.InstanceId} not found.", NotFoundCode);
|
||||
EnforceSiteScope(user, instance.SiteId);
|
||||
var site = await sp.GetRequiredService<ISiteRepository>()
|
||||
.GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false)
|
||||
?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode);
|
||||
|
||||
// Design §2: the flattened configuration is the one place canonical names, effective triggers and
|
||||
// effective parameter definitions all live (composition + inheritance + instance overrides applied).
|
||||
var flattened = await sp.GetRequiredService<IFlatteningPipeline>()
|
||||
.FlattenAndValidateAsync(instance.Id, ct, validateScripts: false).ConfigureAwait(false);
|
||||
if (flattened.IsFailure)
|
||||
throw new ManagementCommandException($"Could not resolve instance '{instance.UniqueName}': {flattened.Error}", NotFoundCode);
|
||||
var script = flattened.Value.Configuration.Scripts
|
||||
.FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.ScriptName, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ManagementCommandException($"Script '{cmd.ScriptName}' is not defined on instance '{instance.UniqueName}'.", NotFoundCode);
|
||||
|
||||
if (ScriptTriggerAttribute.IsWriteKind(script.TriggerType))
|
||||
{
|
||||
if (!ScriptTriggerAttribute.TryRead(script.TriggerType, script.TriggerConfiguration, out var attribute))
|
||||
{
|
||||
throw new ManagementCommandException(
|
||||
$"Script '{script.CanonicalName}' has a {script.TriggerType} trigger with no readable attribute; fix its trigger configuration.",
|
||||
ValidationFailedCode);
|
||||
}
|
||||
|
||||
if (cmd.Value is null)
|
||||
throw new ManagementCommandException($"Value is required to fire '{script.CanonicalName}' (writes '{attribute}').", ValidationFailedCode);
|
||||
return await WriteAndPollAsync(
|
||||
sp, user, instance, site, attribute!, cmd.Value, cmd.WaitSeconds, script.CanonicalName, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var resolveRef = await SchemaRefResolver.BuildAsync(
|
||||
sp.GetService<ISharedSchemaRepository>(), [script.ParameterDefinitions], ct).ConfigureAwait(false);
|
||||
var validation = ParameterValidator.Validate(cmd.Parameters, script.ParameterDefinitions, resolveRef);
|
||||
if (!validation.IsValid)
|
||||
throw new ManagementCommandException(validation.ErrorMessage ?? "Parameter validation failed", ValidationFailedCode);
|
||||
|
||||
var root = Guid.NewGuid();
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var sw = Stopwatch.StartNew();
|
||||
// Review follow-up: the root row is synthesized here, so the copy handed back must be
|
||||
// redacted by this method — WriteCentralAsync's writer only redacts its own private copy.
|
||||
var redactor = sp.GetService<IAuditRedactor>();
|
||||
var collector = new FlowTraceCollector(redactor: redactor);
|
||||
var timeout = TimeSpan.FromSeconds(Math.Max(1, script.ExecutionTimeoutSeconds ?? DefaultScriptTimeoutSeconds));
|
||||
var router = sp.GetRequiredService<IInstanceRouter>();
|
||||
var request = new RouteToCallRequest(
|
||||
Guid.NewGuid().ToString(), instance.UniqueName, script.CanonicalName,
|
||||
validation.Parameters.Count == 0 ? null : validation.Parameters,
|
||||
DateTimeOffset.UtcNow, ParentExecutionId: root, Trace: true);
|
||||
|
||||
RouteToCallResponse response;
|
||||
var timedOut = false;
|
||||
using (var runCts = CancellationTokenSource.CreateLinkedTokenSource(ct))
|
||||
{
|
||||
runCts.CancelAfter(timeout + RunGrace);
|
||||
try
|
||||
{
|
||||
response = await router.RouteToCallAsync(site.SiteIdentifier, request, runCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
timedOut = true;
|
||||
response = new RouteToCallResponse(request.CorrelationId, false, null,
|
||||
$"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace", DateTimeOffset.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var e in response.TraceEvents ?? [])
|
||||
collector.Add(e);
|
||||
var events = collector.Complete();
|
||||
|
||||
var rootRow = ScadaBridgeAuditEventFactory.Create(
|
||||
AuditChannel.ScriptTrace, AuditKind.RouteCall,
|
||||
response.Success ? AuditStatus.Delivered : AuditStatus.Failed,
|
||||
occurredAtUtc: startedAt.UtcDateTime,
|
||||
actor: user.Username,
|
||||
target: $"{instance.UniqueName}.{script.CanonicalName}",
|
||||
sourceNode: sp.GetService<INodeIdentityProvider>()?.NodeName,
|
||||
executionId: root,
|
||||
sourceSiteId: site.SiteIdentifier,
|
||||
sourceInstanceId: instance.UniqueName,
|
||||
durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue),
|
||||
errorMessage: response.ErrorMessage,
|
||||
requestSummary: cmd.Parameters is { } p ? p.GetRawText() : null,
|
||||
responseSummary: response.ReturnValue is null ? null : JsonSerializer.Serialize(response.ReturnValue, ScriptTraceExtra.JsonOptions),
|
||||
extra: new ScriptTraceExtra { Source = SourceTag, ChildExecutionId = response.ChildExecutionId }.ToJson());
|
||||
await WriteCentralAsync(sp, rootRow, ct).ConfigureAwait(false);
|
||||
|
||||
// Arch-review F5: emission order is authoritative — the root row carries central's clock and
|
||||
// the site rows the site's, so re-sorting by OccurredAtUtc lets a skewed site clock float its
|
||||
// steps ahead of the RouteCall that started them.
|
||||
var ordered = new List<AuditEvent>(events.Count + 1) { redactor?.Apply(rootRow) ?? rootRow };
|
||||
ordered.AddRange(events);
|
||||
return new FlowDebugRunResult(
|
||||
root, response.Success, response.ErrorMessage,
|
||||
response.Success ? null : timedOut ? TimeoutCode : CallFailedCode,
|
||||
ordered, Truncated: collector.Truncated, startedAt, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The traced attribute write + spawned-run poll shared by <see cref="RunValueChangeAsync"/> and
|
||||
/// the write-kind branch of the script entry's <c>RunScriptAsync</c> (design 2026-09-08 §4.3). The
|
||||
|
||||
Reference in New Issue
Block a user