diff --git a/docs/plans/2026-09-08-flow-debugger-script-entry-design.md b/docs/plans/2026-09-08-flow-debugger-script-entry-design.md new file mode 100644 index 00000000..27a60c06 --- /dev/null +++ b/docs/plans/2026-09-08-flow-debugger-script-entry-design.md @@ -0,0 +1,194 @@ +# 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> GetInstanceScriptsAsync(int instanceId, CancellationToken ct = default); +Task PredictScriptAsync(int instanceId, string scriptName, CancellationToken ct = default); +Task 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 ``* 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`, 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 --script [--params ''] [--value ] +[--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.