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

195 lines
13 KiB
Markdown

# 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)
```csharp
/// 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):
```csharp
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.