Files
ScadaBridge/docs/plans/2026-09-08-flow-debugger-script-entry-design.md
Joseph Doherty 47535f342c docs(plans): Flow Debugger script entry point — design (site/instance/script; write kinds vs direct call)
Replaces the value-change (site/instance/attribute) entry with a script picker.
ValueChange/Conditional scripts keep the write-and-poll path with the attribute
derived from the trigger config; every other kind is invoked directly through the
traced RouteToCall path with a parameter form from the script's ParameterDefinitions.
New additive FlowDebugRunScriptCommand; run-value-change stays as the CLI escape hatch.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 00:54:26 -04:00

13 KiB

Flow Debugger — Script Entry Point (design)

Date: 2026-09-08 Status: approved (brainstorm 2026-09-08), implementation plan follows Supersedes: the Value-change trigger entry of 2026-09-05-flow-debugger-design.md §4/§5.3 as the page's second entry point. §5.3's trace arm and the FlowDebugRunValueChangeCommand wire record are unchanged.

1. What changes

The Flow Debugger's second entry point selects site → instance → script instead of site → instance → attribute.

  • A script whose trigger is ValueChange or Conditional names one monitored attribute in its trigger configuration. The debugger writes that attribute (the operator supplies only the new value) and waits for the run the change spawns — exactly today's value-change behaviour, with the attribute derived from the script rather than picked.
  • Every other script (Call, Interval, Expression, or no trigger) is invoked directly through the traced routed-call path that Route.To(instance).Call(script, args) already uses. The Inputs card shows a parameter form generated from the script's own ParameterDefinitions, the same way the inbound-API method entry does.

The decision on trigger kinds was taken in the brainstorm: Conditional joins ValueChange on the write side because both name a single attribute; Expression joins the call side because it names none, and a direct call is the only honest single-step run of it. The attribute-level CLI command flow-debug run-value-change stays as the escape hatch for firing an Expression trigger through a real attribute write.

2. Script resolution

Both the page and the runner resolve an instance's scripts through the existing IFlatteningPipeline.FlattenAndValidateAsync(instanceId, validateScripts: false) (DeploymentManager, already referenced by both CentralUI and ManagementService — Create Instance's binding preview uses the same pipeline). FlattenedConfiguration.Scripts gives ResolvedScript rows carrying the canonical name (so composed-module scripts appear as Module.Script, the name the site's InstanceActor keys on), the effective TriggerType/TriggerConfiguration, and the effective ParameterDefinitions/ExecutionTimeoutSeconds after inheritance and instance overrides.

The runner resolves the script itself and never trusts the client's claim of trigger kind: the branch below is taken on the server-side ResolvedScript.

A new pure Commons helper, ScriptTriggerAttribute.TryRead(triggerType, configJson, out attribute) (Commons/Types/Scripts/), returns the monitored attribute for ValueChange/Conditional (keys attributeName then attribute, matching ScriptTriggerConfigCodec and SiteRuntime's TriggerRouting.TryReadAttributeName) and false for every other kind. FlowDebugService.IsValueChangeOn is rewritten on top of it. SiteRuntime's private copy is left alone — it also serves alarms and fail-open routing, a different contract.

Design vs deployed. The site runs the last deployed snapshot. A script that exists in design but was never deployed fails at the site with its existing Script 'X' not found on instance 'Y' error, which the debugger surfaces verbatim; the page does not try to detect staleness (the Instances page already shows it).

3. Wire and command changes (additive)

/// Flow Debugger Live run of a site script by name (design 2026-09-08).
public record FlowDebugRunScriptCommand(
    int InstanceId,
    string ScriptName,          // canonical name from the flattened configuration
    JsonElement? Parameters,    // call kinds: flat JSON object validated against the script's ParameterDefinitions
    string? Value,              // write kinds: the new value for the trigger's attribute
    int WaitSeconds = 45);      // write kinds: NO_TRIGGER_WITHIN_WINDOW window
  • Registered in ManagementEndpoints' command type list beside the other two run commands.
  • ManagementActor.GetRequiredRoles: DeployerOnly, same as FlowDebugRunMethodCommand / FlowDebugRunValueChangeCommand. Replay roles unchanged.
  • FlowDebugRunValueChangeCommand is kept (CLI run-value-change, additive-only contracts). FlowDebugRunResult is unchanged.
  • No proto change: the direct call rides the existing RouteToCallRequest.Trace / RouteToCallResponse.TraceEvents fields.

4. Runner (FlowDebugRunner.RunScriptAsync)

  1. EnsureActiveNode; load instance (NOT_FOUND); EnforceSiteScope(user, instance.SiteId); load site.
  2. Flatten; find ResolvedScript by canonical name, case-insensitive (NOT_FOUND with the message Script 'X' is not defined on instance 'Y'.).
  3. Write kinds (ScriptTriggerAttribute.TryRead succeeds): Value is required (VALIDATION_FAILED); call the shared private helper WriteAndPollAsync(sp, user, instance, site, attribute, value, waitSeconds, ct) — the body of today's RunValueChangeAsync from the traced RouteToSetAttributesAsync onward, extracted unchanged. RunValueChangeAsync becomes a thin caller of the same helper so both commands share one code path and one set of tests.
  4. Call kinds: ParameterValidator.Validate(cmd.Parameters, script.ParameterDefinitions, resolveRef) with SchemaRefResolver.BuildAsync over the shared-schema repository, exactly as RunMethodAsync (VALIDATION_FAILED on failure; null definitions + null parameters is valid and sends no parameters). Then:
    • root = Guid.NewGuid(); timeout = script.ExecutionTimeoutSeconds ?? SiteRuntime default 30 s (the runner has no site options — it uses a DefaultScriptTimeoutSeconds = 30 constant, documented as mirroring SiteRuntimeOptions.ScriptExecutionTimeoutSeconds).
    • IInstanceRouter.RouteToCallAsync(site.SiteIdentifier, new RouteToCallRequest(correlationId, instance.UniqueName, script.CanonicalName, validation.Parameters, now, ParentExecutionId: root, Trace: true), linked token cancelled after timeout + RunGrace). A cancellation is reported as TIMEOUT like the method path.
    • Synthesize the root row: AuditChannel.ScriptTrace / AuditKind.RouteCall, status by response.Success, actor = operator, target = {instance}.{script}, executionId = root, sourceSiteId/sourceInstanceId, durationMs, requestSummary = the parameters JSON, responseSummary = the return value serialized with ScriptTraceExtra.JsonOptions, errorMessage, extra = ScriptTraceExtra { Source = "flow-debugger" }. Written through WriteCentralAsync; the returned copy is redactor.Apply'd (review follow-up rule).
    • The site's response.TraceEvents are pushed through a FlowTraceCollector(redactor:) so the inline copy is redacted and capped (arch-review F1), then Complete()d.
    • Result: [redactedRoot] + collector events, in emission order (F5: no re-sort across clocks), Completed = response.Success, ErrorCode = response.Success ? null : "CALL_FAILED", Truncated = collector.Truncated.

Not using RouteHelper here is deliberate: it would emit its own always-on RouteCall row with no operator actor, giving the graph two roots. Calling IInstanceRouter directly mirrors how the write path calls RouteToSetAttributesAsync and keeps one synthesized, operator-attributed root.

FlowGraphBuilder already treats the first row of the root execution as the chain root; a RouteCall-kind root with ScriptRun children parented to it renders Central → Site exactly like the routed leg of a method run. The plan pins this with a builder test.

5. Central UI

IFlowDebugService (UI-internal, not a wire contract):

sealed record FlowScriptOption(string CanonicalName, string? TriggerType, string? TriggerAttribute,
                               string? ParameterDefinitions, bool IsWriteKind);
Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken ct = default);
Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken ct = default);
Task<FlowDebugOutcome> RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters,
                                      string? value, CancellationToken ct = default);

PredictValueChangeAsync / RunValueChangeAsync are removed from the UI service (their only caller was the page); PredictScriptAsync delegates to the value-change prediction for write kinds (with the trigger's attribute) and, for call kinds, builds a RouteCall root on the Central lane ({instance}.{script}) followed by FlowShapePredictor.PredictSiteScript. RunScriptAsync's Ask timeout is RunAskTimeoutFor(script.ExecutionTimeoutSeconds ?? 30) for call kinds and ValueChangeAskTimeoutFor(45) for write kinds. The script list is cached per instance for the life of the scoped service (one flatten per selection).

Page (FlowDebugger.razor(.cs)).

  • Entry button label: Site script (data-test="flow-entry-script", replacing flow-entry-value-change; EntryKind.ValueChange → EntryKind.Script).
  • Pickers: Site, Instance, Script (data-test="flow-script-select"); each option renders Name (ValueChange: Level) / (Conditional: Level) / (Call) / (Interval) / (Expression) / (no trigger).
  • Inputs card: write kinds → the existing single field, labelled New value for <attribute> with the attribute read-only (flow-input-value); call kinds → the shared ParameterValueForm bound to the option's ParameterDefinitions (data-test="flow-script-params"), or the note This script takes no parameters when the definitions are empty. ParameterValueForm hands back Dictionary<string, object?>, serialized to a JsonElement for the command.
  • CanRun: script chosen and (write kind → any value entered; call kind → form has no parse errors).
  • Query parameters: ?instanceId= and ?script=; ?attribute= is removed (nothing outside the page links with it). Inputs persistence keys become flow-debugger.script.{instanceId}.{script}.
  • FlowStepDetail's SiteDbId/InstanceDbId pass-through keys on EntryKind.Script.
  • The status strip, refresh loop, Retry/Discard and Replay are untouched.

6. CLI

flow-debug run-script --instance-id <id> --script <canonical name> [--params '<json>'] [--value <v>] [--wait-seconds 45] → FlowDebugRunScriptCommand, same client timeout rule as run-method (--timeout-seconds, default 120) because the server may run a call script up to its execution timeout + grace. run-value-change stays, its help text noting it is the attribute-level path (fires Expression triggers) and that run-script is the script-level one.

7. Security and audit

Unchanged posture: Deployer-only, active node only, site scope enforced from the instance before any site traffic, the root row is written centrally against the operator's name whether the call succeeded or not, inline trace copies are redacted and capped. A direct call of a script is a real run with real side effects — the page's existing Live-run wording already says so.

8. Tests

  • FlowDebugRunnerTests: write-kind script → RouteToSetAttributesAsync with the trigger's attribute; call-kind script → RouteToCallAsync with Trace: true, parent = root, parameters validated; unknown script → NOT_FOUND; missing Value on a write kind → VALIDATION_FAILED; bad parameters → VALIDATION_FAILED; out-of-scope site refused; root row redacted; CALL_FAILED on a site failure; TIMEOUT on cancellation. Existing RunValueChange_* tests keep passing through the shared helper.
  • ScriptTriggerAttributeTests (Commons): both key spellings, both kinds, case-insensitive type, malformed JSON → false.
  • FlowGraphBuilderTests: a RouteCall root with a ScriptRun child builds Central → Site.
  • RequiredRoleMatrixTests + ManagementEndpoints registration test for the new command.
  • FlowDebugServiceTests: script list from a flattened configuration, write/call classification, prediction shapes, Ask timeout selection.
  • FlowDebuggerPageTests (bUnit): script picker renders options with trigger annotations; Inputs card switches between value field and ParameterValueForm; CanRun rules; ?script= deep link.
  • FlowDebugCommandsTests (CLI): run-script parsing incl. --params JSON errors.
  • Playwright FlowDebuggerTests: Live run of the rig's OutboundDemo Call script on DelmiaSim01.
  • docker/verify-delmia-notifier.sh step H gains a run-script leg against OutboundDemo.

9. Documents to update

2026-09-05-flow-debugger-design.md §4 (pointer to this doc), Component-CentralUI.md (Flow Debugger entry points), Component-InboundAPI.md (FlowDebug commands table), src/ZB.MOM.WW.ScadaBridge.CLI/README.md, CLAUDE.md Flow Debugger bullet, and the umbrella index row's Flow Debugger sentence.