Compare commits

...

74 Commits

Author SHA1 Message Date
Joseph Doherty f69333776a Merge feat/instance-picker: area-tree instance picker combobox for Flow Debugger + Debug View
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 10:06:51 -04:00
Joseph Doherty c27b22ead3 chore(plans): instance picker — all tasks complete, gate evidence
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 10:04:45 -04:00
Joseph Doherty 50eca87e99 Merge branch 'worktree-agent-a327a30cdb2e454ff' into feat/instance-picker 2026-09-08 10:01:47 -04:00
Joseph Doherty 52726cf30d fix(central-ui): Debug View reconnect guards the stored instance; drop select-era sort; deep-link-in-area test
Three final-review findings on the two picker pages:

1. DebugView's localStorage auto-reconnect restored the stored instance id with
   no membership check, where the ?instanceId= path has one — a stored instance
   that is no longer Enabled (or was deleted, or moved sites) is filtered out of
   the list the picker is handed, so the field rendered blank while the page held
   a non-zero id and auto-connected to it anyway. The guard now mirrors the
   deep-link path: forget the stored instance id, tell the operator, and leave the
   site selected and loaded so they can pick again.
2. FlowDebugger's _instances kept an OrderBy(UniqueName) from the flat <select>
   era; InstanceTreeBuilder sorts areas and instances itself.
3. The deep-link test is joined by one where the instance sits two areas deep, so
   the field text is pinned to resolve from the flat list with the panel never
   opened.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 10:01:22 -04:00
Joseph Doherty 4df0cec8d8 Merge branch 'worktree-agent-a710cd8085a729732' into feat/instance-picker 2026-09-08 10:00:40 -04:00
Joseph Doherty a33a2f4456 fix(central-ui): InstancePicker — keyboard activation selects/toggles through TreeView selection; aria-label/InputId; aria-controls only while open
ArrowDown moved focus into the tree, but the picker passed no Selectable /
SelectedKeyChanged, so Enter/Space on a treeitem only moved the roving tabindex —
the pick lived on the label span's @onclick, i.e. mouse only. The tree now runs in
Selectable mode and BOTH gestures arrive at one handler (OnTreeKeyActivatedAsync)
through TreeView's SelectedKeyChanged: an `i:{id}` key selects that instance, an
`a:{id}` key toggles the branch. The label's own @onclick is gone — the click
already bubbles into TreeView's content span, so a second handler there would fire
the callback twice. SelectedKey stays unbound (a combobox shows its pick in the
field, not as a row highlight, and binding it would arm TreeView's
selected-key-not-in-tree reset on every render while the pick is filtered out);
SelectedCssClass is emptied to match.

a11y: the field gains an accessible name via a new AriaLabel parameter (default
"Instance") and an optional InputId so a page can point a <label for> at it;
aria-controls is rendered only while the panel exists, instead of always pointing
at an id that is absent when closed.

Also drops the unused @using of Commons.Entities.Instances from the .razor.

Tests: keyboard activation on an instance row and on an area row, a
one-selection-per-gesture guard across mouse and keyboard, and the labelling
assertions. CentralUI suite 1331 passed / 0 failed; build 0 warnings.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 10:00:17 -04:00
Joseph Doherty 82f6fb949e fix(central-ui): keep the instance picker disabled until the site's instances have loaded (typing raced the load and was wiped)
Both Flow Debugger and Debug View enable the shared InstancePicker as soon as
_selectedSiteId lands, but Blazor renders at the site-change handler's first
await — at which point the picker is still holding the PREVIOUS site's Areas /
Instances lists. Typing into the field in that window (an operator, or
Playwright's FillAsync, which waits only for "enabled") was silently lost: the
picker compares both lists by reference, so the freshly-assigned lists read as
"the site changed" and it clears the field text and closes the panel.

Both pages now hold an _instancesLoading flag across the whole load — set before
the first await, cleared in a finally so a fault or a site-scope refusal cannot
leave the picker disabled forever — and fold it into the picker's Disabled
expression. InstancePicker itself is unchanged.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:58:44 -04:00
Joseph Doherty b19da25d32 docs(plans): instance picker design — keyboard activation via TreeView selection, tab-out does not close, explicit handlers, loading gate
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:56:07 -04:00
Joseph Doherty d3264ec1b1 Merge branch 'worktree-agent-a24fb696f5eee22f2' into feat/instance-picker 2026-09-08 09:49:19 -04:00
Joseph Doherty 8047de6903 fix(central-ui): Flow Debugger picker — site change clears the pick (test); wider column, shorter placeholder
Pins the site-change behaviour the picker's reference contract exists for: after
picking an instance, switching the site select empties both the page's
_selectedInstanceId and the picker's field text (the new site hands it a
different instance list), and leaves the script select on its placeholder.

Layout: the Instance column goes col-md-2 → col-md-3 so a full instance name
fits — the script-entry branch still sums to 8 of 12 (site 2 + instance 3 +
script 3), and Method never renders beside them — with the placeholder
shortened to "Search instances…" for the narrower field.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:49:05 -04:00
Joseph Doherty f32f08a4c7 Merge branch 'worktree-agent-a9d87141c744e7733' into feat/instance-picker 2026-09-08 09:48:25 -04:00
Joseph Doherty a0292d3063 fix(central-ui): Debug View picker — disabled-while-connected test; drop no-op handler; static label
Task 5 review follow-ups:

- New DebugViewInstancePickerTests.WhileConnected_ThePickerIsDisabled pins that
  a live connection locks the picker: both the input and the chevron carry
  `disabled`. It marks the page connected by reflection, the way the other
  DebugView page tests do (DebugViewAlarmTableTests) — driving the real gRPC
  stream is not available to a bUnit render.
- OnInstanceSelectionChanged() was already a no-op and its comment described the
  @bind that the picker replaced; deleted, and OnInstancePicked now just records
  the id (XML-documented).
- The ItemLabel lambda is hoisted to a private static InstanceLabel(Instance),
  passed as a method group.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:48:13 -04:00
Joseph Doherty 2ec0e63671 Merge branch 'worktree-agent-acdd9dc794506dee9' into feat/instance-picker 2026-09-08 09:45:55 -04:00
Joseph Doherty dd751be917 test(playwright): Flow Debugger instance row matched through the data-test contract
The instance row locator used Locator.GetByText scoped to the panel, which
bypasses the [data-test='flow-instance-row'] contract InstancePicker rows
are documented to carry. Match through the data-test attribute instead,
with an exact-text regex since the test only knows the instance name (not
its id, unlike Debug View's tests).

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:45:41 -04:00
Joseph Doherty a9db239075 Merge branch 'worktree-agent-af2a6023106279956' into feat/instance-picker 2026-09-08 09:45:38 -04:00
Joseph Doherty cccc6e82d2 Merge branch 'worktree-agent-a24fb696f5eee22f2' into feat/instance-picker 2026-09-08 09:45:25 -04:00
Joseph Doherty dfdf480fa1 test(central-ui): InstancePicker — closed-panel ArrowDown, Escape no-op; dispatcher-ordering and overflow notes
ArrowDown on a picker that has never been opened pins the deferred path where
_tree is still null when the keydown is handled: the panel opens in that render
pass and the roving tab stop lands on the first row in the next one. Escape on a
closed picker is pinned as a no-op.

Two notes, no behaviour change: FirstVisibleInstance records WHY it may trust
TreeView.IsExpanded (Blazor Server's single renderer sync context runs the render
plus the pending ExpandAll/CollapseAll before the next keydown is dispatched — an
await that genuinely yields inside OnAfterRenderAsync would break that), and the
stylesheet warns integrators that the absolutely-positioned panel is clipped by
any ancestor with overflow: hidden/auto.

The ArrowDown focus interop assertion is kept — it was stable across repeated
runs — with a comment marking the tabindex as the behavioural check.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:45:15 -04:00
Joseph Doherty 0f97317933 feat(central-ui): Flow Debugger picks the instance through InstancePicker
The script entry's flat instance <select> is replaced by the shared
InstancePicker combobox (design 2026-09-08 §3): the site's areas load
alongside its instances in LoadInstancesAsync and are held in fields, so the
picker sees one stable list reference per site and only closes its panel on a
real site change. Picking a row raises the id, which OnInstancePickedAsync
applies before running the unchanged OnInstanceChangedAsync — the deep link,
the localStorage restore and the site-scope gate are untouched.

Tests: the five instance-select .Change("7") call sites go through a
PickInstance helper (open the panel, click the data-instance-id row), plus a
new test that areas open collapsed and only reveal the instance once both
levels are expanded, and one that ?instanceId= shows the instance name in the
picker field.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:45:07 -04:00
Joseph Doherty 740f5fa6da Merge branch 'worktree-agent-a9d87141c744e7733' into feat/instance-picker 2026-09-08 09:44:43 -04:00
Joseph Doherty 1d622f536d feat(central-ui): Debug View picks the instance through InstancePicker
The flat instance <select> on Debug View is replaced by the shared
InstancePicker area-tree combobox (design 2026-09-08 §3): the page now loads
the site's areas alongside its Enabled instances in LoadInstancesForSite and
hands both lists to the picker with ItemLabel = "UniqueName (State)",
Disabled="@_connected" and DataTestPrefix="debug-instance".

Both lists are now REPLACED rather than cleared in place wherever the site
changes (LoadInstancesForSite, StartFresh) — the picker compares Areas and
Instances by reference and a new object is what tells it the site changed.
The ?siteId=/?instanceId= deep link and the localStorage reconnect keep
setting _selectedInstanceId directly; the picker reads it on parameter set,
so both paths are unchanged.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:44:26 -04:00
Joseph Doherty 45daf31194 Merge branch 'worktree-agent-acdd9dc794506dee9' into feat/instance-picker 2026-09-08 09:43:51 -04:00
Joseph Doherty 07c5d53f39 test(playwright): drive the instance picker on Flow Debugger and Debug View
Flow Debugger's script-entry instance selection and Debug View's instance
selection both go through the shared InstancePicker combobox now instead of
a flat <select>. Update the tests to type into the {prefix}-input, wait for
the matching {prefix}-row, and click it; keep the <select>-polling helper
for the site select, which is unchanged.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:43:37 -04:00
Joseph Doherty a071433096 docs: instance picker on Flow Debugger + Debug View
Note the shared InstancePicker combobox (2026-09-08 design) replacing
the flat instance <select> on both pages, in Component-CentralUI.md's
Flow Debugger and Debug View sections and the CLAUDE.md Flow Debugger
bullet.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:43:04 -04:00
Joseph Doherty 1c665d3acb chore(plans): instance picker tasks 2-3 complete
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:40:12 -04:00
Joseph Doherty 3c3a7cbbc1 fix(central-ui): InstancePicker — field above the backdrop; site change always closes; reference contract documented; tests
Three Task 2 review findings:

1. The outside-click backdrop is a fixed full-viewport layer at z-index 1040,
   but the input-group was unpositioned — so once the panel was open a click on
   the field or the chevron hit the backdrop and closed the panel instead of
   letting the operator keep typing. The field now carries .ip-field
   (position:relative, z-index 1042), stacking backdrop < panel < field. Pinned
   in bUnit by the class (the z-index itself is scoped CSS, verified visually).

2. OnParametersSet closes the panel on ANY lists change, not only when the
   selection disappeared: an open panel is showing the previous site's tree,
   which is wrong even when the selected instance happens to exist in both.
   The reference-equality contract on Areas/Instances is now stated on both
   parameters — pass a stable reference, replace it only when the set changed.

3. Tests: the chevron closes an open panel; two pickers mint distinct panel ids
   (and matching aria-controls); an unrelated parameter change with the SAME
   Instances reference leaves a half-typed filter and the open panel alone.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:39:46 -04:00
Joseph Doherty 621d6bfd94 feat(central-ui): InstancePicker search filter + keyboard (Enter/Escape/ArrowDown)
Typing in the field is the filter: the forest is rebuilt to the matching
instances plus their ancestor areas and force-expanded so every match is on
screen; clearing the text restores the full forest, collapsed. Both bulk
expansions are deferred to OnAfterRenderAsync, because the TreeView only exists
once the panel has rendered.

Enter picks the first instance the operator can actually SEE — document order,
descending into a branch only when the tree reports it expanded — so with areas
collapsed it picks the first root instance, and with a filter (every branch
expanded) it degrades to plain document order. Escape closes and restores the
field text from the current selection; ArrowDown hands keyboard navigation to
the tree via a new additive TreeView.FocusFirstVisibleAsync(), which reuses the
existing roving-tabindex _focusNeedsApply path rather than adding a second
focus mechanism.

No preventDefault: Blazor's flag is all-or-nothing per element, and neither
default is harmful (Enter in a text input outside a form does nothing,
ArrowDown just moves the caret) while suppressing Tab would trap the control.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:37:44 -04:00
Joseph Doherty 05e4f3ca0f Merge branch 'worktree-agent-a6cee26f4fe0abdd1' into feat/instance-picker 2026-09-08 09:29:45 -04:00
Joseph Doherty ba1ac38333 feat(central-ui): InstancePicker — combobox over the site's area tree
A shared Bootstrap-only combobox that replaces the flat instance <select> on
Flow Debugger and Debug View (design docs/plans/2026-09-08-instance-picker-design.md,
plan task 2): a text field over a dropdown panel hosting TreeView across the
InstanceTreeBuilder forest, every area collapsed on open. Clicking an area row
toggles it, clicking an instance row selects it, closes the panel and raises
SelectedInstanceIdChanged. Outside clicks close through a transparent fixed
backdrop — the same pattern TreeView uses for its context menu, so no JavaScript.

A site change (new Areas/Instances references) closes the panel and clears the
field when the selection no longer resolves; a parent-driven SelectedInstanceId
change re-derives the field text while closed. ItemLabel customises row and field
text (Debug View wants a "(State)" suffix) while search still matches UniqueName.

TreeView gains one additive public ToggleExpanded(key) — the chevron's existing
private toggle, exposed so a consumer whose own NodeContent owns the label click
can toggle the branch.

Search filtering and keyboard handling land in task 3; _filter/Rebuild() are in
place for them.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:29:21 -04:00
Joseph Doherty 8f8100c7a8 docs(plans): instance picker — node shape + orphan rules as shipped; task 1 complete
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:26:41 -04:00
Joseph Doherty 8e6b748029 fix(central-ui): InstanceTreeBuilder — orphaned instances surface at root; read-only Children; edge tests
Review follow-ups on f12ba2f9:
- An instance whose AreaId points at an area outside the supplied list was
  silently dropped; it now surfaces at the root, mirroring the orphaned-area
  rule, and stays filterable.
- InstanceTreeNode.Children is an IReadOnlyList over a private backing list,
  composed by the builder through an internal AddChild — the record no longer
  hands callers a mutable list.
- Extra tests: unknown-area instance (root + filterable), 3-level nesting,
  a ParentAreaId cycle that must neither loop nor throw, and a filter that
  matches instance names only (an area name match alone does not keep it).

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:26:14 -04:00
Joseph Doherty f12ba2f9eb feat(central-ui): InstanceTreeBuilder — area/instance forest for the instance picker
Task 1 of the Instance Picker plan (docs/plans/2026-09-08-instance-picker.md).
Pure, DI-free builder that nests a site's areas by ParentAreaId and hangs each
area's instances beneath it, mirroring Topology's ordering (child areas first,
then that level's instances; OrdinalIgnoreCase sorts) with unparented instances
at the root after the root areas. Orphaned areas become roots, a case-insensitive
filter keeps matching instances plus their ancestors and drops empty branches,
and a ParentAreaId cycle can never recurse forever.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:22:11 -04:00
Joseph Doherty 356b7620f8 docs(plans): Instance Picker implementation plan (8 tasks)
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:18:02 -04:00
Joseph Doherty 861f510427 docs(plans): Instance Picker design — area-tree combobox for Flow Debugger + Debug View instance selection
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:15:57 -04:00
Joseph Doherty 859922ba17 fix(central-ui): Flow Debugger spawn edges — real arrowhead + bolt badge; spawner picked by trigger attribute
The trigger-cascade (Spawn) edge used a lightning-bolt marker IN PLACE of the arrowhead. On the upward
Tags→Site hop the bolt sat on top of the riser and read as a broken arrow (rig screenshot 2026-09-08).
Every connector now ends in an ordinary arrowhead; the bolt is a badge (circle + bolt) centred on the
connector's vertical run (FlowGraphView.SpawnBadgePoint, kept in step with OrthogonalPath/AsyncPath).

Same screenshot, second defect: the cascade spawned by "RecipeDownloadFlag = false" was drawn from the
LAST write of the burst (RecipeProcessedFlag) because all four writes share a millisecond and the
spawner rule was time-only. FlowGraphBuilder now prefers the parent's last AttributeWrite of the attribute
the child's triggerAttribute names (at or before the child, else the last such write); the time/position
rules remain the fallback when the child names no attribute or the parent never wrote it.

Verified live on the rebuilt rig (DelmiaSim01 / DelmiaReceiverSimulator): both spawn edges show an
arrowhead + badge, the second one originates from the RecipeDownloadFlag write. bUnit + builder tests
updated/added; Playwright FlowDebuggerTests 4/4.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:04:39 -04:00
Joseph Doherty cb8c6eb813 Merge feat/flow-debugger-script-entry: Flow Debugger script entry point (site → instance → script)
ValueChange/Conditional scripts are fired by writing the trigger's attribute; every other
kind is a direct traced RouteToCall with a ParameterValueForm from the script's
ParameterDefinitions. New FlowDebugRunScriptCommand (Deployer-only, long-running),
IFlowDebugService script surface over the flattening pipeline, CLI flow-debug run-script,
verify-delmia-notifier step H2, docs. Design: docs/plans/2026-09-08-flow-debugger-script-entry-design.md.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 08:51:51 -04:00
Joseph Doherty 999882f6c1 chore(plans): flow debugger script entry — task ledger complete + gate evidence (rig H/H2/I, Playwright 4/4, browser)
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:47:49 -04:00
Joseph Doherty 7792b76b12 Merge branch 'worktree-agent-a33b0136b18715bde' into feat/flow-debugger-script-entry 2026-09-08 01:42:42 -04:00
Joseph Doherty ca9be68d91 fix(management): FlowDebugRunner — transport faults still write the root row; write-kind completion is script-specific; standby/unreadable-trigger tests
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:42:17 -04:00
Joseph Doherty 671191db37 Merge branch 'worktree-agent-ac243fcc404f04015' into feat/flow-debugger-script-entry 2026-09-08 01:41:31 -04:00
Joseph Doherty 7197c3986f fix(central-ui): FlowDebugService — re-flatten on each instance selection; safe Ask fallback
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:41:14 -04:00
Joseph Doherty 061f2b722b docs: run-script help names the server ceiling; Open in Debug View applies to script entries
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:39:46 -04:00
Joseph Doherty a2bfc79e3a fix(central-ui): Flow Debugger — remount ParameterValueForm per script so stale form state cannot block Run
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:32:29 -04:00
Joseph Doherty d9d8fdde05 Merge branch 'worktree-agent-aa4aca38eb138ea83' into feat/flow-debugger-script-entry 2026-09-08 01:29:38 -04:00
Joseph Doherty 0887c69500 test(rig): flow debugger script entry — Playwright direct-call run + verify-delmia-notifier step H2
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:29:10 -04:00
Joseph Doherty a4e8518e69 fix(management): FlowDebugRunner.RunScriptAsync — always write the root row after a routed call; timeout + unserializable-return tests
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:27:44 -04:00
Joseph Doherty 11d32335af Merge branch 'worktree-agent-ac243fcc404f04015' into feat/flow-debugger-script-entry 2026-09-08 01:23:36 -04:00
Joseph Doherty e1865f9ab3 fix(central-ui): FlowDebugService — reuse the runner's default script timeout, warn on flatten failure, pin one-flatten memo + parameter threading
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:23:18 -04:00
Joseph Doherty 24026b25e2 docs: flow debugger — command counts read four commands / three run commands everywhere
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:22:49 -04:00
Joseph Doherty 50abc96c26 Merge branch 'worktree-agent-a50990ef8ca7ee842' into feat/flow-debugger-script-entry 2026-09-08 01:22:37 -04:00
Joseph Doherty 9404ff87ca Merge branch 'worktree-agent-a6dd4922425ca12c2' into feat/flow-debugger-script-entry 2026-09-08 01:21:55 -04:00
Joseph Doherty 4bb831fc41 docs: flow debugger script entry — component specs, CLAUDE.md, design pointers
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:21:43 -04:00
Joseph Doherty 6fe67e147c feat(central-ui): Flow Debugger script entry — site/instance/script picker, value field or ParameterValueForm by trigger kind
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:21:30 -04:00
Joseph Doherty e5ca703714 Merge branch 'worktree-agent-a5a04e3ef680c61db' into feat/flow-debugger-script-entry 2026-09-08 01:19:07 -04:00
Joseph Doherty 3f4fde3e35 feat(management): dispatch FlowDebugRunScriptCommand (Deployer-only, long-running) on the ManagementActor
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:18:52 -04:00
Joseph Doherty b74d3f4de9 Merge branch 'worktree-agent-abbb97d8899ed576d' into feat/flow-debugger-script-entry 2026-09-08 01:17:43 -04:00
Joseph Doherty 45c6b27d99 fix(cli): flow-debug help text names the script entry; replay role note; run-script guard tests
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:17:32 -04:00
Joseph Doherty 51ce72404b Merge branch 'worktree-agent-a33b0136b18715bde' into feat/flow-debugger-script-entry 2026-09-08 01:16:34 -04:00
Joseph Doherty f1db90cb8a Merge branch 'worktree-agent-a5e1884475a5b785e' into feat/flow-debugger-script-entry 2026-09-08 01:16:11 -04:00
Joseph Doherty a5979b5cfd 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
2026-09-08 01:15:57 -04:00
Joseph Doherty 912e519954 fix(central-ui): ParameterValueForm — raise ValidChanged on schema set so required-field validity is never stale
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:15:40 -04:00
Joseph Doherty acf4ab3d4d Merge branch 'worktree-agent-ac243fcc404f04015' into feat/flow-debugger-script-entry 2026-09-08 01:15:04 -04:00
Joseph Doherty 5b185c93ff feat(central-ui): IFlowDebugService script entry — GetInstanceScripts / PredictScript / RunScript over the flattened configuration
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:14:40 -04:00
Joseph Doherty 067a858ce9 feat(cli): flow-debug run-script — run a site script by name through the flow debugger
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:13:15 -04:00
Joseph Doherty 001123d930 Merge branch 'worktree-agent-a5e1884475a5b785e' into feat/flow-debugger-script-entry 2026-09-08 01:11:04 -04:00
Joseph Doherty a437e0c14f Merge branch 'worktree-agent-a59908183c5526bc7' into feat/flow-debugger-script-entry 2026-09-08 01:09:34 -04:00
Joseph Doherty 78f9c9c250 Merge branch 'worktree-agent-ac0d267f4b80216f4' into feat/flow-debugger-script-entry 2026-09-08 01:09:34 -04:00
Joseph Doherty a36c813b89 feat(central-ui): ParameterValueForm.ValidChanged — additive validity callback
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:09:30 -04:00
Joseph Doherty 504151991e refactor(management): FlowDebugRunner — extract WriteAndPollAsync from RunValueChangeAsync (no behaviour change)
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:08:50 -04:00
Joseph Doherty 30de51c48a test(commons): FlowGraphBuilder — a RouteCall root (flow debugger direct script call) builds Central → Site
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:08:39 -04:00
Joseph Doherty 3d5759613f Merge branch 'worktree-agent-a02d51803adfe3011' into feat/flow-debugger-script-entry 2026-09-08 01:08:14 -04:00
Joseph Doherty 952b41f0f7 feat(commons): ScriptTriggerAttribute — monitored attribute of a ValueChange/Conditional trigger (flow debugger script entry)
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:07:38 -04:00
Joseph Doherty 8712732311 feat(commons): FlowDebugRunScriptCommand — additive management command for the flow debugger script entry
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:07:27 -04:00
Joseph Doherty fefa3ffa77 docs(plans): flow debugger script entry — implementation plan (13 tasks, 6 waves)
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:02:10 -04:00
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
51 changed files with 5937 additions and 307 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+31 -2
View File
@@ -5,7 +5,7 @@ set -euo pipefail
# crash (docker kill = SIGKILL, so auto-down failover is exercised, not CoordinatedShutdown's
# graceful handover) + rejoin. Requires: seed-delmia-sim.sh + seed-delmia-api.sh already run, and
# DELMIA_API_KEY exported (the token printed by seed-delmia-api.sh). FLOW_ONLY=1 skips A-G (no
# notifier build, no docker kill) and runs the Flow Debugger steps H and I alone.
# notifier build, no docker kill) and runs the Flow Debugger steps H, H2 and I alone.
#
# HISTORY (2026-09-02): the first run of this script found that inbound API keys were a PER-NODE
# SQLite store, so the key minted through the active node did not exist on the standby and every
@@ -32,6 +32,10 @@ set -euo pipefail
# G reject path (DownloadPath *.bad) -> NO + "Simulated reject"
# H Flow Debugger live run + replay -> YES (flow-debug run-method returns the traced
# site steps inline; flow-debug replay rebuilds the same chain from the central audit store)
# H2 Flow Debugger SCRIPT entry (2026-09-08) -> YES (flow-debug run-script calls $MACHINE's
# OutboundDemo Call script directly — no inbound method in front of it — and the reply leads with
# the operator-attributed RouteCall root, then the site's inline ScriptRun. Skipped with a note
# when the instance is not seeded.)
# I Flow Debugger OUTBOUND calls (2026-09-06) -> YES (FlowOutboundDemo — seed-flow-outbound-demo.sh —
# returns ApiOutbound + DbOutbound + Notification rows inline: the sync ApiCall Delivered, the cached
# DB write Submitted or — DB reachable, immediate-completion path — already Delivered, the
@@ -77,7 +81,7 @@ for n in a b; do
done
if [ "${FLOW_ONLY:-}" = 1 ]; then
echo "== FLOW_ONLY=1: skipping A-G (notifier + failover drill); running the Flow Debugger steps H and I only"
echo "== FLOW_ONLY=1: skipping A-G (notifier + failover drill); running the Flow Debugger steps H, H2 and I only"
else
echo "== A baseline (active first)"; write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "$DELMIA_API_KEY"; notify A YES
echo "== B standby first (relay)"; write_cfg "$(url_of "$STB"),$(url_of "$ACT")" "$DELMIA_API_KEY"; notify B YES
@@ -142,6 +146,31 @@ done
echo " replay: $H_REPLAY"
echo "H live+replay -> YES"
# H2 — Flow Debugger SCRIPT entry (design 2026-09-08): flow-debug run-script invokes $MACHINE's
# OutboundDemo Call script directly through the traced RouteToCall path — no inbound method in front
# of it. The reply's first row is the operator-attributed RouteCall root written centrally, followed
# by the site's inline ScriptRun (and this script's outbound rows). OutboundDemo declares no
# ParameterDefinitions (seed-flow-outbound-demo.sh passes no --parameters; it reads an optional
# WorkOrder and defaults it), so no --params is needed.
echo "== H2 Flow Debugger script entry (direct call)"
H2_INSTANCE_ID=$("$CLI" instance list "${H_ARGS[@]}" | python3 -c 'import json,sys; xs=json.load(sys.stdin); print(next((i["id"] for i in xs if i.get("uniqueName")==sys.argv[1]), ""))' "$MACHINE")
if [ -z "$H2_INSTANCE_ID" ]; then
echo "H2 script entry -> SKIPPED ($MACHINE not seeded — run docker/seed-delmia-sim.sh)"
else
set +e; H2_RUN=$("$CLI" flow-debug run-script --instance-id "$H2_INSTANCE_ID" --script OutboundDemo "${H_ARGS[@]}"); H2_RC=$?; set -e
[ "$H2_RC" -eq 0 ] || { echo "FAIL: flow-debug run-script exited $H2_RC: $H2_RUN"; exit 1; }
H2_OK=$(echo "$H2_RUN" | python3 -c '
import json, sys
r = json.load(sys.stdin)
kinds = [json.loads(e["detailsJson"]).get("kind") or "?" for e in r.get("events", [])]
ok = r.get("completed") is True and kinds[:1] == ["RouteCall"] and "ScriptRun" in kinds
print(("YES" if ok else "NO") + " root=" + str(r.get("rootExecutionId")) + " kinds=" + ",".join(kinds)
+ (" error=" + str(r.get("errorCode")) if r.get("errorCode") else ""))
sys.exit(0 if ok else 1)') || { echo "FAIL: run-script incomplete: $H2_OK"; exit 1; }
echo " live: $H2_OK"
echo "H2 script entry (direct call) -> YES"
fi
# I — Flow Debugger outbound calls (plan 2026-09-06). The demo script makes a sync ExternalSystem.Call,
# a Database.CachedWrite and a Notify.Send. Live: the site returns the rows it emitted inline — the
# sync ApiCall is already Delivered, the cached write and the notification are Submitted (pending).
@@ -23,7 +23,7 @@ on the `DelmiaReceiver` child → seven attribute writes → wait for Galaxy to
| # | Decision | Rejected |
|---|----------|----------|
| D1 | **Two modes: Live and Replay.** Live is a real run with real values, real writes, audited like any inbound call. Replay executes nothing — it rebuilds the flow from an existing ExecutionId's trace rows. | *Dry run* (execute scripts but capture writes/ES/DB/Notify instead of sending). Needs an interception seam in the site runtime that does not exist and would fork the semantics of every helper. |
| D2 | **Two entry points in v1:** an Inbound API method, and a value-change-triggered site script on a named instance. Both may land on a site script on instance X. | Alarm/timer trigger simulation, direct "call script X" — see §11. |
| D2 | **Two entry points in v1:** an Inbound API method, and a value-change-triggered site script on a named instance. Both may land on a site script on instance X. | Alarm/timer trigger simulation, direct "call script X" — see §11. Superseded by `2026-09-08-flow-debugger-script-entry-design.md` (site → instance → script) as the page's second entry point. |
| D3 | **Value-change on a data-sourced attribute does a REAL tag write** through the instance actor's normal write path; the device echo fires the trigger. Correlation is by a short-lived *trace arm* on the instance actor (§5.3). | A debug-only "inject value" command that synthesises the change without touching the device — leaves the instance's cached value diverged from the device until the next real update. |
| D4 | **Live gets its trace inline; Replay reads audit rows.** Site audit telemetry forwards on a 5 s busy / 30 s idle cadence (`AuditLog:SiteTelemetry`), too slow to drive an interactive run, so the routed reply carries the site steps back and the same steps are *also* written as audit rows for Replay. | Live waiting on the audit pipeline. |
| D5 | **Tracing is gated, not always-on.** Sites run scripts at high rates from timers and value changes; a trace flag rides beside `ParentExecutionId` and only flagged runs emit trace rows. One exception is always-on: a central `RouteCall` row per `Route.To().Call` (inbound-driven volume, joins the two halves of a Replay). | Tracing every run (volume), or a purely time-window join. |
@@ -55,6 +55,8 @@ Reference the mockup's two full-fidelity boards; this section only records what
**Toolbar.** Entry point (`Inbound API method` | `Value-change trigger`) + a picker for the method or
the instance + script; Mode (`Live` | `Replay`); `Edit inputs`, `Load from audit…`, `Run`.
Superseded by `2026-09-08-flow-debugger-script-entry-design.md`: the second entry point is
site → instance → script, not site → instance → attribute.
**Status strip.** State chip (`Not run` / `Running` / `Completed` / `Failed` / `Timed out`), run
number, wall-clock and node, total duration, counts (steps, boundaries crossed, attribute writes),
@@ -77,6 +79,9 @@ instance, composed child, connection, attribute kind, source line) with `Open in
`ParameterDefinitions` (type, required) or, for a value-change entry, one field for the trigger
attribute's new value plus read-only current value/quality. `Fill from last real call` pre-fills
from the most recent `InboundRequest` row for that method.
Superseded by `2026-09-08-flow-debugger-script-entry-design.md`: the script entry point's Inputs
card is a `ParameterValueForm` from the script's own `ParameterDefinitions` for call kinds, and the
single value field (attribute derived from the trigger, not picked) for write kinds.
**Replay.** `Load from audit…` takes an ExecutionId (or arrives via a new `Open in Flow Debugger`
action on the Audit Log drawer and the Execution Chain page). The graph is built from trace rows
@@ -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<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.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
{
"planPath": "docs/plans/2026-09-08-flow-debugger-script-entry.md",
"tasks": [
{
"id": 1,
"subject": "Task 1: ScriptTriggerAttribute helper (Commons)",
"status": "completed"
},
{
"id": 2,
"subject": "Task 2: FlowDebugRunScriptCommand record",
"status": "completed"
},
{
"id": 3,
"subject": "Task 3: Extract WriteAndPollAsync from RunValueChangeAsync",
"status": "completed"
},
{
"id": 4,
"subject": "Task 4: FlowDebugRunner.RunScriptAsync",
"status": "completed",
"blockedBy": [
1,
2,
3
]
},
{
"id": 5,
"subject": "Task 5: ManagementActor dispatch, role matrix, long-running registration",
"status": "completed",
"blockedBy": [
4
]
},
{
"id": 6,
"subject": "Task 6: FlowGraphBuilder RouteCall-root pin",
"status": "completed"
},
{
"id": 7,
"subject": "Task 7: ParameterValueForm.ValidChanged",
"status": "completed"
},
{
"id": 8,
"subject": "Task 8: IFlowDebugService / FlowDebugService script entry",
"status": "completed",
"blockedBy": [
1,
2
]
},
{
"id": 9,
"subject": "Task 9: Flow Debugger page script picker + inputs card",
"status": "completed",
"blockedBy": [
7,
8
]
},
{
"id": 10,
"subject": "Task 10: CLI flow-debug run-script",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 11,
"subject": "Task 11: Playwright script run + verify script leg",
"status": "completed",
"blockedBy": [
9,
10
]
},
{
"id": 12,
"subject": "Task 12: Documentation",
"status": "completed",
"blockedBy": [
5,
8,
10
]
},
{
"id": 13,
"subject": "Task 13: Gate \u2014 build, full tests, rig, browser",
"status": "completed",
"blockedBy": [
11,
12
]
}
],
"lastUpdated": "2026-09-08T05:47:49Z"
}
@@ -0,0 +1,70 @@
# Instance Picker — area-tree combobox for Flow Debugger and Debug View (design)
**Date:** 2026-09-08
**Status:** approved (user, 2026-09-08: "Approve as designed"; scope: Flow Debugger + Debug View)
**Supersedes:** the flat `<select>` instance pickers on `/deployment/flow-debugger` (`flow-instance-select`) and `/deployment/debug-view` (`debug-instance-select`).
## 1. Change
Both pages pick an instance from a flat, alphabetical `<select>` of every instance at the chosen site. A site with a few hundred instances across nested areas makes that list useless. Replace it with a **combobox**: a text field the operator can type an instance name into, whose dropdown is the site's **area tree** (all areas collapsed on open) with instances as leaves, so the operator can either drill down or search.
Alternatives rejected:
- A modal picker like Create Instance's *Choose…* button (`TemplatePickerDialog`): consistent, but two clicks and nothing typeable in the field. The user asked for a combobox.
- A native `<datalist>` typeahead: search only, no area drill-down.
## 2. Component — `InstancePicker`
`src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor` (+ `.razor.cs`, `.razor.css`). Bootstrap-only, no third-party UI, no new JavaScript.
**Field.** A `form-control form-control-sm` text input with a chevron button, sized like the sibling selects. Closed, it shows the selected instance's `UniqueName` (empty when nothing is selected, placeholder *Search or browse instances…*). Focus or typing opens the panel; the chevron toggles it. `role="combobox"`, `aria-expanded`, `aria-controls` → the panel id; `Disabled` disables both.
**Panel.** A `position-absolute` dropdown under the field (z-index above page content, `max-height: 40vh`, scroll) containing the existing `TreeView<InstanceTreeNode>`:
- **Forest** = the site's areas nested by `ParentAreaId` (Topology's shape), each area's instances as leaves after its child areas, and instances with no area listed at the root **after** the root areas. Ordinal-ignore-case sort on both.
- **All areas collapsed** when the panel opens with an empty filter (no `InitiallyExpanded`, no `StorageKey`).
- Clicking an area row toggles it (the TreeView chevron; the label click also toggles). Clicking an instance row selects it and closes the panel.
- An empty tree renders *No instances at this site* / *No instances match*.
**Search.** The field text is a case-insensitive `Contains` on `UniqueName`. With a non-empty filter the forest is rebuilt to the matching instances plus their ancestor areas, and the tree is force-expanded (`ExpandAll` after the rebuild, as the template picker does). Clearing the text restores the full forest and collapses everything (`CollapseAll`).
**Keyboard.** `Enter` selects the first visible matching instance (document order); `ArrowDown` from the field moves focus into the tree (its roving-tabindex arrow navigation; the picker passes `Selectable` + `SelectedKeyChanged` so `Enter`/`Space` on an instance row selects it and on an area row toggles it — the same handler the mouse uses); `Escape` closes and restores the field text to the current selection. Tabbing out does **not** close the panel (a correct focus-out needs `relatedTarget`, i.e. JS interop, which this design avoids); the next click anywhere lands on the backdrop and closes it, which is acceptable.
**Outside click.** A transparent fixed backdrop (the same `tv-ctx-overlay` pattern `TreeView` uses for its context menu) sits under the panel while open and closes it on click. No JS interop.
**Parameters.**
| Parameter | Type | Notes |
|---|---|---|
| `Areas` | `IReadOnlyList<Area>` | the site's areas (flat, `ParentAreaId` links) |
| `Instances` | `IReadOnlyList<Instance>` | the site's instances |
| `SelectedInstanceId` / `SelectedInstanceIdChanged` | `int` / `EventCallback<int>` | two-way bind, `0` = none |
| `Disabled` | `bool` | |
| `ItemLabel` | `Func<Instance,string>?` | row + field text; default `UniqueName`. Debug View passes `$"{i.UniqueName} ({i.State})"` |
| `DataTestPrefix` | `string` | `flow-instance` / `debug-instance` → `{prefix}-picker`, `{prefix}-input`, `{prefix}-toggle`, `{prefix}-panel`, rows `{prefix}-row` with `data-instance-id` |
| `Placeholder` | `string` | default *Search or browse instances…* |
A parent that changes `Areas`/`Instances` (site change) resets the field text and closes the panel; a parent that changes `SelectedInstanceId` (URL / localStorage restore) updates the field text.
**Pure builder.** `InstanceTreeBuilder.Build(areas, instances, filter)` → `IReadOnlyList<InstanceTreeNode>` (`Components/Shared/InstanceTreeNode.cs`, `InstanceTreeBuilder.cs`; record `InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance)` with a read-only `Children` property the builder fills, keys `a:{id}` / `i:{id}`, `IsInstance => Instance != null`). Orphaned areas (parent id pointing nowhere) and orphaned instances (area id pointing nowhere) are treated as roots so a bad row can never hide a subtree; a `ParentAreaId` cycle is unreachable from any root and simply does not render (guarded against looping). `Topology.razor` keeps its own richer `TopoNode` — not refactored (YAGNI).
## 3. Page wiring
- **Flow Debugger** (`FlowDebugger.razor` / `.razor.cs`): `LoadInstancesAsync` also loads `_areas` via `ITemplateEngineRepository.GetAreasBySiteIdAsync(siteId)`; the `<select data-test="flow-instance-select">` becomes `<InstancePicker … SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePickedAsync" Disabled="@(_running || _selectedSiteId == 0 || _instancesLoading)" DataTestPrefix="flow-instance" />` (the handler sets the id then awaits the unchanged `OnInstanceChangedAsync`). **The picker stays disabled while the site's instances load** (`_instancesLoading`, both pages): Blazor renders at the handler's first `await`, so without the flag the field is enabled while the old lists are still bound, and text typed in that window is wiped when the loaded lists arrive (the reference change reads as a site change) — found by Playwright on the rig. `_selectedInstanceId`, `OnInstanceChangedAsync`, the `?instanceId=` deep link, localStorage restore and the site-scope gate are unchanged.
- **Debug View** (`DebugView.razor`): `LoadInstancesForSite` also loads the areas; the `<select data-test="debug-instance-select">` becomes the picker with `ItemLabel` = `UniqueName (State)`, `Disabled="@(_connected || _instancesLoading)"`, `DataTestPrefix="debug-instance"`, `SelectedInstanceIdChanged="OnInstancePicked"`. `OnInstanceSelectionChanged`, `?instanceId=` and localStorage reconnect unchanged.
## 4. Security
None new. Site scoping stays on the site select (both pages already re-check `SiteScope.IsSiteAllowedAsync` on site change); the picker only ever receives the instances of the already-allowed site. Labels are rendered as text (Blazor-encoded).
## 5. Tests
- `CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs`: nesting, root instances after root areas, sort, orphaned area → root, filter keeps ancestors and drops non-matching branches, empty inputs.
- `CentralUI.Tests/Shared/InstancePickerTests.cs` (bUnit): opens collapsed (no instance rows visible under a collapsed area); typing reveals matches expanded; clearing collapses; click selects + closes + raises `SelectedInstanceIdChanged`; `Enter` picks the first match; `Escape` restores text; `Disabled`; parent-driven `SelectedInstanceId` updates the field; `ItemLabel`.
- `FlowDebuggerPageTests`: the five `flow-instance-select` `.Change("7")` call sites go through a test helper that opens the picker and clicks the row (`data-instance-id`).
- Debug View bUnit tests: none drive the select today; add one page test that a picker row click sets the instance and enables *Connect*.
- Playwright: `FlowDebuggerTests` (instance pick at line ~209), `DebugViewTests`, `DebugViewTreeTests` switch from `<select>`+option to typing in `{prefix}-input` and clicking the row.
## 6. Docs
`docs/requirements/Component-CentralUI.md` (Flow Debugger + Debug View paragraphs), `CLAUDE.md` Flow Debugger bullet (one sentence), this design doc.
+261
View File
@@ -0,0 +1,261 @@
# Instance Picker Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Replace the flat instance `<select>` on Flow Debugger and Debug View with a shared combobox whose dropdown is the site's collapsed area tree, searchable by typing.
**Architecture:** A pure `InstanceTreeBuilder` turns `Area`/`Instance` lists (+ filter) into `InstanceTreeNode` forests; a new Bootstrap-only `InstancePicker` component wraps a text field + the existing `TreeView` in an absolutely-positioned panel with a transparent backdrop (no JS). Both pages keep their selection state and only swap the control. Design: `docs/plans/2026-09-08-instance-picker-design.md`.
**Tech Stack:** Blazor Server, Bootstrap 5, bUnit + NSubstitute + xUnit, Playwright (rig).
**Branch:** `feat/instance-picker` (from `main` @ `859922ba`). Worktree agents branch from `main`, so every implementer starts with `git reset --hard <current feature sha>` (see memory `agent-worktree-isolation-base`).
**Conventions:** `docs/plans/...` rules in `CLAUDE.md`; tests run per project (`dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter ...`); commit trailer `Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29`. Never block on a Task from a Blazor lifecycle/event path.
---
### Task 1: `InstanceTreeNode` + `InstanceTreeBuilder` (pure) with tests
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (everything else depends on it)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs`
**Step 1: Write the failing tests** (`InstanceTreeBuilderTests`, namespace `ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared`; `Area` is `ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Area` — check the namespace with grep — `new Area("Name") { Id, SiteId, ParentAreaId }`; `Instance` is `Commons.Entities.Instances.Instance`, `new Instance("Name") { Id, SiteId, AreaId }`):
- `Build_NestsAreasByParent_AndPutsRootInstancesAfterRootAreas`: areas Line1 (1), Cell A (2, parent 1); instances P1 (area 2), Zulu (no area), Alpha (no area) → roots = [`a:1`, `i:<Alpha>`, `i:<Zulu>`]; `a:1` → [`a:2`] → [`i:P1`]. Labels = names; `IsInstance` true only on instance nodes.
- `Build_SortsAreasAndInstancesCaseInsensitively`: "beta", "Alpha" areas → Alpha first; same for instances.
- `Build_OrphanedArea_BecomesARoot`: area with `ParentAreaId = 99` (absent) appears at root, its instances under it.
- `Build_Filter_KeepsMatchingInstancesWithAncestors_DropsEmptyBranches`: filter "p1" (case-insensitive) keeps `a:1 → a:2 → i:P1`, drops Zulu/Alpha and any area with no match; `Build_Filter_WhitespaceMeansNoFilter`.
- `Build_NullOrEmptyInputs_ReturnEmpty` (null areas / null instances tolerated).
- `Build_AreaWithNoInstancesAndNoFilter_IsStillShown` (empty areas are shown when unfiltered).
**Step 2: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter FullyQualifiedName~InstanceTreeBuilderTests` → build errors (types missing).
**Step 3: Implement**
```csharp
// InstanceTreeNode.cs
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>One row of the <see cref="InstancePicker"/> tree: an area branch or an instance leaf. Keys are a:{areaId} / i:{instanceId}.</summary>
public sealed record InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance)
{
public List<InstanceTreeNode> Children { get; } = new();
public bool IsInstance => Instance is not null;
public bool HasChildren => Children.Count > 0;
}
```
```csharp
// InstanceTreeBuilder.cs — pure, no Blazor/DI.
public static class InstanceTreeBuilder
{
public static IReadOnlyList<InstanceTreeNode> Build(IReadOnlyList<Area>? areas, IReadOnlyList<Instance>? instances, string? filter)
// 1. areas ??= []; instances ??= []; needle = filter?.Trim(); hasFilter = !string.IsNullOrEmpty(needle)
// 2. known = set of area ids; children-of lookup: parentId → areas (null parent OR parent not in known ⇒ root)
// 3. instancesByArea: AreaId (null ⇒ root) → instances, each sorted OrdinalIgnoreCase by UniqueName
// 4. BuildLevel(parentId): child areas sorted by Name OrdinalIgnoreCase, then instances of that level;
// with a filter an instance is kept iff UniqueName.Contains(needle, OrdinalIgnoreCase),
// an area is kept iff it has any kept child (area or instance); without a filter everything is kept.
// 5. return roots
}
```
**Step 4: Run the tests → PASS. Step 5: Commit** `feat(central-ui): InstanceTreeBuilder — area/instance forest for the instance picker`.
---
### Task 2: `InstancePicker` component — field, panel, select, outside-click
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor`
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs`
Read first: `TreeView.razor` (parameters at ~line 165–190, `ExpandAll`/`CollapseAll`/`RevealNode` at ~600–680, the context-menu overlay markup at ~line 41 for the backdrop pattern), `TemplatePickerDialog.razor` (search box + tree host + `NodeContent` with `tv-glyph`/`tv-label`), `TemplatePickerDialogTests.cs` (bUnit style; `JSInterop.Mode = JSRuntimeMode.Loose`).
**Step 1: Failing tests** (`InstancePickerTests : BunitContext`, Loose JS interop). Fixture: areas Line1 (1) → Cell A (2); instances P1 (id 7, area 2), Zulu (id 8, root). Render `InstancePicker` with `Areas`, `Instances`, `DataTestPrefix="t"`, `SelectedInstanceIdChanged` capturing into a local.
- `Closed_ShowsNothingButTheField`: no `[data-test='t-panel']`; input `[data-test='t-input']` has `role="combobox"`, `aria-expanded="false"`.
- `Toggle_OpensPanel_WithAreasCollapsed`: click `[data-test='t-toggle']` → panel present; rows `[data-test='t-row']` visible = Line1 (area) and Zulu (root instance) only — P1 is under a collapsed area, so no `[data-instance-id='7']`.
- `ClickingAnArea_ExpandsIt`: click the `tv-toggle` of Line1 then Cell A → `[data-instance-id='7']` appears.
- `ClickingAnInstance_Selects_Closes_AndRaisesTheCallback`: after expanding, click `[data-instance-id='7']` → callback got 7, panel gone, input value `"P1"`.
- `Disabled_NeverOpens`: `Disabled=true` → input disabled, toggle disabled, clicking does nothing.
- `Backdrop_ClickClosesThePanel_WithoutChangingSelection`.
- `ParentDrivenSelection_UpdatesTheFieldText`: re-render with `SelectedInstanceId=8` → input value "Zulu"; `SelectedInstanceId=0` → "".
- `ItemLabel_IsUsedForRowsAndField`: `ItemLabel = i => i.UniqueName + " (x)"`.
**Step 2: run → build errors. Step 3: Implement.**
`InstancePicker.razor` (sketch — Bootstrap only):
```razor
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
<div class="ip-wrap position-relative" data-test="@($"{DataTestPrefix}-picker")">
<div class="input-group input-group-sm">
<input type="text" class="form-control" role="combobox" autocomplete="off"
id="@_inputId" aria-autocomplete="list" aria-haspopup="tree"
aria-expanded="@(_open ? "true" : "false")" aria-controls="@_panelId"
placeholder="@Placeholder" disabled="@Disabled"
data-test="@($"{DataTestPrefix}-input")"
value="@_text" @oninput="OnInput" @onfocus="OpenAsync" @onkeydown="OnInputKeyDown" />
<button type="button" class="btn btn-outline-secondary" tabindex="-1" aria-label="Browse instances"
disabled="@Disabled" data-test="@($"{DataTestPrefix}-toggle")" @onclick="ToggleAsync">
<i class="bi @(_open ? "bi-chevron-up" : "bi-chevron-down")"></i>
</button>
</div>
@if (_open)
{
<div class="ip-backdrop" @onclick="CloseAsync"></div>
<div class="ip-panel border rounded bg-body shadow-sm" id="@_panelId" data-test="@($"{DataTestPrefix}-panel")">
<TreeView @ref="_tree" TItem="InstanceTreeNode" Items="_roots"
ChildrenSelector="n => n.Children" HasChildrenSelector="n => n.HasChildren"
KeySelector="n => n.Key" IndentPx="16">
<NodeContent Context="node">
<span class="tv-glyph"><i class="bi @(node.IsInstance ? "bi-box" : "bi-diagram-3")"></i></span>
<span class="tv-label @(node.IsInstance ? "ip-instance" : "ip-area")" title="@node.Label"
data-test="@($"{DataTestPrefix}-row")" data-instance-id="@(node.Instance?.Id)"
@onclick="() => OnRowClickAsync(node)">@node.Label</span>
</NodeContent>
<EmptyContent><span class="text-muted fst-italic small px-2">@(_hasFilter ? "No instances match." : "No instances at this site.")</span></EmptyContent>
</TreeView>
</div>
}
</div>
```
Code-behind essentials:
- Parameters per design §2 table (`Areas`, `Instances`, `SelectedInstanceId`, `SelectedInstanceIdChanged`, `Disabled`, `ItemLabel`, `DataTestPrefix` (default `"instance"`), `Placeholder` (default `"Search or browse instances…"`)).
- State: `_open`, `_text`, `_filter`, `_roots`, `_tree` (`TreeView<InstanceTreeNode>?`), `_inputId`/`_panelId` from a `Guid` suffix.
- `OnParametersSet`: if `Areas`/`Instances` references changed → rebuild `_roots` with the current filter and (if the selection is no longer in `Instances`) clear text; if `SelectedInstanceId` changed and the panel is closed → `_text = LabelOf(selected)`.
- `Rebuild()` → `InstanceTreeBuilder.Build(Areas, Instances, _filter)` — the label used for matching is `UniqueName` (the builder's contract), rows display `ItemLabel`.
- `OnRowClickAsync(node)`: area → `_tree.ToggleExpand`? (`TreeView` has no public toggle — expose one: add `public void ToggleExpanded(object key)` to `TreeView.razor` next to `IsExpanded`, a one-line wrapper around the existing private `ToggleExpand`); instance → `SelectAsync(node.Instance)`.
- `SelectAsync(inst)`: `_text = LabelOf(inst)`, `_filter = ""`, close, `await SelectedInstanceIdChanged.InvokeAsync(inst.Id)`.
- `CloseAsync`: `_open = false`, `_filter = ""`, `_text = LabelOf(current)`.
- Guard: when `Disabled`, `OpenAsync`/`ToggleAsync` no-op.
CSS (`InstancePicker.razor.css`): `.ip-backdrop { position: fixed; inset: 0; z-index: 1040; background: transparent; }` `.ip-panel { position: absolute; left: 0; right: 0; top: 100%; z-index: 1041; max-height: 40vh; overflow-y: auto; min-width: 18rem; padding: .25rem; }` `.ip-instance { cursor: pointer; }` `.ip-area { cursor: pointer; }`. Rows render inside `TreeView`, whose `tv-*` classes are already global to that component — check `TreeView.razor.css` whether `tv-label`/`tv-glyph` are `::deep`-reachable from a consumer (TemplatePickerDialog uses them the same way, so follow that).
**Step 4: tests → PASS. Step 5: Commit** `feat(central-ui): InstancePicker — combobox over the site's area tree`.
---
### Task 3: `InstancePicker` — search filter + keyboard
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor` / `.razor.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs`
**Step 1: Failing tests**
- `Typing_Filters_AndExpandsToTheMatches`: open, `Input("p1")` on the input → `[data-instance-id='7']` is rendered (ancestors expanded), Zulu row absent, Line1 row present.
- `Clearing_RestoresTheFullTree_Collapsed`: after the above, `Input("")` → Zulu back, P1 hidden again (Line1 collapsed).
- `Typing_OpensTheClosedPanel`.
- `Enter_PicksTheFirstVisibleMatch`: type "u" (matches Zulu only) + `KeyDown(Key="Enter")` → callback 8, closed, text "Zulu"; with no match Enter does nothing (still open).
- `Escape_ClosesAndRestoresTheSelectedText`: select P1, open, type "zzz", Escape → closed, text "P1".
- `ArrowDown_MovesFocusIntoTheTree`: after ArrowDown the first visible `[role='treeitem']` has `tabindex="0"` (the tree's roving tabindex target) — assert via the `TreeView` markup; focus itself is JS (Loose mode), so assert the call happened through `JSInterop` if `TreeView` uses `FocusAsync` (an `ElementReference.FocusAsync` shows up as a `Blazor._internal.domWrapper.focus` invocation in bUnit — assert `JSInterop.Invocations` contains it, or expose a `public Task FocusFirstVisibleAsync()` on `TreeView` and assert it doesn't throw).
**Step 2: run → fail. Step 3: Implement**
- `OnInput(ChangeEventArgs e)`: `_text = e.Value?.ToString() ?? ""`, `_filter = _text`, `_open = true`, `Rebuild()`; after render, if `_hasFilter` → `_tree?.ExpandAll()` else `_tree?.CollapseAll()` (do it in `OnAfterRender` guarded by a `_pendingExpand` flag so the tree instance exists).
- `OnInputKeyDown(KeyboardEventArgs e)`: `Enter` → first instance node in document order of the current (filtered) `_roots` → `SelectAsync`; `Escape` → `CloseAsync`; `ArrowDown` → `_open = true` and focus the tree's first visible item (`TreeView`: add `public async Task FocusFirstVisibleAsync()` that sets `_focusedKey` to the first visible node key and applies focus via the existing `_focusNeedsApply` mechanism).
- Typing on a closed picker opens it.
**Step 4: PASS. Step 5: Commit** `feat(central-ui): InstancePicker search filter + keyboard (Enter/Escape/ArrowDown)`.
---
### Task 4: Wire Flow Debugger to the picker
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor` (~lines 62–71, the instance `<select>`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs` (`_instances` ~114, `LoadInstancesAsync` ~412)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs` (five `select[data-test='flow-instance-select']` call sites ~623/637/656/675/717; `WireInstance` ~608)
**Steps:** (1) Add a test helper `static void PickInstance(IRenderedComponent<FlowDebuggerPage> cut, int id)` = click `[data-test='flow-instance-toggle']`, click `[data-test='flow-instance-row'][data-instance-id='{id}']` (the fixture instance has no area, so it is a root row); replace the five `.Change("7")` lines; make `_templates.GetAreasBySiteIdAsync(1, …)` return `[]` in `WireInstance`. Add `ScriptEntry_InstancePicker_ShowsAreasCollapsed` (areas fixture with the instance under an area: only the area row is visible until expanded). (2) Run the class → fails. (3) Page: `private List<Area> _areas = [];` loaded in `LoadInstancesAsync` alongside instances (`GetAreasBySiteIdAsync`), cleared on site 0; markup:
```razor
<InstancePicker Areas="_areas" Instances="_instances"
SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePickedAsync"
Disabled="@(_running || _selectedSiteId == 0)" DataTestPrefix="flow-instance" />
```
with `private async Task OnInstancePickedAsync(int id) { _selectedInstanceId = id; await OnInstanceChangedAsync(); }`. Everything else (deep link, localStorage, scope) unchanged. (4) `FlowDebuggerPageTests` all pass. (5) Commit `feat(central-ui): Flow Debugger picks the instance through InstancePicker`.
---
### Task 5: Wire Debug View to the picker
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 4, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor` (instance `<select>` ~lines 83–92; `_siteInstances` ~259; `LoadInstancesForSite` ~430–452)
- Test: create `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs` (copy the service registration from `DebugViewDisposalTests.cs`)
**Steps:** (1) Test: substitute repo returns one site, instances [Enabled "Sim01" id 7 in area 2] + areas [Line1 (1) → Cell A (2)]; render `DebugView`, `Change` the site select to the site, open `[data-test='debug-instance-toggle']` → only the Line1 row; expand twice → row `[data-instance-id='7']` text `"Sim01 (Enabled)"`; click it → *Connect* button enabled. (2) fails. (3) Page: `_siteAreas` list loaded in `LoadInstancesForSite` (same try/catch), picker markup with `ItemLabel="@(i => $"{i.UniqueName} ({i.State})")"`, `SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="id => { _selectedInstanceId = id; OnInstanceSelectionChanged(); }"`, `Disabled="@_connected"`, `DataTestPrefix="debug-instance"`. (4) `DebugView*` tests pass. (5) Commit `feat(central-ui): Debug View picks the instance through InstancePicker`.
---
### Task 6: Playwright tests drive the picker
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 4, Task 5
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs` (~lines 209–213)
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs` (~68, ~93–97)
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs` (~79–83)
Replace `<select>` + `SelectOptionAsync` with: `FillAsync` the `[data-test='{prefix}-input']` with the instance name, then `Expect(row [data-test='{prefix}-row'][data-instance-id='{id}']).ToBeVisibleAsync()` and `ClickAsync` it (Flow Debugger knows the name, not the id → click `[data-test='flow-instance-row']:has-text("<name>")` exactly; `Skip.If` no row appears within 10 s, same skip messages). `DebugViewTests` line 68 asserts the picker container `[data-test='debug-instance-picker']` count 1. Not runnable without the rig — build the project (`dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests`) and commit `test(playwright): drive the instance picker on Flow Debugger and Debug View`. The controller runs them on the rig in the gate.
---
### Task 7: Docs
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 4, Task 5, Task 6
**Files:**
- Modify: `docs/requirements/Component-CentralUI.md` (Flow Debugger v1.3 paragraph + the Debug View section — grep `debug-instance-select` / "Debug View")
- Modify: `CLAUDE.md` (Flow Debugger bullet, v1.3 sentence — append one sentence)
Text: "The instance is picked through the shared **`InstancePicker`** combobox (2026-09-08, `docs/plans/2026-09-08-instance-picker-design.md`): a text field whose dropdown is the site's area tree (`InstanceTreeBuilder`, all areas collapsed, un-areaed instances at the root), typed text filters by instance name and expands to the matches, Enter picks the first match; Debug View uses the same component with a `(State)` suffix." Commit `docs: instance picker on Flow Debugger + Debug View`.
---
### Task 8: Gate (controller)
Build solution (0 warnings), run `CentralUI.Tests` + `Commons.Tests`, `bash docker/deploy.sh`, Playwright `FlowDebuggerTests` + `DebugViewTests` + `DebugViewTreeTests`, browser check of both pages (collapsed areas, typing filter, Enter, Escape, deep link `?instanceId=`), then append gate evidence to this plan and finish via `finishing-a-development-branch`.
## Gate evidence (2026-09-08, branch `feat/instance-picker`)
- Solution build: 0 warnings. `CentralUI.Tests`: 1335 passed (picker 30 + builder 12 + Flow Debugger page 29 + Debug View picker 7 among them). Playwright project builds.
- Rig rebuilt (`docker/deploy.sh`) twice. Playwright `FlowDebuggerTests` + `DebugViewTests` + `DebugViewTreeTests`: **7/7, 0 skipped** on the second build. The first build failed two of them and skipped two: the tests typed into the picker immediately after choosing a site, while the site's instances were still loading; the loaded lists then arrived as a new reference and the picker reset (its "site changed" rule), so the row never appeared. Fixed by keeping the picker **disabled until the load completes** (`_instancesLoading` on both pages, pinned by TCS-based bUnit tests) — a real race for an operator on a slow database, not just a test artefact.
- Browser (Claude-in-Chrome, `localhost:9000`): Flow Debugger deep link `?instanceId=3687` shows `DelmiaSim01` in the field without opening the tree; typing `Delmia` opens the panel with the single matching row; Escape restores the selection text and closes. Debug View: after picking Test Plant A the panel lists the three `zztest-area-*` areas first (collapsed) and the Enabled instances after them with the `(State)` suffix.
- Whole-branch review findings closed: keyboard activation now routes through `TreeView` selection (mouse + keyboard one handler; `SelectedKey` deliberately unbound to avoid the TreeView reset loop); `aria-label`/`InputId`; `aria-controls` only while open; Debug View's localStorage auto-reconnect guards a stored instance that is no longer Enabled; select-era `OrderBy` dropped; deep-link-in-area test added; design doc corrected (tab-out does not close — needs JS `relatedTarget`; explicit handlers instead of `@bind`).
- Untested on the rig: keyboard activation inside the tree in a real browser (pinned by bUnit only); `overflow: hidden` clipping (neither page has such an ancestor today; CSS comment warns integrators).
@@ -0,0 +1,70 @@
{
"planPath": "docs/plans/2026-09-08-instance-picker.md",
"tasks": [
{
"id": 1,
"subject": "Task 1: InstanceTreeNode + InstanceTreeBuilder (pure) with tests",
"status": "completed"
},
{
"id": 2,
"subject": "Task 2: InstancePicker component \u2014 field, panel, select, outside-click",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 3,
"subject": "Task 3: InstancePicker \u2014 search filter + keyboard",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 4,
"subject": "Task 4: Wire Flow Debugger to the picker",
"status": "completed",
"blockedBy": [
3
]
},
{
"id": 5,
"subject": "Task 5: Wire Debug View to the picker",
"status": "completed",
"blockedBy": [
3
]
},
{
"id": 6,
"subject": "Task 6: Playwright tests drive the picker",
"status": "completed",
"blockedBy": [
3
]
},
{
"id": 7,
"subject": "Task 7: Docs",
"status": "completed",
"blockedBy": [
3
]
},
{
"id": 8,
"subject": "Task 8: Gate (controller)",
"status": "completed",
"blockedBy": [
4,
5,
6,
7
]
}
],
"lastUpdated": "2026-09-08T13:18:02.265057Z"
}
+6 -6
View File
@@ -175,7 +175,7 @@ Central cluster only. Sites have no user interface.
- Track per-site deployment status.
### Debug View (Deployment Role)
- Select a deployed instance and open a live debug view.
- Select a deployed instance and open a live debug view — the instance picked through the same shared **`InstancePicker`** combobox as the Flow Debugger, with an `ItemLabel` suffix of `(State)` on each row.
- Real-time streaming of all attribute values (with quality and timestamp) and alarm states for that instance.
- The `DebugStreamService` creates a `DebugStreamBridgeActor` on the central side. The bridge actor opens a **gRPC server-streaming subscription** to the site's `SiteStreamGrpcServer` for the selected instance, then requests an initial `DebugViewSnapshot` over the central→site gRPC command channel (`SiteCommandService`).
- Ongoing events (`AttributeValueChanged`, `AlarmStateChanged`) flow via the gRPC data stream directly to the bridge actor — they do not travel on the command channel.
@@ -216,13 +216,13 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
- **Computed alarms render unchanged** from the prior flat-table style; the enrichment is purely additive for native rows.
### Flow Debugger (Flow Debugger policy; Live run = Deployment Role)
- **`/deployment/flow-debugger`** (nav: Deployment → Flow Debugger; design `docs/plans/2026-09-05-flow-debugger-design.md`, plan `docs/plans/2026-09-05-flow-debugger.md`). Runs one entry point with operator-supplied inputs and renders every step the run passes through as a **four-lane swimlane** (Central / Site / Tags / **External**) — request, route call, site script run, attribute writes, attribute waits, **outbound API / database / notification calls**, response — each node carrying its input, output, duration and outcome; a tabbed detail panel (Input · Output · Writes *or* Lifecycle · Timing, plus a *Where this ran* column) opens on the selected node with `Open in Debug View` (value-change entries, where the instance is known) and `Audit rows (n)` (→ `/audit/log?executionId=`) drill-ins.
- **`/deployment/flow-debugger`** (nav: Deployment → Flow Debugger; design `docs/plans/2026-09-05-flow-debugger-design.md`, plan `docs/plans/2026-09-05-flow-debugger.md`). Runs one entry point with operator-supplied inputs and renders every step the run passes through as a **four-lane swimlane** (Central / Site / Tags / **External**) — request, route call, site script run, attribute writes, attribute waits, **outbound API / database / notification calls**, response — each node carrying its input, output, duration and outcome; a tabbed detail panel (Input · Output · Writes *or* Lifecycle · Timing, plus a *Where this ran* column) opens on the selected node with `Open in Debug View` (script entries, where the instance is known) and `Audit rows (n)` (→ `/audit/log?executionId=`) drill-ins.
- **Two modes.** *Live* is a real run — the method script executes on the active central node with tracing on, or a real attribute write goes through the routed-write path and the site's triggered run is polled back — audited like any other call. *Replay* executes nothing: it rebuilds the flow from an existing execution's `ScriptTrace` audit rows plus its `ApiOutbound` / `DbOutbound` / `Notification` rows (`?executionId=` drill-in from the Audit Log drawer's and the Execution Chain page's **Open in Flow Debugger** actions).
- **External lane (v1.1, plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`).** Every `ExternalSystem.Call/CachedCall`, `Database.*` and `Notify.Send` a traced run makes is a node on the fourth lane — kinds `ApiCall` / `DbCall` / `Notify` (eyebrows `API CALL` / `DATABASE` / `NOTIFY`), title = the system.method / connection / list, a status pill (`Ok` / `Pending` / `Parked` / `Failed`) plus `sync` | `cached` | `notify`. **One node per operation:** a cached call's or notification's later lifecycle rows (Forwarded → Attempted → Delivered / Parked, written minutes later by the S&amp;F forwarder or the Notification Outbox) fold into the node (`FlowStep.Lifecycle`, a Lifecycle tab in the detail panel with *Delivered by*) instead of drawing four boxes. A sync call sits in the execution chain; a cached call or notification hangs off it on a dashed **async** connector, and a trigger cascade's spawn edge carries a bolt marker. A Live run returns only the rows the run itself emitted, so a graph left with `Pending` nodes **refreshes itself** through the Replay command every 5 s for up to 2 min (page constants), and a *Refresh outcomes* button re-reads on demand in either mode; the state chip stays *Completed* — pending outbound work is not a failed run. A refresh **merges** the re-read rows into the run's known rows by `EventId` (fresh copy wins) and rebuilds the graph from the union, so it only ever adds nodes: the site's outbound rows reach central's audit store by telemetry seconds after the run returned them inline, and a Replay read taken in between would otherwise erase an already-drawn node (observed on the rig 2026-09-06 with a resolved cached DB write). The union is exact because the inline rows are the very `AuditEvent` objects the site writer persists. **v1.2 (follow-ups plan `docs/plans/2026-09-06-flow-debugger-followups.md`, register row 111 closed):** a folded node carries its `CorrelationId` (`FlowStep.CorrelationId` — the `TrackedOperationId` / `NotificationId`), so the detail panel deep-links to **`/site-calls/report?correlationId=<id>`** / **`/notifications/report?correlationId=<id>`** (*Open in Site Calls* / *Open in Notification report*; both pages accept that query, pre-filter to the one row through an additive per-id query filter — `SiteCallQueryRequest.TrackedOperationIdFilter` / `NotificationOutboxQueryRequest.NotificationIdFilter` — show a removable *Operation* / *Notification* pill and auto-open the row's detail modal; note `NotificationId` is minted as a Guid in **`N` form** — no dashes — so the Notify link passes `N` and the report page normalises any Guid-shaped value to `N`, while `TrackedOperationId` is a `varchar(36)` `D` string and the Site Calls filter binds a `varchar` parameter for the PK seek), and a **`Parked` node offers Retry / Discard in place**: the same site-scope re-check, confirm dialog (Discard = danger) and central→site / Notification Outbox relay the report pages use (`IFlowDebugService.RetryOutboundAsync` / `DiscardOutboundAsync` → `CommunicationService`), then the page re-polls Replay (5 s / 2 min) until the node leaves `Parked`. **Prediction is async-safe:** `FlowDebugService.PredictMethodAsync` resolves route targets by a resolve-and-rewalk loop — its original blocking resolver deadlocked the whole Blazor circuit on the renderer sync context the moment a method routing to a site script was chosen (found 2026-09-06; pinned by a single-thread-pump unit test and the Playwright `FlowDebuggerTests`, which log in through the fixture and run a Live run of an ephemeral method in a real browser).
- **Authorization (arch-review F3).** The page is `RequireFlowDebugger` — `AuthorizationPolicies.FlowDebuggerAccessRoles` = Deployer, Administrator, Viewer — so an auditor can open it and Replay a past execution without the ability to run anything, and `FlowDebugReplayCommand` accepts the same set. **Everything that executes stays Deployer-only:** the two run commands (`FlowDebugRunMethodCommand` / `FlowDebugRunValueChangeCommand`), the Live entry-point controls, the parked-node Retry/Discard actions and the `?methodId=` / `?instanceId=` drill-ins; for a non-Deployer the page pins itself to Replay mode.
- **Two entry points.** An inbound API method (inputs form generated from the method's `ParameterDefinitions`, values coerced by declared type) or a value-change trigger on a named instance attribute (Site → Instance → Attribute pickers, site-scope filtered). A method-entry Live run is additionally site-scoped **at route time** on the server (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED`, arch-review F2), so an operator cannot reach a site outside their scope by choosing a method that routes there.
- **External lane (v1.1, plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`).** Every `ExternalSystem.Call/CachedCall`, `Database.*` and `Notify.Send` a traced run makes is a node on the fourth lane — kinds `ApiCall` / `DbCall` / `Notify` (eyebrows `API CALL` / `DATABASE` / `NOTIFY`), title = the system.method / connection / list, a status pill (`Ok` / `Pending` / `Parked` / `Failed`) plus `sync` | `cached` | `notify`. **One node per operation:** a cached call's or notification's later lifecycle rows (Forwarded → Attempted → Delivered / Parked, written minutes later by the S&amp;F forwarder or the Notification Outbox) fold into the node (`FlowStep.Lifecycle`, a Lifecycle tab in the detail panel with *Delivered by*) instead of drawing four boxes. A sync call sits in the execution chain; a cached call or notification hangs off it on a dashed **async** connector, and a trigger cascade's spawn edge carries a **bolt badge on the connector's riser** — the edge itself keeps an ordinary arrowhead (the bolt was the arrowhead until 2026-09-08, and on the upward Tags→Site hop it read as a broken arrow). The spawn edge starts at the parent's **last write of the attribute the child's `triggerAttribute` names** (`FlowGraphBuilder`); only when the child names no attribute, or the parent never wrote it, does the timestamp rule (last non-async parent step at or before the child) decide — a burst of writes landing in one millisecond used to draw the cascade from the last of them rather than the one that fired it. A Live run returns only the rows the run itself emitted, so a graph left with `Pending` nodes **refreshes itself** through the Replay command every 5 s for up to 2 min (page constants), and a *Refresh outcomes* button re-reads on demand in either mode; the state chip stays *Completed* — pending outbound work is not a failed run. A refresh **merges** the re-read rows into the run's known rows by `EventId` (fresh copy wins) and rebuilds the graph from the union, so it only ever adds nodes: the site's outbound rows reach central's audit store by telemetry seconds after the run returned them inline, and a Replay read taken in between would otherwise erase an already-drawn node (observed on the rig 2026-09-06 with a resolved cached DB write). The union is exact because the inline rows are the very `AuditEvent` objects the site writer persists. **v1.2 (follow-ups plan `docs/plans/2026-09-06-flow-debugger-followups.md`, register row 111 closed):** a folded node carries its `CorrelationId` (`FlowStep.CorrelationId` — the `TrackedOperationId` / `NotificationId`), so the detail panel deep-links to **`/site-calls/report?correlationId=<id>`** / **`/notifications/report?correlationId=<id>`** (*Open in Site Calls* / *Open in Notification report*; both pages accept that query, pre-filter to the one row through an additive per-id query filter — `SiteCallQueryRequest.TrackedOperationIdFilter` / `NotificationOutboxQueryRequest.NotificationIdFilter` — show a removable *Operation* / *Notification* pill and auto-open the row's detail modal; note `NotificationId` is minted as a Guid in **`N` form** — no dashes — so the Notify link passes `N` and the report page normalises any Guid-shaped value to `N`, while `TrackedOperationId` is a `varchar(36)` `D` string and the Site Calls filter binds a `varchar` parameter for the PK seek), and a **`Parked` node offers Retry / Discard in place**: the same site-scope re-check, confirm dialog (Discard = danger) and central→site / Notification Outbox relay the report pages use (`IFlowDebugService.RetryOutboundAsync` / `DiscardOutboundAsync` → `CommunicationService`), then the page re-polls Replay (5 s / 2 min) until the node leaves `Parked`. **Prediction is async-safe:** `FlowDebugService.PredictMethodAsync` resolves route targets by a resolve-and-rewalk loop — its original blocking resolver deadlocked the whole Blazor circuit on the renderer sync context the moment a method routing to a site script was chosen (found 2026-09-06; pinned by a single-thread-pump unit test and the Playwright `FlowDebuggerTests`, which log in through the fixture and run a Live run of an ephemeral method in a real browser).
- **Authorization (arch-review F3).** The page is `RequireFlowDebugger` — `AuthorizationPolicies.FlowDebuggerAccessRoles` = Deployer, Administrator, Viewer — so an auditor can open it and Replay a past execution without the ability to run anything, and `FlowDebugReplayCommand` accepts the same set. **Everything that executes stays Deployer-only:** the three run commands (`FlowDebugRunMethodCommand` / `FlowDebugRunScriptCommand` / `FlowDebugRunValueChangeCommand`), the Live entry-point controls, the parked-node Retry/Discard actions and the `?methodId=` / `?instanceId=` / `?script=` drill-ins; for a non-Deployer the page pins itself to Replay mode.
- **Two entry points** (script entry redesigned 2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`). An inbound API method (inputs form generated from the method's `ParameterDefinitions`, values coerced by declared type) or a **site script** (Site → Instance → Script pickers, site-scope filtered): a script whose trigger is ValueChange or Conditional shows one *New value for `<attribute>`* field, with the attribute derived from the script's own trigger configuration rather than picked; every other script kind (Call, Interval, Expression, or no trigger) shows a `ParameterValueForm` generated from the script's `ParameterDefinitions`, or a "this script takes no parameters" note when there are none. A method-entry Live run is additionally site-scoped **at route time** on the server (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED`, arch-review F2), so an operator cannot reach a site outside their scope by choosing a method that routes there. Both entry points pick the target instance through the shared **`InstancePicker`** combobox (2026-09-08, `docs/plans/2026-09-08-instance-picker-design.md`): a text field whose dropdown is the site's area tree (`InstanceTreeBuilder` — all areas collapsed on open, un-areaed instances at the root, orphaned rows surface at the root), typed text filters by instance name and expands to the matches, Enter picks the first visible match, Escape restores the selection.
- **Predicted graph first.** Choosing an entry point renders the statically predicted shape (`FlowShapePredictor`, Script Analysis REQ-SA-7) before anything runs — nodes whose target depends on inputs render dashed; after a run, predicted nodes the run never produced render greyed.
- **Execution path.** `IFlowDebugService` dispatches `FlowDebugRunMethodCommand` / `FlowDebugRunValueChangeCommand` / `FlowDebugReplayCommand` to the **per-node `ManagementActor`** through `ManagementActorHolder` (the same seam the HTTP `/management` endpoint uses), so the server enforces the Deployer gate and the active-node refusal. A standby node answers the two run commands with `STANDBY_NODE`, which the page renders as an inline alert with the server message verbatim; Replay is served from any node. The same three commands back the CLI's `flow-debug run-method | run-value-change | replay`. **The client wait is derived from the server's own bound, not a fixed duration (arch-review F4):** `FlowDebugService.RunAskTimeoutFor(timeoutSeconds)` = the method's `TimeoutSeconds` + the server's `FlowDebugRunner.RunGrace` (10 s) + 15 s client slack, floored at 30 s, and `ValueChangeAskTimeoutFor(waitSeconds)` = the trigger-wait window + 15 s (Replay stays a flat 30 s); the CLI mirrors it with `run-method --timeout-seconds` (default 120, set it to the method's timeout + 25 for long methods) and a `run-value-change` wait of `--wait-seconds + 25`. A long-running method used to be cut off client-side before the server's own grace expired.
- **Execution path.** `IFlowDebugService` dispatches `FlowDebugRunMethodCommand` / `FlowDebugRunScriptCommand` / `FlowDebugRunValueChangeCommand` / `FlowDebugReplayCommand` to the **per-node `ManagementActor`** through `ManagementActorHolder` (the same seam the HTTP `/management` endpoint uses), so the server enforces the Deployer gate and the active-node refusal. A standby node answers the three run commands with `STANDBY_NODE`, which the page renders as an inline alert with the server message verbatim; Replay is served from any node. The same four commands back the CLI's `flow-debug run-method | run-script | run-value-change | replay`. **The client wait is derived from the server's own bound, not a fixed duration (arch-review F4):** `FlowDebugService.RunAskTimeoutFor(timeoutSeconds)` = the method's `TimeoutSeconds` + the server's `FlowDebugRunner.RunGrace` (10 s) + 15 s client slack, floored at 30 s, and `ValueChangeAskTimeoutFor(waitSeconds)` = the trigger-wait window + 15 s (Replay stays a flat 30 s); the CLI mirrors it with `run-method --timeout-seconds` (default 120, set it to the method's timeout + 25 for long methods) and a `run-value-change` wait of `--wait-seconds + 25`. A long-running method used to be cut off client-side before the server's own grace expired. **The script entry point (2026-09-08) reuses the same two timeout functions by kind:** a call-kind script run waits `RunAskTimeoutFor(script.ExecutionTimeoutSeconds ?? 30)`, a write-kind script run waits `ValueChangeAskTimeoutFor(45)`; the page's `?instanceId=` + `?script=` drill-in replaces the former `?attribute=` query parameter.
- State machine `Idle → Predicted → Running → Completed | Failed | TimedOut`; the status strip shows run number, start, total duration, step/boundary/write counts and the root `ExecutionId` (linking to the Execution Chain page). A run whose route call came back with no child execution and no Site-lane steps shows a *site not traced* note (older site build, or an untraced call). Last entry point and per-method inputs persist in `localStorage`.
- The renderer is the custom-SVG `FlowGraphView` (no third-party charting) — pure, unit-tested layout; culture-invariant coordinates; each node a keyboard-activatable `role="button"`. Node text is HTML inside an SVG `foreignObject` so the browser measures it: the title wraps to two lines and ellipsizes by width (never by character count), the subtitle by width on one line, and the hover tooltip carries the full text. A lane whose real titles share a dotted head (Tags: `DelmiaReceiver.RecipeDownloadFlag`, `DelmiaReceiver.RecipeProcessedFlag`, …) shows that head once in the lane gutter (`DelmiaReceiver.*`) and each node shows only the tail that tells it apart (`FlowGraphView.CommonDottedPrefix`).
+1 -1
View File
@@ -303,7 +303,7 @@ For everything else, scripts interact with the system through the curated `Route
## Flow Debugger runs
The Flow Debugger (Central UI, design `docs/plans/2026-09-05-flow-debugger-design.md`) drives this component through three management commands handled by the per-node `ManagementActor` (Deployer role): `FlowDebugRunMethodCommand` executes a method exactly as an external caller would — `ParameterValidator` + `SchemaRefResolver`, then `InboundScriptExecutor.ExecuteAsync(..., trace: true)` with a `RouteHelper` trace sink collecting the `RouteCall` row and the site's inline events — and **synthesizes the `ApiInbound.InboundRequest` row itself** with the operator as actor (`flow-debugger` source tag) because no HTTP request passed through `AuditWriteMiddleware`; it refuses on a standby node with `STANDBY_NODE` (`IActiveNodeGate`), so the script runs only where a real call would. `FlowDebugRunValueChangeCommand` writes an attribute through the traced routed-write path and polls the site (`PullTraceEvents`) for the run the change spawned; `FlowDebugReplayCommand` reads the chain back from the central Audit Log and therefore accepts the wider `AuthorizationPolicies.FlowDebuggerAccessRoles` set (Deployer, Administrator, Viewer) while the two run commands stay Deployer-only (arch-review F3). The result (`FlowDebugRunResult`) carries the audit events inline — the graph is a central-side projection, never a wire type.
The Flow Debugger (Central UI, design `docs/plans/2026-09-05-flow-debugger-design.md`; script entry point `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`) drives this component through four management commands handled by the per-node `ManagementActor` (Deployer role): `FlowDebugRunMethodCommand` executes a method exactly as an external caller would — `ParameterValidator` + `SchemaRefResolver`, then `InboundScriptExecutor.ExecuteAsync(..., trace: true)` with a `RouteHelper` trace sink collecting the `RouteCall` row and the site's inline events — and **synthesizes the `ApiInbound.InboundRequest` row itself** with the operator as actor (`flow-debugger` source tag) because no HTTP request passed through `AuditWriteMiddleware`; it refuses on a standby node with `STANDBY_NODE` (`IActiveNodeGate`), so the script runs only where a real call would. `FlowDebugRunValueChangeCommand` writes an attribute through the traced routed-write path and polls the site (`PullTraceEvents`) for the run the change spawned. `FlowDebugRunScriptCommand` (2026-09-08) resolves the named script on the instance through the flattening pipeline and branches on its trigger kind: a write kind (ValueChange/Conditional) reuses the same traced routed-write + `PullTraceEvents` poll as `FlowDebugRunValueChangeCommand`, using the attribute derived from the script's own trigger configuration; a call kind (Call/Interval/Expression/no trigger) routes a `RouteToCallRequest(Trace: true)` through `IInstanceRouter` **directly** rather than through `RouteHelper` — this keeps exactly one operator-attributed `RouteCall` root row instead of `RouteHelper`'s own always-on, actor-less one — and returns the site's inline trace rows through `FlowTraceCollector`; its error codes are `NOT_FOUND` (unknown script or instance), `VALIDATION_FAILED` (missing write-kind value, or call-kind parameters that fail `ParameterValidator`), `CALL_FAILED` (the routed call completed but the site reported failure), and `TIMEOUT` (no response within the script's execution timeout). `FlowDebugReplayCommand` reads the chain back from the central Audit Log and therefore accepts the wider `AuthorizationPolicies.FlowDebuggerAccessRoles` set (Deployer, Administrator, Viewer) while the three run commands stay Deployer-only (arch-review F3). The result (`FlowDebugRunResult`) carries the audit events inline — the graph is a central-side projection, never a wire type.
- **`SchemaRefResolver` is `public` for the runner's benefit.** It was internal to the HTTP path; the Flow Debugger's `FlowDebugRunMethodCommand` validates its operator-supplied parameters through exactly the same `ParameterValidator` + `SchemaRefResolver` pair a real HTTP call uses, so a Live run cannot accept an input the endpoint would reject. Keep the two paths on the one resolver — a second, debugger-local validator would silently fork the contract.
- **Site scope is enforced at route time on a Live run** (arch-review F2). `RouteHelper.WithSiteScope(IReadOnlySet<string>? permittedSiteIdentifiers)` carries the operator's permitted **site identifiers**; `RouteTarget.ResolveSiteAsync` throws `RouteSiteScopeException` before any site traffic when the resolved target site is outside that set, and `InboundScriptExecutor` maps it to error code **`SITE_SCOPE_DENIED`**. `FlowDebugRunner` translates that code into a `SiteScopeViolationException` (`ManagementUnauthorized`) *after* writing the inbound audit row, so the denied attempt is recorded under the operator's name. Administrators and unscoped users get a null scope (unrestricted), and the HTTP endpoint passes no scope at all — production routing is unchanged.
@@ -6,11 +6,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// The <c>flow-debug</c> command group (Flow Debugger design 2026-09-05 §7.1): a Live run of
/// an inbound API method or a value-change trigger with every routed site run traced, and a
/// Replay of a past execution chain from the central audit store. All three are Deployer
/// commands answered by the node the CLI is pointed at; a standby central refuses the two
/// run commands with <c>STANDBY_NODE</c> (Replay is served from any node).
/// The <c>flow-debug</c> command group (Flow Debugger design 2026-09-05 §7.1; script entry
/// point 2026-09-08 §6): a Live run of an inbound API method, a site script, or a
/// value-change trigger with every routed site run traced, and a Replay of a past execution
/// chain from the central audit store. The three run commands are Deployer-only commands
/// answered by the node the CLI is pointed at; a standby central refuses them with
/// <c>STANDBY_NODE</c>. Replay is served from any node and accepts a wider role set.
/// </summary>
public static class FlowDebugCommands
{
@@ -29,9 +30,10 @@ public static class FlowDebugCommands
/// <returns>The configured <c>flow-debug</c> command.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("flow-debug") { Description = "Flow Debugger: trace an inbound API method or value-change flow end to end, or replay a past one" };
var command = new Command("flow-debug") { Description = "Flow Debugger: trace an inbound API method, a site script, or a value-change flow end to end, or replay a past one" };
command.Add(BuildRunMethod(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildRunScript(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildRunValueChange(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildReplay(urlOption, formatOption, usernameOption, passwordOption));
@@ -82,13 +84,79 @@ public static class FlowDebugCommands
return cmd;
}
private static Command BuildRunScript(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var instanceIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var scriptOption = new Option<string>("--script")
{
Description = "Canonical script name, as shown by the Flow Debugger picker / the flattened configuration (e.g. Manual or Module.Script)",
Required = true,
};
var paramsOption = new Option<string?>("--params") { Description = "Flat JSON object of parameters for a Call/Interval/Expression/no-trigger script (validated against the script's ParameterDefinitions)" };
var valueOption = new Option<string?>("--value") { Description = "Value to write to the trigger attribute of a ValueChange/Conditional script (canonical string form)" };
var waitOption = new Option<int>("--wait-seconds") { Description = "Write kinds: how long to wait for the triggered run before NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 };
var timeoutOption = new Option<int>("--timeout-seconds")
{
Description = "Client-side wait for the run; raise above the script's ExecutionTimeoutSeconds + 25 for a long script. "
+ "The server caps long-running commands at its own ManagementService:LongRunningCommandTimeout "
+ "(default 5 min), so a larger value still 504s at that ceiling",
DefaultValueFactory = _ => 120,
};
var cmd = new Command("run-script") { Description = "Run one site script with tracing — a ValueChange/Conditional script is fired by writing its trigger attribute (--value); any other script is called directly (--params)" };
cmd.Add(instanceIdOption);
cmd.Add(scriptOption);
cmd.Add(paramsOption);
cmd.Add(valueOption);
cmd.Add(waitOption);
cmd.Add(timeoutOption);
cmd.SetAction(async (ParseResult result) =>
{
var waitSeconds = result.GetValue(waitOption);
var timeoutSeconds = result.GetValue(timeoutOption);
if (waitSeconds <= 0)
{
OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT");
return 1;
}
if (timeoutSeconds <= 0)
{
OutputFormatter.WriteError("--timeout-seconds must be greater than zero.", "INVALID_ARGUMENT");
return 1;
}
JsonElement? parameters;
try
{
parameters = ParseParameters(result.GetValue(paramsOption));
}
catch (JsonException ex)
{
OutputFormatter.WriteError($"--params is not valid JSON: {ex.Message}", "INVALID_PARAMS");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new FlowDebugRunScriptCommand(
result.GetValue(instanceIdOption),
result.GetValue(scriptOption)!,
parameters,
result.GetValue(valueOption),
waitSeconds),
timeout: TimeSpan.FromSeconds(Math.Max(timeoutSeconds, waitSeconds + ClientSlackSeconds)),
tableProjector: FlowDebugTableProjection.ProjectEvents);
});
return cmd;
}
private static Command BuildRunValueChange(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var instanceIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var attributeOption = new Option<string>("--attribute") { Description = "Canonical attribute name to write", Required = true };
var valueOption = new Option<string>("--value") { Description = "Value to write (canonical string form)", Required = true };
var waitOption = new Option<int>("--wait-seconds") { Description = "How long to wait for a triggered script run before reporting NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 };
var cmd = new Command("run-value-change") { Description = "Write an attribute through the traced path and capture the script run(s) the change triggers" };
var cmd = new Command("run-value-change") { Description = "Write an attribute through the traced path and capture the script run(s) the change triggers — the attribute-level path (also fires Expression triggers); prefer run-script to run a named script" };
cmd.Add(instanceIdOption);
cmd.Add(attributeOption);
cmd.Add(valueOption);
+41 -9
View File
@@ -2270,16 +2270,20 @@ scadabridge --url <url> cached-call discard --site-id <string> --tracked-operati
---
### `flow-debug` — Trace an inbound API or value-change flow end to end
### `flow-debug` — Trace an inbound API, script, or value-change flow end to end
The CLI face of the **Flow Debugger** (design `docs/plans/2026-09-05-flow-debugger-design.md`).
A Live run executes the real thing — the method script on the active central node, or a
real attribute write at the site — with tracing switched on for the whole chain, so every
routed site script run, attribute write and attribute wait it causes is recorded as a
`ScriptTrace` audit row and returned inline. Replay reads those rows back for any past
execution. All three sub-commands require the **Deployer** role.
The CLI face of the **Flow Debugger** (design `docs/plans/2026-09-05-flow-debugger-design.md`,
script entry point `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`).
A Live run executes the real thing — the method script on the active central node, a named
site script (called directly, or fired by writing its trigger attribute), or a real attribute
write at the site — with tracing switched on for the whole chain, so every routed site script
run, attribute write and attribute wait it causes is recorded as a `ScriptTrace` audit row and
returned inline. Replay reads those rows back for any past execution. The three run commands
(`run-method`, `run-script`, `run-value-change`) require the **Deployer** role; `replay`
accepts the wider `AuthorizationPolicies.FlowDebuggerAccessRoles` set (Deployer,
Administrator, Viewer — arch-review F3), since it only reads the audit chain.
The two run commands are answered by the node the CLI is pointed at. Through the Traefik
The three run commands are answered by the node the CLI is pointed at. Through the Traefik
load balancer that is the active node; pointed at a standby node directly they refuse with
error code `STANDBY_NODE` rather than run the script somewhere it would not run in
production. `replay` is served from any node.
@@ -2317,10 +2321,38 @@ scadabridge --url <url> flow-debug run-method --method-id <int> [--params <json>
Parameters that fail schema validation are reported as `VALIDATION_FAILED` without running
the script.
#### `flow-debug run-script`
Run one site script by its canonical name with tracing. A `ValueChange`/`Conditional` script
is fired by writing its trigger attribute (`--value`) and waiting for the run it spawns,
exactly like `run-value-change`; every other script (`Call`, `Interval`, `Expression`, or no
trigger) is called directly with `--params`, the same way `run-method` calls an inbound API
method. The runner resolves the script from the instance's flattened configuration and picks
the branch itself — the client does not need to know the script's trigger kind ahead of time.
```sh
scadabridge --url <url> flow-debug run-script --instance-id <int> --script <name> [--params <json>] [--value <string>] [--wait-seconds 45] [--timeout-seconds 120]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--instance-id` | yes | Instance ID |
| `--script` | yes | Canonical script name, as shown by the Flow Debugger picker / the flattened configuration (e.g. `Manual` or `Module.Script`) |
| `--params` | no | Flat JSON object of parameters for a Call/Interval/Expression/no-trigger script, validated against the script's `ParameterDefinitions` |
| `--value` | no | Value to write to the trigger attribute of a ValueChange/Conditional script, in canonical string form |
| `--wait-seconds` | no | Write kinds: how long to wait for the triggered run before reporting `NO_TRIGGER_WITHIN_WINDOW` (default 45) |
| `--timeout-seconds` | no | Client-side wait for the run (default 120). Raise this above the script's own `ExecutionTimeoutSeconds + 25` for a long-running script. **Server ceiling:** the management endpoint applies `ScadaBridge:ManagementService:LongRunningCommandTimeout` (default 5 min) to `flow-debug run-script`, so a value above that still returns `504` at the server's ceiling — raise the server setting too if a script legitimately runs longer. |
A missing `--value` on a write-kind script, or `--params` that fail schema validation on a
call-kind script, are reported as `VALIDATION_FAILED` without running the script. An unknown
`--script` is reported as `NOT_FOUND`.
#### `flow-debug run-value-change`
Write an attribute on a deployed instance through the traced routed-write path, then wait
for the script run(s) the change triggers at the site and print them.
for the script run(s) the change triggers at the site and print them. This is the
attribute-level path (it is also the way to fire an `Expression` trigger, which names no
attribute of its own); prefer `run-script` to run a script by name.
```sh
scadabridge --url <url> flow-debug run-value-change --instance-id <int> --attribute <name> --value <string> [--wait-seconds 45]
@@ -82,14 +82,10 @@
</div>
<div class="col-md-4">
<label class="form-label small">Instance</label>
<select class="form-select form-select-sm" data-test="debug-instance-select"
@bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged" disabled="@_connected">
<option value="0">Select instance...</option>
@foreach (var inst in _siteInstances)
{
<option value="@inst.Id">@inst.UniqueName (@inst.State)</option>
}
</select>
<InstancePicker Areas="_siteAreas" Instances="_siteInstances"
ItemLabel="InstanceLabel"
SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePicked"
Disabled="@(_connected || _instancesLoading)" DataTestPrefix="debug-instance" />
</div>
<div class="col-md-3 d-flex align-items-end gap-2">
@if (!_connected)
@@ -257,11 +253,22 @@
private List<Site> _sites = new();
private List<Instance> _siteInstances = new();
// The site's areas, backing the InstancePicker's tree. Both lists are REPLACED
// (never mutated in place) on a site change — the picker compares them by
// reference and treats a new object as "the site changed".
private List<Area> _siteAreas = new();
private int _selectedSiteId;
private int _selectedInstanceId;
private bool _loading = true;
private bool _connected;
private bool _connecting;
// True for the whole of LoadInstancesForSite. Blazor renders as soon as that load hits its
// first await, and the page is not _connected then — so without this the picker is enabled
// while it is still showing the PREVIOUS site's tree, and anything typed into it is wiped
// when the new lists land (the picker reads a new list object as "the site changed").
private bool _instancesLoading;
private bool _connectedFromStorage;
// Which tab pane is visible — "attributes" (default) or "alarms".
@@ -414,48 +421,93 @@
{
_selectedSiteId = siteId;
await LoadInstancesForSite();
// Membership guard, mirroring the ?instanceId= path above. A stored instance that
// has since been disabled (or deleted, or moved to another site) is not in the list
// the picker was handed, so it renders a blank field while the page holds a non-zero
// id and auto-connects to it anyway. Forget the stored instance instead — the site
// stays selected and loaded, so the operator just picks again.
var stored = _siteInstances.FirstOrDefault(i => i.Id == instanceId);
if (stored == null)
{
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceId");
_toast.ShowInfo(
"The instance from your previous session is no longer available for debug streaming.",
autoDismissMs: 8000);
StateHasChanged();
return;
}
_selectedInstanceId = instanceId;
_connectedFromStorage = true;
StateHasChanged();
await Connect();
// Auto-reconnect notice — the user didn't initiate this connection.
var inst = _siteInstances.FirstOrDefault(i => i.Id == instanceId);
_toast.ShowInfo(
$"Auto-reconnected to {inst?.UniqueName ?? "instance"} from previous session.",
$"Auto-reconnected to {stored.UniqueName} from previous session.",
autoDismissMs: 8000);
}
}
/// <summary>
/// Loads the selected site's instances and areas into the picker's two lists, holding
/// <c>_instancesLoading</c> for the whole round trip so the field cannot be typed into
/// while it is still showing the previous site's tree. The flag is cleared in a
/// <c>finally</c>: a scope refusal or a repository fault must not leave the picker
/// disabled forever.
/// </summary>
private async Task LoadInstancesForSite()
{
_siteInstances.Clear();
_selectedInstanceId = 0;
if (_selectedSiteId == 0) return;
// Site scoping (CentralUI-002): re-check the claim server-side — a query
// string or stale localStorage value could name a site outside the grant.
if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId))
{
_selectedSiteId = 0;
_toast.ShowError("You are not permitted to debug instances on that site.");
return;
}
_instancesLoading = true;
try
{
_siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
.Where(i => i.State == InstanceState.Enabled)
.ToList();
_siteInstances = new();
_siteAreas = new();
_selectedInstanceId = 0;
if (_selectedSiteId == 0) return;
// Site scoping (CentralUI-002): re-check the claim server-side — a query
// string or stale localStorage value could name a site outside the grant.
if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId))
{
_selectedSiteId = 0;
_toast.ShowError("You are not permitted to debug instances on that site.");
return;
}
try
{
_siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
.Where(i => i.State == InstanceState.Enabled)
.ToList();
_siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
}
catch (Exception ex)
{
_toast.ShowError($"Failed to load instances: {ex.Message}");
}
}
catch (Exception ex)
finally
{
_toast.ShowError($"Failed to load instances: {ex.Message}");
_instancesLoading = false;
}
}
private void OnInstanceSelectionChanged()
{
// No-op; selection is tracked via _selectedInstanceId binding
}
/// <summary>
/// The label the <c>InstancePicker</c> renders on a row and in its field:
/// the instance's unique name with its lifecycle state.
/// </summary>
/// <param name="instance">The instance to label.</param>
/// <returns>The display label, e.g. <c>Line1.Mixer (Enabled)</c>.</returns>
private static string InstanceLabel(Instance instance) =>
$"{instance.UniqueName} ({instance.State})";
/// <summary>
/// Records the instance the operator picked in the <c>InstancePicker</c>.
/// Everything downstream (the Connect button's enablement, <c>Connect()</c>
/// itself) reads <c>_selectedInstanceId</c>, so there is nothing else to do.
/// </summary>
/// <param name="id">Id of the picked instance.</param>
private void OnInstancePicked(int id) => _selectedInstanceId = id;
/// <summary>
/// When the attribute filter changes, expand the tree so any branches holding
@@ -597,7 +649,8 @@
await Disconnect();
_selectedSiteId = 0;
_selectedInstanceId = 0;
_siteInstances.Clear();
_siteInstances = new();
_siteAreas = new();
_toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000);
}
@@ -24,8 +24,8 @@
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point">
<button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
<button type="button" class="btn @(_entry == EntryKind.ValueChange ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-value-change" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.ValueChange)">Value-change trigger</button>
<button type="button" class="btn @(_entry == EntryKind.Script ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-script" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Script)">Site script</button>
</div>
</div>
}
@@ -59,25 +59,27 @@
}
</select>
</div>
<div class="col-md-2">
@* Wider than the site select beside it: the field carries a whole instance
name, not a short site name. The script-entry branch still sums to 8 of 12
(site 2 + instance 3 + script 3) alongside the auto-width toolbar columns. *@
<div class="col-md-3">
<label class="form-label form-label-sm">Instance</label>
<select class="form-select form-select-sm" data-test="flow-instance-select"
@bind="_selectedInstanceId" @bind:after="OnInstanceChangedAsync" disabled="@(_running || _selectedSiteId == 0)">
<option value="0">— choose —</option>
@foreach (var i in _instances)
{
<option value="@i.Id">@i.UniqueName</option>
}
</select>
@* A site can hold hundreds of instances, so this is the shared area-tree
combobox, not a flat list (design 2026-09-08). _areas/_instances are
loaded once per site — the picker treats a new list as a site change. *@
<InstancePicker Areas="_areas" Instances="_instances"
SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePickedAsync"
Disabled="@(_running || _selectedSiteId == 0 || _instancesLoading)"
Placeholder="Search instances…" DataTestPrefix="flow-instance" />
</div>
<div class="col-md-2">
<label class="form-label form-label-sm">Attribute</label>
<select class="form-select form-select-sm" data-test="flow-attribute-select"
@bind="_selectedAttribute" @bind:after="OnAttributeChangedAsync" disabled="@(_running || _selectedInstanceId == 0)">
<div class="col-md-3">
<label class="form-label form-label-sm">Script</label>
<select class="form-select form-select-sm" data-test="flow-script-select"
@bind="_selectedScript" @bind:after="OnScriptChangedAsync" disabled="@(_running || _selectedInstanceId == 0)">
<option value="">— choose —</option>
@foreach (var a in _attributes)
@foreach (var s in _scripts)
{
<option value="@a">@a</option>
<option value="@s.CanonicalName">@s.CanonicalName (@s.TriggerLabel)</option>
}
</select>
</div>
@@ -193,7 +195,7 @@
<div class="row g-3">
@* ── Inputs (Live only) ────────────────────────────────────────────── *@
@if (_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0))
@if (_mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _inputFields.Count > 0))
{
<div class="col-lg-3">
<div class="card fd-inputs" data-test="flow-inputs">
@@ -214,13 +216,38 @@
</div>
}
}
else
else if (SelectedScript is { } script)
{
<div class="mb-2">
<label class="form-label form-label-sm">New value for <span class="font-monospace">@(_selectedAttribute == "" ? "…" : _selectedAttribute)</span></label>
<input class="form-control form-control-sm" data-test="flow-input-value"
@bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" />
</div>
@* A write kind is fired by writing its own trigger attribute — the operator
supplies only the value. Every other kind is called directly with the
parameters its definitions declare. *@
@if (script.IsWriteKind)
{
<div class="mb-2">
<label class="form-label form-label-sm">New value for <span class="font-monospace">@(script.TriggerAttribute ?? "…")</span></label>
<input class="form-control form-control-sm" data-test="flow-input-value"
@bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" />
<div class="form-text">@script.TriggerType trigger — writing the attribute fires the script.</div>
</div>
}
else if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
{
<div class="text-muted small" data-test="flow-script-no-params">This script takes no parameters.</div>
}
else
{
<div data-test="flow-script-params">
@* Keyed on the script so a change of script REMOUNTS the form: its raw
text and parse errors are per-instance state that must not survive
(and two scripts may even declare byte-identical definitions, which
would otherwise skip the reparse and its ValidChanged entirely). *@
<ParameterValueForm @key="@script.CanonicalName"
ParameterDefinitions="@script.ParameterDefinitions"
Values="_scriptParams"
ValuesChanged="@(v => _scriptParams = v)"
ValidChanged="@(ok => _scriptParamsValid = ok)" />
</div>
}
}
</div>
</div>
@@ -228,7 +255,7 @@
}
@* ── Flow panel ────────────────────────────────────────────────────── *@
<div class="@(_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0) ? "col-lg-9" : "col-12")">
<div class="@(_mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _inputFields.Count > 0) ? "col-lg-9" : "col-12")">
<div class="fd-flow-tools">
<div class="small text-muted">
@if (_graph == null && _predicted != null)
@@ -254,8 +281,8 @@
@* Retry / Discard relay a command to the site — only wired for a Deployer, so the
buttons never render for an auditor (arch-review F3). *@
<FlowStepDetail Step="_selected" Graph="_graph"
SiteDbId="@(_entry == EntryKind.ValueChange && _selectedSiteId > 0 ? _selectedSiteId : null)"
InstanceDbId="@(_entry == EntryKind.ValueChange && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
SiteDbId="@(_entry == EntryKind.Script && _selectedSiteId > 0 ? _selectedSiteId : null)"
InstanceDbId="@(_entry == EntryKind.Script && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
OnRetry="RetryCallback" OnDiscard="DiscardCallback"
ActionInProgress="_actionInProgress" />
</div>
@@ -21,9 +21,9 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// <summary>
/// Flow Debugger page (design 2026-09-05 §4): pick an entry point (inbound API method or a
/// value-change trigger), see the predicted swimlane, run it live with tracing on the active
/// central node, or replay a past execution from the audit store. State machine
/// Flow Debugger page (design 2026-09-05 §4, script entry 2026-09-08 §5): pick an entry point
/// (inbound API method or a site script), see the predicted swimlane, run it live with tracing on
/// the active central node, or replay a past execution from the audit store. State machine
/// <c>Idle → Predicted → Running → Completed | Failed | TimedOut</c>. The server's
/// <c>STANDBY_NODE</c> refusal renders as an inline alert with the message verbatim.
/// </summary>
@@ -34,8 +34,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
{
/// <summary>An inbound API method.</summary>
Method,
/// <summary>A value-change trigger on an instance attribute.</summary>
ValueChange,
/// <summary>
/// A script on a site instance: a write-kind trigger (ValueChange / Conditional) is fired
/// by writing its monitored attribute, every other kind is called directly.
/// </summary>
Script,
}
/// <summary>Live vs Replay.</summary>
@@ -68,7 +71,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private const string StorageMethod = "flowDebugger.methodId";
private const string StorageSite = "flowDebugger.siteId";
private const string StorageInstance = "flowDebugger.instanceId";
private const string StorageAttribute = "flowDebugger.attribute";
private const string StorageScript = "flowDebugger.script";
private const string StorageInputsPrefix = "flowDebugger.inputs.";
[Inject] private IFlowDebugService FlowDebug { get; set; } = default!;
@@ -83,11 +86,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// <summary>Drill-in: pre-select an inbound method.</summary>
[SupplyParameterFromQuery] public int? MethodId { get; set; }
/// <summary>Drill-in: pre-select an instance (value-change entry).</summary>
/// <summary>Drill-in: pre-select an instance (script entry).</summary>
[SupplyParameterFromQuery] public int? InstanceId { get; set; }
/// <summary>Drill-in: pre-select the trigger attribute (with <see cref="InstanceId"/>).</summary>
[SupplyParameterFromQuery] public string? Attribute { get; set; }
/// <summary>Drill-in: pre-select the script (with <see cref="InstanceId"/>).</summary>
[SupplyParameterFromQuery] public string? Script { get; set; }
/// <summary>Drill-in: replay this execution on first render.</summary>
[SupplyParameterFromQuery] public Guid? ExecutionId { get; set; }
@@ -108,16 +111,44 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private List<ApiMethod> _methods = [];
private List<Site> _sites = [];
/// <summary>
/// The selected site's instances, feeding the <c>InstancePicker</c> alongside
/// <see cref="_areas"/>. The picker compares both by REFERENCE — a new list object is read
/// as "the site changed" and closes its panel — so they are assigned exactly once per site
/// load (<see cref="LoadInstancesAsync"/>) and never rebuilt on a render path.
/// </summary>
private List<Instance> _instances = [];
private List<string> _attributes = [];
/// <summary>The selected site's areas; the picker nests them into its tree. See <see cref="_instances"/>.</summary>
private List<Area> _areas = [];
/// <summary>
/// True while <see cref="LoadInstancesAsync"/> is fetching a site's instances and areas, and
/// the sole reason the picker is disabled for that window. Blazor renders the moment the load
/// hits its first await, and by then <see cref="_selectedSiteId"/> is already the NEW site
/// while the two lists are still the OLD ones — so without this the field is enabled over
/// stale data, and anything typed into it is wiped when the lists land (the picker reads a new
/// list object as "the site changed" and clears its text).
/// </summary>
private bool _instancesLoading;
private List<FlowScriptOption> _scripts = [];
private int _selectedMethodId;
private int _selectedSiteId;
private int _selectedInstanceId;
private string _selectedAttribute = string.Empty;
private string _selectedScript = string.Empty;
private string _valueChangeValue = string.Empty;
private string _replayExecutionIdText = string.Empty;
/// <summary>The chosen script's option row, or null when nothing (or something stale) is selected.</summary>
private FlowScriptOption? SelectedScript
=> _scripts.FirstOrDefault(s => string.Equals(s.CanonicalName, _selectedScript, StringComparison.Ordinal));
/// <summary>Call-kind parameter values from <c>ParameterValueForm</c>, and whether it currently parses.</summary>
private Dictionary<string, object?> _scriptParams = new();
private bool _scriptParamsValid;
private List<InboundApiSchemaField> _inputFields = [];
private readonly Dictionary<string, string> _inputs = new(StringComparer.Ordinal);
@@ -159,9 +190,13 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private bool CanRefresh => !_running && _raw is not null;
// Script entry: a write kind needs the value to write; a call kind needs a parameter form
// that parses (a script declaring no parameters is runnable as soon as it is chosen).
private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && (
_entry == EntryKind.Method ? _selectedMethodId > 0
: _selectedInstanceId > 0 && !string.IsNullOrWhiteSpace(_selectedAttribute));
: SelectedScript is { } s && (s.IsWriteKind
? !string.IsNullOrWhiteSpace(_valueChangeValue)
: string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid));
private string StateLabel => _state switch
{
@@ -222,17 +257,19 @@ public partial class FlowDebugger : ComponentBase, IDisposable
if (InstanceId is int iid && iid > 0)
{
_entry = EntryKind.ValueChange;
_entry = EntryKind.Script;
var inst = await TemplateEngineRepository.GetInstanceByIdAsync(iid);
if (inst != null && await SiteScope.IsSiteAllowedAsync(inst.SiteId))
{
_selectedSiteId = inst.SiteId;
await LoadInstancesAsync();
_selectedInstanceId = inst.Id;
await LoadAttributesAsync();
if (!string.IsNullOrEmpty(Attribute) && _attributes.Contains(Attribute))
await LoadScriptsAsync();
// The link may spell the script in any case; the picker binds the canonical one.
var named = _scripts.FirstOrDefault(s => string.Equals(s.CanonicalName, Script, StringComparison.OrdinalIgnoreCase));
if (named != null)
{
_selectedAttribute = Attribute;
_selectedScript = named.CanonicalName;
await PredictAsync();
}
}
@@ -266,12 +303,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable
try
{
var entry = await JS.InvokeAsync<string?>("localStorage.getItem", StorageEntry);
if (string.Equals(entry, nameof(EntryKind.ValueChange), StringComparison.Ordinal))
if (string.Equals(entry, nameof(EntryKind.Script), StringComparison.Ordinal))
{
_entry = EntryKind.ValueChange;
_entry = EntryKind.Script;
var site = await JS.InvokeAsync<string?>("localStorage.getItem", StorageSite);
var inst = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInstance);
var attr = await JS.InvokeAsync<string?>("localStorage.getItem", StorageAttribute);
var script = await JS.InvokeAsync<string?>("localStorage.getItem", StorageScript);
if (int.TryParse(site, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sid) && _sites.Any(s => s.Id == sid))
{
_selectedSiteId = sid;
@@ -279,10 +316,10 @@ public partial class FlowDebugger : ComponentBase, IDisposable
if (int.TryParse(inst, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iid) && _instances.Any(i => i.Id == iid))
{
_selectedInstanceId = iid;
await LoadAttributesAsync();
if (!string.IsNullOrEmpty(attr) && _attributes.Contains(attr))
await LoadScriptsAsync();
if (_scripts.Any(s => string.Equals(s.CanonicalName, script, StringComparison.Ordinal)))
{
_selectedAttribute = attr;
_selectedScript = script!;
await PredictAsync();
}
}
@@ -313,10 +350,15 @@ public partial class FlowDebugger : ComponentBase, IDisposable
ResetResult();
_predicted = null;
_state = FlowState.Idle;
// Inputs are per-entry-point, as in OnScriptChangedAsync: never carry a script's value or
// parameter tree (or its validity) across a switch of entry kind.
_scriptParams = new();
_scriptParamsValid = false;
_valueChangeValue = string.Empty;
await PersistAsync(StorageEntry, entry.ToString());
if (_entry == EntryKind.Method && _selectedMethodId > 0)
await OnMethodChangedAsync();
else if (_entry == EntryKind.ValueChange && _selectedInstanceId > 0 && _selectedAttribute != string.Empty)
else if (_entry == EntryKind.Script && SelectedScript != null)
await PredictAsync();
}
@@ -355,8 +397,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable
_predicted = null;
_state = FlowState.Idle;
_selectedInstanceId = 0;
_selectedAttribute = string.Empty;
_attributes = [];
_selectedScript = string.Empty;
_scripts = [];
await LoadInstancesAsync();
await PersistAsync(StorageSite, _selectedSiteId.ToString(CultureInfo.InvariantCulture));
}
@@ -366,34 +408,72 @@ public partial class FlowDebugger : ComponentBase, IDisposable
ResetResult();
_predicted = null;
_state = FlowState.Idle;
_selectedAttribute = string.Empty;
await LoadAttributesAsync();
_selectedScript = string.Empty;
await LoadScriptsAsync();
await PersistAsync(StorageInstance, _selectedInstanceId.ToString(CultureInfo.InvariantCulture));
}
private async Task OnAttributeChangedAsync()
private async Task OnScriptChangedAsync()
{
ResetResult();
await PersistAsync(StorageEntry, EntryKind.ValueChange.ToString());
await PersistAsync(StorageAttribute, _selectedAttribute);
await PredictAsync();
_predicted = null;
_state = FlowState.Idle;
// Inputs belong to the chosen script: a write kind's value and a call kind's parameter
// tree are both dropped so nothing carries across a change of script.
_scriptParams = new();
_scriptParamsValid = false;
_valueChangeValue = string.Empty;
await PersistAsync(StorageEntry, nameof(EntryKind.Script));
await PersistAsync(StorageScript, _selectedScript);
if (SelectedScript != null)
await PredictAsync();
}
/// <summary>
/// Loads the selected site's instances and areas into the picker's two lists, holding
/// <see cref="_instancesLoading"/> for the whole round trip so the field cannot be typed
/// into while it is still showing the previous site's tree. The flag is cleared in a
/// <c>finally</c>: a repository fault must not leave the picker disabled forever.
/// </summary>
private async Task LoadInstancesAsync()
{
_instances = _selectedSiteId > 0
? (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList()
: [];
_instancesLoading = true;
try
{
if (_selectedSiteId <= 0)
{
_instances = [];
_areas = [];
return;
}
// Neither list is sorted here: the picker nests and orders both itself
// (InstanceTreeBuilder). The OrderBy this replaces dated from the flat <select>.
_instances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).ToList();
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
}
finally
{
_instancesLoading = false;
}
}
private async Task LoadAttributesAsync()
/// <summary>
/// The picker raises the id rather than binding a &lt;select&gt;, so the page applies it
/// before running the same change handler the old dropdown used.
/// </summary>
private async Task OnInstancePickedAsync(int id)
{
_attributes = [];
var inst = _instances.FirstOrDefault(i => i.Id == _selectedInstanceId);
if (inst == null)
return;
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId);
_attributes = attrs.Select(a => a.Name).Distinct(StringComparer.Ordinal).OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
_selectedInstanceId = id;
await OnInstanceChangedAsync();
}
/// <summary>The instance's scripts, from its flattened configuration (canonical names, effective triggers).</summary>
private async Task LoadScriptsAsync()
{
_scripts = _selectedInstanceId > 0
? (await FlowDebug.GetInstanceScriptsAsync(_selectedInstanceId)).ToList()
: [];
}
private async Task PredictAsync()
@@ -402,7 +482,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
{
_predicted = _entry == EntryKind.Method
? await FlowDebug.PredictMethodAsync(_selectedMethodId)
: await FlowDebug.PredictValueChangeAsync(_selectedInstanceId, _selectedAttribute);
: await FlowDebug.PredictScriptAsync(_selectedInstanceId, _selectedScript);
_state = _predicted.Nodes.Count > 0 ? FlowState.Predicted : FlowState.Idle;
}
catch (Exception ex)
@@ -445,7 +525,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable
}
else
{
outcome = await FlowDebug.RunValueChangeAsync(_selectedInstanceId, _selectedAttribute, _valueChangeValue);
// A write kind is fired by writing its trigger attribute; every other kind is a
// direct traced call carrying the parameter form's values.
var script = SelectedScript!;
outcome = script.IsWriteKind
? await FlowDebug.RunScriptAsync(_selectedInstanceId, script.CanonicalName, null, _valueChangeValue)
: await FlowDebug.RunScriptAsync(_selectedInstanceId, script.CanonicalName, BuildScriptParameters(), null);
}
ApplyOutcome(outcome);
@@ -801,6 +886,14 @@ public partial class FlowDebugger : ComponentBase, IDisposable
return JsonSerializer.SerializeToElement(obj);
}
/// <summary>
/// The call-kind script's parameters as a flat JSON object, or null when the form produced
/// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as
/// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is.
/// </summary>
internal JsonElement? BuildScriptParameters()
=> _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
private static JsonNode? Coerce(string? type, string raw)
{
switch (InboundApiSchema.NormalizeType(type))
@@ -17,10 +17,6 @@
<marker id="fg-arrow-accent" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="fg-arrowhead accent" />
</marker>
@* Trigger spawn (plan D9): a small bolt in place of the arrowhead — this write fired a value-change script. *@
<marker id="fg-arrow-spawn" viewBox="0 0 12 14" refX="10" refY="7" markerWidth="12" markerHeight="14" orient="auto-start-reverse">
<path d="M 5 1 l -4 6 h 5 l -3 6 l 7 -8 h -5 l 3 -4 z" class="fg-arrowhead spawn" />
</marker>
</defs>
@* Lanes *@
@@ -57,6 +53,17 @@
@* Names the branch where it lands: the run carried on without waiting for this. *@
@SvgText(tag.X, tag.Y, "fg-edge-tag", "async", dataTest: "flow-edge-tag")
}
@if (SpawnBadgeFor(e) is { } badge)
{
@* Trigger cascade (plan D9): a bolt badge on the connector itself — this write fired a value-change
script. The edge keeps an ordinary arrowhead; the bolt used to BE the arrowhead, and on the
upward Tags→Site hop it read as a broken arrow (rig, 2026-09-08). *@
<g class="fg-spawn-badge" transform="translate(@badge.X @badge.Y)" data-test="flow-edge-spawn">
<title>Trigger cascade: this write fired the script</title>
<circle r="8" class="fg-spawn-badge-bg" />
<path d="M -1 -6 l -4 6 h 5 l -3 6 l 7 -8 h -5 l 3 -4 z" class="fg-spawn-bolt" />
</g>
}
}
@* Nodes *@
@@ -55,7 +55,7 @@ public sealed record FlowLayoutNode(
/// <param name="External">Dashed — an external actor (the tag source responding).</param>
/// <param name="PredictedOnly">Light — an edge of the prediction, not of a run.</param>
/// <param name="Async">Dashed — a fire-and-forget branch (cached call / notification) the source never waited for (plan D4).</param>
/// <param name="Spawn">Bolt marker — the target is the first step of a trigger-spawned child execution (plan D9).</param>
/// <param name="Spawn">Bolt badge on the connector — the target is the first step of a trigger-spawned child execution (plan D9).</param>
public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly, bool Async = false, bool Spawn = false);
/// <summary>A computed swimlane layout.</summary>
@@ -184,8 +184,9 @@ public partial class FlowGraphView : ComponentBase
return sb.ToString();
}
/// <summary>Every connector ends in an arrowhead — a spawn edge marks itself with a bolt badge on the line instead (<see cref="SpawnBadgeFor"/>).</summary>
private static string MarkerFor(FlowLayoutEdge e)
=> e.Spawn ? "url(#fg-arrow-spawn)" : e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
=> e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
/// <summary>True for a real outbound step (API call / database / notification) — the kinds that carry a status pill instead of a subtitle line.</summary>
private static bool HasPill(FlowLayoutNode n)
@@ -351,6 +352,47 @@ public partial class FlowGraphView : ComponentBase
return b is null ? null : (F(b.X + 22), F(b.Y - 4));
}
/// <summary>
/// Where a spawn edge's bolt badge sits: centred on the connector's vertical run (the riser between the
/// lanes), so the badge is read as a property of the hop and the arrowhead stays an arrowhead. Null for
/// every non-spawn edge.
/// </summary>
private (string X, string Y)? SpawnBadgeFor(FlowLayoutEdge e)
{
if (!e.Spawn) return null;
var a = _layout.Nodes.FirstOrDefault(n => n.Key == e.From);
var b = _layout.Nodes.FirstOrDefault(n => n.Key == e.To);
if (a is null || b is null) return null;
var (x, y) = SpawnBadgePoint(a, b, e.Async);
return (F(x), F(y));
}
/// <summary>
/// Midpoint of the vertical run of the connector <see cref="OrthogonalPath"/> / <see cref="AsyncPath"/>
/// draws from <paramref name="a"/> to <paramref name="b"/> (the middle of a same-lane straight line).
/// Kept in step with those two path builders.
/// </summary>
public static (double X, double Y) SpawnBadgePoint(FlowLayoutNode a, FlowLayoutNode b, bool isAsync)
{
var x1 = a.X + NodeWidth;
var y1 = a.Y + NodeHeight / 2;
var x2 = b.X;
var y2 = b.Y + NodeHeight / 2;
if (isAsync && b.Y > a.Y)
{
// AsyncPath: first vertical run drops from the source's bottom-centre to the strip above the target.
var strip = b.Y - LanePad / 2;
return (a.X + NodeWidth / 2, (a.Y + NodeHeight + strip) / 2);
}
if (Math.Abs(y1 - y2) < 0.01)
return ((x1 + x2) / 2, y1);
if (x2 >= x1)
return (x1 + (x2 - x1) / 2, (y1 + y2) / 2);
var drop = Math.Max(a.Y, b.Y) + NodeHeight + 8;
return ((x1 + 8 + x2 - 8) / 2, drop);
}
private Task SelectAsync(FlowLayoutNode n)
=> n.Step != null && OnSelect.HasDelegate ? OnSelect.InvokeAsync(n.Step) : Task.CompletedTask;
@@ -121,11 +121,13 @@
.fg-edge.boundary { stroke: var(--accent); }
.fg-edge.external { stroke-dasharray: 5 4; }
/* Async branch (cached call / notification): same dash as the echo edge, its own class — the run
carried on without waiting. Spawn: solid line ending in the bolt marker (a trigger cascade). */
carried on without waiting. Spawn: solid line with an ordinary arrowhead plus a bolt badge sitting
on the riser (a trigger cascade) — the bolt is NOT the arrowhead, that read as a broken arrow. */
.fg-edge.async { stroke-dasharray: 5 4; }
.fg-edge.spawn { stroke: var(--ink-soft); }
.fg-edge.predicted { stroke-opacity: 0.5; stroke-dasharray: 3 3; }
.fg-arrowhead { fill: var(--ink-faint); }
.fg-arrowhead.accent { fill: var(--accent); }
.fg-arrowhead.spawn { fill: var(--ink-soft); }
.fg-spawn-badge-bg { fill: var(--bs-body-bg, #fff); stroke: var(--ink-soft); stroke-width: 1; }
.fg-spawn-bolt { fill: var(--ink-soft); }
.fg-wrap ::deep .fg-edge-tag { fill: var(--ink-faint); font-size: 0.6rem; font-family: var(--mono); }
@@ -0,0 +1,58 @@
@*
Instance Picker — a combobox whose dropdown is the site's area tree (design
2026-09-08). The field shows the selected instance; the panel hosts the shared
TreeView over InstanceTreeBuilder's forest with every area collapsed. Clicking an
area row toggles it, activating an instance row selects it and closes the panel —
and "activating" is one path for both input modes, because the tree runs in
Selectable mode and the mouse click reaches it through TreeView's own content span.
Typing filters the forest to the matching instances and their ancestors; Enter picks
the first visible match, ArrowDown hands keyboard navigation to the tree and Escape
closes without changing the selection.
Bootstrap only, no JavaScript: the transparent fixed backdrop closes the panel on
an outside click, the same pattern TreeView uses for its context menu.
*@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
<div class="ip-wrap position-relative" data-test="@($"{DataTestPrefix}-picker")">
@* ip-field lifts the field above the open panel's backdrop — see the .razor.css note. *@
<div class="input-group input-group-sm ip-field">
<input type="text" class="form-control" role="combobox" autocomplete="off"
id="@_inputId" aria-autocomplete="list" aria-haspopup="tree" aria-label="@AriaLabel"
aria-expanded="@(_open ? "true" : "false")" aria-controls="@(_open ? _panelId : null)"
placeholder="@Placeholder" disabled="@Disabled"
data-test="@($"{DataTestPrefix}-input")"
value="@_text" @onfocus="Open" @oninput="OnInput" @onkeydown="OnInputKeyDown" />
<button type="button" class="btn btn-outline-secondary" tabindex="-1" aria-label="Browse instances"
disabled="@Disabled" data-test="@($"{DataTestPrefix}-toggle")" @onclick="Toggle">
<i class="bi @(_open ? "bi-chevron-up" : "bi-chevron-down")"></i>
</button>
</div>
@if (_open)
{
<div class="ip-backdrop" data-test="@($"{DataTestPrefix}-backdrop")" @onclick="Close"></div>
<div class="ip-panel border rounded bg-body shadow-sm" id="@_panelId" data-test="@($"{DataTestPrefix}-panel")">
@* Selectable is what makes the tree's own Enter/Space activation reach us: it
routes BOTH a click on TreeView's content span and a key press on the
treeitem through SelectedKeyChanged, so mouse and keyboard share one path
(the row label below therefore carries no @onclick of its own — a second
handler there would fire twice on a click, since the click bubbles into the
content span). SelectedKey is deliberately left unbound: a combobox shows
its pick in the field, not as a row highlight, and binding it would also arm
TreeView's "selected key no longer in the tree" reset — which fires on every
render while the pick is filtered out, and this component would never clear
it. SelectedCssClass is emptied to match; with no SelectedKey nothing is ever
marked selected, so it is belt-and-braces. *@
<TreeView @ref="_tree" TItem="InstanceTreeNode" Items="_roots"
ChildrenSelector="n => n.Children" HasChildrenSelector="n => n.HasChildren"
KeySelector="n => n.Key" IndentPx="16"
Selectable="true" SelectedCssClass="" SelectedKeyChanged="OnTreeKeyActivatedAsync">
<NodeContent Context="node">
<span class="tv-glyph"><i class="bi @(node.IsInstance ? "bi-box" : "bi-diagram-3")"></i></span>
<span class="tv-label @(node.IsInstance ? "ip-instance" : "ip-area")" title="@node.Label"
data-test="@($"{DataTestPrefix}-row")" data-instance-id="@(node.Instance?.Id)">@RowLabel(node)</span>
</NodeContent>
<EmptyContent><span class="text-muted fst-italic small px-2">@(_hasFilter ? "No instances match." : "No instances at this site.")</span></EmptyContent>
</TreeView>
</div>
}
</div>
@@ -0,0 +1,378 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// A combobox that picks one instance out of a site's area tree: a text field over a
/// dropdown panel hosting <see cref="TreeView{TItem}"/> across the forest that
/// <see cref="InstanceTreeBuilder"/> builds from the site's areas and instances.
///
/// <para>
/// It replaces the flat instance <c>&lt;select&gt;</c> on Flow Debugger and Debug View,
/// where a site with a few hundred instances made the list unusable. The panel opens
/// with every area collapsed so the operator drills down; an outside click closes it
/// through a transparent fixed backdrop (no JavaScript, the same pattern
/// <see cref="TreeView{TItem}"/> uses for its context menu).
/// </para>
///
/// <para>
/// Typing in the field filters the forest to the instances whose <c>UniqueName</c> contains
/// the text plus their ancestor areas, force-expanded so every match is on screen; clearing
/// the text restores the full forest, collapsed. <c>Enter</c> picks the first instance the
/// operator can actually see (document order, skipping collapsed branches), <c>ArrowDown</c>
/// hands keyboard navigation to the tree's own roving tabindex, and <c>Escape</c> closes
/// without changing the selection. The filter never survives a close or a pick.
/// </para>
///
/// <para>
/// Inside the tree, <c>Enter</c>/<c>Space</c> on the focused row does exactly what a click on
/// it does — picks an instance, toggles an area — because the tree runs in
/// <c>Selectable</c> mode and both gestures arrive at
/// <see cref="OnTreeKeyActivatedAsync"/> through TreeView's single selection callback.
/// </para>
///
/// <para>
/// A parent that swaps <see cref="Areas"/>/<see cref="Instances"/> (a site change)
/// closes the panel and re-derives the field text from the current selection, clearing
/// it when the selected instance is not in the new list. A parent that changes
/// <see cref="SelectedInstanceId"/> (a deep link or a localStorage restore) updates the
/// field text while the panel is closed.
/// </para>
/// </summary>
public partial class InstancePicker
{
/// <summary>
/// The site's areas, flat and linked by <see cref="Area.ParentAreaId"/>; null is treated
/// as empty. Compared by REFERENCE: a new list object means "the site changed" and closes
/// the panel, drops the filter and re-derives the field text. Hold a stable reference and
/// only hand the picker a new list when the underlying set actually changed — rebuilding
/// the list on every parent render would close the panel out from under the operator.
/// </summary>
[Parameter]
public IReadOnlyList<Area>? Areas { get; set; }
/// <summary>
/// The site's instances; null is treated as empty. Compared by REFERENCE on the same
/// contract as <see cref="Areas"/> — pass a stable reference and only replace it when the
/// underlying set changed.
/// </summary>
[Parameter]
public IReadOnlyList<Instance>? Instances { get; set; }
/// <summary>Id of the currently selected instance; <c>0</c> means nothing is selected.</summary>
[Parameter]
public int SelectedInstanceId { get; set; }
/// <summary>Raised with the instance id when the operator picks a row.</summary>
[Parameter]
public EventCallback<int> SelectedInstanceIdChanged { get; set; }
/// <summary>Disables the field and the chevron; the panel can no longer be opened.</summary>
[Parameter]
public bool Disabled { get; set; }
/// <summary>
/// Maps an instance to the text shown on its row and, once picked, in the field.
/// Defaults to <see cref="Instance.UniqueName"/>. Note that searching always matches
/// on <c>UniqueName</c> (the builder's contract), whatever this renders.
/// </summary>
[Parameter]
public Func<Instance, string>? ItemLabel { get; set; }
/// <summary>
/// <c>data-test</c> root for the control: <c>{prefix}-picker</c>, <c>-input</c>,
/// <c>-toggle</c>, <c>-panel</c>, <c>-backdrop</c> and <c>-row</c> (instance rows also
/// carry <c>data-instance-id</c>).
/// </summary>
[Parameter]
public string DataTestPrefix { get; set; } = "instance";
/// <summary>Placeholder shown in the field while nothing is selected.</summary>
[Parameter]
public string Placeholder { get; set; } = "Search or browse instances…";
/// <summary>
/// The combobox's accessible name, rendered as <c>aria-label</c> on the field. A page
/// that renders its own visible <c>&lt;label for&gt;</c> should point it at
/// <see cref="InputId"/>; the label wins for a screen reader either way, so this stays
/// as the fallback name rather than being suppressed.
/// </summary>
[Parameter]
public string AriaLabel { get; set; } = "Instance";
/// <summary>
/// DOM id for the text field, so a page can address it from a <c>&lt;label for&gt;</c>.
/// Unset means the per-instance generated id — two pickers on one page never collide.
/// </summary>
[Parameter]
public string? InputId { get; set; }
private readonly string _idSuffix = Guid.NewGuid().ToString("N")[..8];
private bool _open;
private string _text = string.Empty;
/// <summary>
/// The name filter applied to the forest. Empty until the search box lands; kept
/// separate from <see cref="_text"/> so a selected label never doubles as a filter.
/// </summary>
private string _filter = string.Empty;
private bool _hasFilter;
private IReadOnlyList<InstanceTreeNode> _roots = [];
private TreeView<InstanceTreeNode>? _tree;
/// <summary>Bulk expansion the tree owes the current filter, applied after the next render.</summary>
private enum PendingTreeAction
{
/// <summary>Nothing to do.</summary>
None,
/// <summary>Open every branch so the filtered matches are all on screen.</summary>
ExpandAll,
/// <summary>Close every branch, back to the drill-down starting point.</summary>
CollapseAll,
}
// The TreeView only exists once the panel has rendered, so keyboard/typing work that
// targets it is deferred to OnAfterRenderAsync through these two flags.
private PendingTreeAction _pendingTreeAction;
private bool _pendingTreeFocus;
// Last parameter values seen, so OnParametersSet can tell a real change from a
// parent that simply re-rendered with the same lists and selection.
private IReadOnlyList<Area>? _lastAreas;
private IReadOnlyList<Instance>? _lastInstances;
private int _lastSelectedId;
private bool _parametersSeen;
private string _inputId => InputId is { Length: > 0 } id ? id : $"ip-{_idSuffix}-input";
private string _panelId => $"ip-{_idSuffix}-panel";
/// <inheritdoc />
protected override void OnParametersSet()
{
var listsChanged = !_parametersSeen
|| !ReferenceEquals(_lastAreas, Areas)
|| !ReferenceEquals(_lastInstances, Instances);
var selectionChanged = !_parametersSeen || _lastSelectedId != SelectedInstanceId;
_parametersSeen = true;
_lastAreas = Areas;
_lastInstances = Instances;
_lastSelectedId = SelectedInstanceId;
if (!listsChanged && !selectionChanged) return;
var selected = CurrentSelection();
if (listsChanged)
{
// A site change: drop any filter, rebuild, and close unconditionally — the panel
// is showing the OLD site's tree, so leaving it open would be showing a lie even
// when the selected instance happens to exist in the new list too.
_filter = string.Empty;
Rebuild();
_open = false;
_text = selected is null ? string.Empty : LabelOf(selected);
}
else if (!_open)
{
_text = selected is null ? string.Empty : LabelOf(selected);
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
var action = _pendingTreeAction;
var focus = _pendingTreeFocus;
_pendingTreeAction = PendingTreeAction.None;
_pendingTreeFocus = false;
// The panel may have closed again in the same batch (a pick, an Escape), taking
// the tree with it — there is nothing left to expand or focus.
if (!_open || _tree is null) return;
// ExpandAll/CollapseAll re-render the TREE, not this component, so consuming the
// flag here cannot loop back into another picker render.
switch (action)
{
case PendingTreeAction.ExpandAll:
_tree.ExpandAll();
break;
case PendingTreeAction.CollapseAll:
_tree.CollapseAll();
break;
}
if (focus) await _tree.FocusFirstVisibleAsync();
}
/// <summary>The instance <see cref="SelectedInstanceId"/> points at, or null when unresolved.</summary>
private Instance? CurrentSelection() =>
SelectedInstanceId == 0 ? null : Instances?.FirstOrDefault(i => i.Id == SelectedInstanceId);
/// <summary>Rebuilds the forest for the current filter.</summary>
private void Rebuild()
{
_hasFilter = !string.IsNullOrWhiteSpace(_filter);
_roots = InstanceTreeBuilder.Build(Areas, Instances, _filter);
}
private string LabelOf(Instance instance) => ItemLabel?.Invoke(instance) ?? instance.UniqueName;
private string RowLabel(InstanceTreeNode node) =>
node.Instance is { } instance ? LabelOf(instance) : node.Label;
private void Open()
{
if (Disabled || _open) return;
_open = true;
}
private void Toggle()
{
if (Disabled) return;
if (_open) Close(); else _open = true;
}
private void Close()
{
_open = false;
if (_hasFilter)
{
_filter = string.Empty;
Rebuild();
}
var selected = CurrentSelection();
_text = selected is null ? string.Empty : LabelOf(selected);
}
/// <summary>
/// Every keystroke retypes the filter: the field text IS the filter while the operator
/// is typing, and the panel opens if it was closed. The rebuilt forest is expanded when
/// something is being searched for and collapsed back to the drill-down view when the
/// text is cleared — both after the next render, once the tree exists.
/// </summary>
private void OnInput(ChangeEventArgs e)
{
if (Disabled) return;
_text = e.Value?.ToString() ?? string.Empty;
_filter = _text;
_open = true;
Rebuild();
_pendingTreeAction = _hasFilter ? PendingTreeAction.ExpandAll : PendingTreeAction.CollapseAll;
}
/// <summary>
/// The field's keyboard surface. Deliberately no <c>preventDefault</c>: Blazor's flag is
/// all-or-nothing per element, and neither default is harmful here (Enter in a text input
/// outside a form does nothing; ArrowDown just moves the caret) — while suppressing Tab
/// would break getting out of the control.
/// </summary>
private Task OnInputKeyDown(KeyboardEventArgs e)
{
if (Disabled) return Task.CompletedTask;
switch (e.Key)
{
case "Enter" when _open:
return FirstVisibleInstance() is { } instance ? SelectAsync(instance) : Task.CompletedTask;
case "Escape" when _open:
Close();
break;
case "ArrowDown":
_open = true;
_pendingTreeFocus = true;
break;
}
return Task.CompletedTask;
}
/// <summary>
/// The first instance leaf in document order among the rows the operator can actually
/// see — a collapsed area's contents are skipped, matching what Enter appears to pick.
/// With a filter active every branch is expanded, so this degrades to plain document
/// order over the filtered forest.
///
/// <para>
/// Reading <see cref="TreeView{TItem}.IsExpanded"/> is safe here because Blazor Server
/// dispatches every UI event on one renderer sync context: the render and the pending
/// <c>ExpandAll</c>/<c>CollapseAll</c> that <see cref="OnAfterRenderAsync"/> applies both
/// complete before the NEXT keydown is dispatched, so the tree's expansion state can never
/// be one keystroke stale. That would stop holding if the pending action ever became
/// genuinely async (an await that yields inside <see cref="OnAfterRenderAsync"/>); it would
/// then have to be awaited before Enter is allowed to read the tree.
/// </para>
/// </summary>
private Instance? FirstVisibleInstance() => FirstVisibleInstance(_roots);
private Instance? FirstVisibleInstance(IReadOnlyList<InstanceTreeNode> nodes)
{
foreach (var node in nodes)
{
if (node.Instance is { } instance) return instance;
if (!node.HasChildren) continue;
// Before the tree's first render the filter's pending ExpandAll decides visibility.
var expanded = _tree?.IsExpanded(node.Key) ?? _hasFilter;
if (expanded && FirstVisibleInstance(node.Children) is { } hit) return hit;
}
return null;
}
/// <summary>
/// The single activation path for a tree row, reached from TreeView's
/// <c>SelectedKeyChanged</c> — which a mouse click on the row and an
/// <c>Enter</c>/<c>Space</c> on the focused treeitem both run through, so the two input
/// modes can never diverge. An instance key picks that instance; an area key toggles the
/// branch (there is nothing to select on an area, and a keyboard user who cannot open a
/// branch cannot reach the instances inside it).
///
/// <para>
/// Keys are the ones <see cref="InstanceTreeBuilder"/> mints: <c>i:{instanceId}</c> and
/// <c>a:{areaId}</c>. Anything else — including the null TreeView raises when it clears a
/// selection — is ignored rather than guessed at.
/// </para>
/// </summary>
private Task OnTreeKeyActivatedAsync(object? key)
{
if (Disabled || key?.ToString() is not { Length: > 0 } keyStr) return Task.CompletedTask;
if (keyStr.StartsWith("i:", StringComparison.Ordinal))
{
return int.TryParse(keyStr.AsSpan(2), out var instanceId)
&& Instances?.FirstOrDefault(i => i.Id == instanceId) is { } instance
? SelectAsync(instance)
: Task.CompletedTask;
}
if (keyStr.StartsWith("a:", StringComparison.Ordinal)) _tree?.ToggleExpanded(keyStr);
return Task.CompletedTask;
}
private async Task SelectAsync(Instance instance)
{
_text = LabelOf(instance);
_open = false;
if (_hasFilter)
{
_filter = string.Empty;
Rebuild();
}
await SelectedInstanceIdChanged.InvokeAsync(instance.Id);
}
}
@@ -0,0 +1,47 @@
/* Instance Picker combobox — the field, the dropdown panel and the outside-click
backdrop are stacked deliberately: backdrop (1040) < panel (1041) < field (1042). */
/* The backdrop is a full-viewport fixed layer, so an UNPOSITIONED input-group would be
painted underneath it and every click on the field or the chevron would be swallowed
by the backdrop (= panel closes, cannot keep typing). Giving the field its own
positioned layer above both keeps it clickable while the panel is open; the panel
sits at top:100% so being one level lower never hides it. */
.ip-field {
position: relative;
z-index: 1042;
}
/* Transparent full-viewport catcher: a click anywhere outside the panel closes it.
Sits just under the panel and above page content (Bootstrap's dropdown z-index
band), mirroring TreeView's context-menu overlay. */
.ip-backdrop {
position: fixed;
inset: 0;
z-index: 1040;
background: transparent;
}
/* Dropdown panel anchored under the field (the wrapper is position-relative).
Scrolls once the site's tree outgrows 40% of the viewport.
INTEGRATORS: the panel is absolutely positioned INSIDE the picker's wrapper, so any
ancestor with `overflow: hidden`/`auto` (a card body, a scrolling toolbar, a table cell)
clips it — the tree appears truncated or invisible while the field still says it is open.
Place the picker outside such a container, or let that container overflow visibly. */
.ip-panel {
position: absolute;
left: 0;
right: 0;
top: 100%;
z-index: 1041;
max-height: 40vh;
overflow-y: auto;
min-width: 18rem;
padding: 0.25rem;
}
/* Both row kinds are clickable: an area toggles, an instance selects. */
.ip-instance,
.ip-area {
cursor: pointer;
}
@@ -0,0 +1,86 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Builds the Instance Picker's area/instance forest from a site's flat area and
/// instance lists. Pure — no Blazor, no DI, no I/O — so the shape is unit-testable
/// on its own and the picker component only has to render it.
/// </summary>
public static class InstanceTreeBuilder
{
/// <summary>
/// Nests the supplied areas by <see cref="Area.ParentAreaId"/> and hangs each area's
/// instances beneath it, mirroring the Topology page's ordering: at every level the
/// child areas come first (sorted by name, ordinal-ignore-case), then that level's
/// instances (sorted by unique name, ordinal-ignore-case). Instances with no area sit
/// at the root after the root areas. A row whose parent points at an area that is not in
/// <paramref name="areas"/> is treated as a root — both an orphaned area and an instance
/// with an unknown area id — so a stale row can never hide a subtree or an instance.
/// </summary>
/// <param name="areas">The site's areas (flat, linked by <see cref="Area.ParentAreaId"/>); null is treated as empty.</param>
/// <param name="instances">The site's instances; null is treated as empty.</param>
/// <param name="filter">
/// Optional case-insensitive substring matched against <see cref="Instance.UniqueName"/>.
/// When set, only matching instances are kept along with their ancestor areas; areas left
/// with nothing beneath them are dropped. Null or whitespace means no filter.
/// </param>
/// <returns>The root rows of the forest, never null.</returns>
public static IReadOnlyList<InstanceTreeNode> Build(
IReadOnlyList<Area>? areas,
IReadOnlyList<Instance>? instances,
string? filter)
{
areas ??= [];
instances ??= [];
var needle = filter?.Trim();
var hasFilter = !string.IsNullOrEmpty(needle);
var knownAreaIds = areas.Select(a => a.Id).ToHashSet();
// Child areas by effective parent id — null for a root or an orphaned area.
var areasByParent = areas
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.ToLookup(a => a.ParentAreaId is int p && knownAreaIds.Contains(p) ? p : (int?)null);
var instancesByArea = instances
.Where(i => !hasFilter || i.UniqueName.Contains(needle!, StringComparison.OrdinalIgnoreCase))
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.ToLookup(i => i.AreaId is int a && knownAreaIds.Contains(a) ? a : (int?)null);
return BuildLevel(parentId: null, areasByParent, instancesByArea, hasFilter, ancestors: []);
}
private static List<InstanceTreeNode> BuildLevel(
int? parentId,
ILookup<int?, Area> areasByParent,
ILookup<int?, Instance> instancesByArea,
bool hasFilter,
HashSet<int> ancestors)
{
var level = new List<InstanceTreeNode>();
foreach (var area in areasByParent[parentId])
{
// Defensive: a cycle in ParentAreaId must never recurse forever.
if (!ancestors.Add(area.Id)) continue;
var node = new InstanceTreeNode($"a:{area.Id}", area.Name, area, Instance: null);
foreach (var child in BuildLevel(area.Id, areasByParent, instancesByArea, hasFilter, ancestors))
{
node.AddChild(child);
}
ancestors.Remove(area.Id);
// With a filter an area survives only if something matched beneath it.
if (!hasFilter || node.HasChildren) level.Add(node);
}
level.AddRange(instancesByArea[parentId].Select(i =>
new InstanceTreeNode($"i:{i.Id}", i.UniqueName, Area: null, Instance: i)));
return level;
}
}
@@ -0,0 +1,30 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// One row of the Instance Picker tree: an area branch or an instance leaf.
/// Keys are <c>a:{areaId}</c> for areas and <c>i:{instanceId}</c> for instances.
/// Children are populated by <see cref="InstanceTreeBuilder"/> and read-only to callers.
/// </summary>
/// <param name="Key">Stable tree key — <c>a:{areaId}</c> or <c>i:{instanceId}</c>.</param>
/// <param name="Label">Text rendered for this row.</param>
/// <param name="Area">The area this node represents, or null for an instance leaf.</param>
/// <param name="Instance">The instance this node represents, or null for an area branch.</param>
public sealed record InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance)
{
private readonly List<InstanceTreeNode> _children = new();
/// <summary>Gets the child rows nested under this node (child areas first, then instances).</summary>
public IReadOnlyList<InstanceTreeNode> Children => _children;
/// <summary>Gets a value indicating whether this node is a selectable instance leaf.</summary>
public bool IsInstance => Instance is not null;
/// <summary>Gets a value indicating whether this node has any child rows.</summary>
public bool HasChildren => _children.Count > 0;
/// <summary>Appends a child row. Only the builder composes the tree.</summary>
/// <param name="child">The child row to append.</param>
internal void AddChild(InstanceTreeNode child) => _children.Add(child);
}
@@ -48,6 +48,13 @@ else
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
/// <summary>
/// Raised after every edit with <c>true</c> when the value tree has no parse or validation errors
/// (Flow Debugger script entry, design 2026-09-08 §5: the page gates Run on it). Additive; existing
/// callers ignore it.
/// </summary>
[Parameter] public EventCallback<bool> ValidChanged { get; set; }
private IReadOnlyList<ParameterShape> _shapes = Array.Empty<ParameterShape>();
private IReadOnlyList<InboundApiSchemaField> _topLevelFields = Array.Empty<InboundApiSchemaField>();
private InboundApiSchema? _rootSchema;
@@ -70,6 +77,44 @@ else
_shapes = ScriptParameterNames.ParseShapes(ParameterDefinitions);
_rootSchema = await ParseRootAsync();
_topLevelFields = _rootSchema?.Type == "object" ? _rootSchema.Fields : Array.Empty<InboundApiSchemaField>();
// Report validity for the freshly-(re)parsed schema so a caller gating a Run
// button on ValidChanged never starts (or carries over from a prior script)
// a stale "valid" reading when required fields are unset. This deliberately
// does NOT call Validate()/populate _parseErrors: the component has no
// "untouched field" concept -- RenderError paints whatever is in
// _parseErrors unconditionally -- so doing that here would immediately
// paint every required field red before the user has touched anything. The
// silent check below computes the same answer without that side effect;
// _parseErrors (and the visible red text) still only appears once editing
// drives it through Emit()/Validate() as before.
await ValidChanged.InvokeAsync(ComputeIsValid());
}
// Mirrors Validate()'s schema pass (plus any raw-text parse markers already
// parked in _parseErrors) WITHOUT writing to _parseErrors, so it can run on
// every schema (re)parse without painting untouched required fields red.
private bool ComputeIsValid()
{
if (_parseErrors.Count > 0)
{
return false;
}
if (_rootSchema is null)
{
return true;
}
try
{
var element = JsonSerializer.SerializeToElement(Values);
var errors = new List<string>();
_rootSchema.Validate(element, string.Empty, errors);
return errors.Count == 0;
}
catch (JsonException)
{
return false;
}
}
/// <summary>
@@ -620,8 +665,11 @@ else
{
Validate();
await ValuesChanged.InvokeAsync(Values);
await ValidChanged.InvokeAsync(IsValid);
}
private bool IsValid => _parseErrors.Count == 0;
// Validates the whole value tree against the parsed schema and projects each
// path-qualified error onto the input that owns it. Raw-text parse failures
// (numeric / JSON) are added directly by the setters; schema validation adds
@@ -601,6 +601,35 @@ else
/// <summary>Whether the node with the given key is currently expanded.</summary>
public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key));
/// <summary>
/// Make the first visible node the roving-tabindex target and pull browser focus to it,
/// so a host control above the tree (a combobox field handing over on ArrowDown) can
/// enter the tree's own arrow navigation. Focus is applied by the existing
/// <c>_focusNeedsApply</c> path after the re-render this schedules. No-op on an empty tree.
/// </summary>
/// <returns>A completed task once the focus request has been scheduled.</returns>
public Task FocusFirstVisibleAsync()
{
var visible = BuildVisibleNodes();
if (visible.Count == 0) return Task.CompletedTask;
MoveFocusTo(visible[0].Key);
StateHasChanged();
return Task.CompletedTask;
}
/// <summary>
/// Expand the node with the given key if it is collapsed, collapse it otherwise —
/// the same toggle the row chevron performs, for a consumer whose own
/// <c>NodeContent</c> handles the click (a label that toggles its branch).
/// </summary>
/// <param name="key">Key of the node to toggle, as produced by <c>KeySelector</c>.</param>
public void ToggleExpanded(object key)
{
ToggleExpand(key);
StateHasChanged();
}
/// <summary>Expand every branch node in the tree.</summary>
public void ExpandAll()
{
@@ -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;
@@ -77,15 +85,36 @@ 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. A scoped service in Blazor Server lives for the
/// whole CIRCUIT, so this is emphatically not a per-interaction memo: it is refreshed by
/// every <see cref="GetInstanceScriptsAsync"/> call (the page makes one per instance
/// selection), and only the prediction and the run read it back. That keeps one flatten per
/// picker selection while making a re-selection pick up a template edit or a redeploy — a
/// cache held for the session would otherwise serve a stale parameter schema (unexplained
/// <c>VALIDATION_FAILED</c>) and a stale Ask bound against a runner that re-flattens on every
/// run. 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 +122,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 +191,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 FlattenScriptsAsync(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 CachedScriptsAsync(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 +236,30 @@ 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 (falling back to the runner's own
// FlowDebugRunner.DefaultScriptTimeoutSeconds so client and server agree on the bound),
// a write kind by the trigger-wait window. When the script cannot be resolved at all the
// server may still take EITHER branch, so the fallback is the longer of the two — a
// client Ask shorter than the server's bound would report a phantom timeout on a run
// that is still going.
var command = new FlowDebugRunScriptCommand(instanceId, scriptName, parameters, value);
var option = (await CachedScriptsAsync(instanceId, cancellationToken))?.Options
.FirstOrDefault(o => o.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase));
var writeKindWait = ValueChangeAskTimeoutFor(command.WaitSeconds);
var defaultCallWait = RunAskTimeoutFor(FlowDebugRunner.DefaultScriptTimeoutSeconds);
var timeout = option switch
{
{ IsWriteKind: false } => RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? FlowDebugRunner.DefaultScriptTimeoutSeconds),
not null => writeKindWait,
null => writeKindWait > defaultCallWait ? writeKindWait : defaultCallWait,
};
return await SendAndBuildAsync(command, timeout, cancellationToken);
}
/// <inheritdoc/>
@@ -366,38 +412,108 @@ 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>
/// The instance's scripts as the current picker selection knows them: the cached entry a
/// <see cref="GetInstanceScriptsAsync"/> call just refreshed, or a fresh flatten when the
/// prediction / run is reached without one (a deep link straight into a run).
/// </summary>
private async Task<InstanceScripts?> CachedScriptsAsync(int instanceId, CancellationToken ct)
=> _scriptCache.TryGetValue(instanceId, out var cached) ? cached : await FlattenScriptsAsync(instanceId, ct);
/// <summary>
/// Flattens an instance's scripts (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 — and refreshes the cache entry, so a template edit or a redeploy is picked up on the
/// next picker selection. Returns null when the instance does not exist (Debug — an id from a
/// stale link) or the flatten failed (Warning — the same severity <c>DeploymentService</c> and
/// the Configure page give it, since the instance could not be deployed either); either way
/// the stale entry is dropped rather than served on. Neither is a page error: the picker
/// simply has no scripts to offer.
/// </summary>
private async Task<InstanceScripts?> FlattenScriptsAsync(int instanceId, CancellationToken ct)
{
if (!string.Equals(script.TriggerType, "valuechange", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(script.TriggerType, "ValueChange", StringComparison.OrdinalIgnoreCase))
return false;
var cfg = script.TriggerConfiguration;
if (string.IsNullOrWhiteSpace(cfg))
return false;
try
var instance = await _templates.GetInstanceByIdAsync(instanceId, ct);
if (instance == null)
{
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.
_scriptCache.Remove(instanceId);
return null;
}
return false;
var flattened = await _pipeline.FlattenAndValidateAsync(instance.Id, ct, validateScripts: false);
if (flattened.IsFailure)
{
_logger.LogWarning(
"Flow Debugger could not flatten instance {InstanceId} ({UniqueName}): {Error}",
instanceId, instance.UniqueName, flattened.Error);
_scriptCache.Remove(instanceId);
return null;
}
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 four management commands (Live run of a
/// method, of a script, of a value change; 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>
@@ -24,6 +24,27 @@ public record FlowDebugRunMethodCommand(int ApiMethodId, JsonElement? Parameters
/// <param name="WaitSeconds">How long to wait for a spawned run before reporting <c>NO_TRIGGER_WITHIN_WINDOW</c>.</param>
public record FlowDebugRunValueChangeCommand(int InstanceId, string AttributeName, string Value, int WaitSeconds = 45);
/// <summary>
/// Flow Debugger Live run of a site script by name (design 2026-09-08). The runner resolves the
/// script's effective definition from the flattened instance configuration and branches on its
/// trigger kind: a <c>ValueChange</c>/<c>Conditional</c> script is fired by WRITING its monitored
/// attribute (<paramref name="Value"/>) and polling for the spawned run, exactly like
/// <see cref="FlowDebugRunValueChangeCommand"/>; every other kind is invoked DIRECTLY through the
/// traced routed-call path with <paramref name="Parameters"/>. Deployer role; refuses on a standby
/// with <c>STANDBY_NODE</c>.
/// </summary>
/// <param name="InstanceId">The instance the script belongs to.</param>
/// <param name="ScriptName">Canonical script name from the flattened configuration (case-insensitive).</param>
/// <param name="Parameters">Call kinds: flat JSON object validated against the script's ParameterDefinitions; null = none.</param>
/// <param name="Value">Write kinds: canonical string value to write to the trigger's attribute.</param>
/// <param name="WaitSeconds">Write kinds: how long to wait for a spawned run before <c>NO_TRIGGER_WITHIN_WINDOW</c>.</param>
public record FlowDebugRunScriptCommand(
int InstanceId,
string ScriptName,
JsonElement? Parameters,
string? Value,
int WaitSeconds = 45);
/// <summary>
/// Flow Debugger Replay (design 2026-09-05 §7.1): returns the audit events of the execution
/// tree that <paramref name="ExecutionId"/> belongs to, from the central store.
@@ -27,7 +27,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <c>Notify</c>, or a cached operation whose first row is <c>CachedSubmit</c>. Each skipped step instead gets an
/// <c>Async</c> (dashed) edge from the chain step immediately before it in time.</item>
/// <item>The first step of an execution is linked from its spawner: a <c>RouteCall</c> whose
/// <c>childExecutionId</c> matches, else — a <c>Spawn</c> edge (trigger cascade) — the last non-async,
/// <c>childExecutionId</c> matches, else — a <c>Spawn</c> edge (trigger cascade) — the parent's last
/// <c>AttributeWrite</c> of the attribute the child's <c>triggerAttribute</c> names (at or before the child's
/// first step, else the last such write at all), and only when the child names no attribute or the parent
/// never wrote it, the last non-async,
/// non-<c>Response</c> parent step that <b>occurred at or before</b> the child's first step. Parent and
/// child of a cascade share the site clock, so that timestamp rule picks the write that actually fired
/// the trigger; only when it finds nothing (a child clock BEHIND its parent's — a mixed-clock Replay)
@@ -146,11 +149,21 @@ public static class FlowGraphBuilder
// (a run shaped ScriptRun → Write(A) → Wait → Write(B) would spawn from Write(B)).
// Position stays as the fallback for the mixed-clock case F5 was written for — a child
// clock behind its parent's, where no parent step satisfies the time rule.
// Before either rule: when the child names the attribute that fired it, the spawner is the
// parent's last write OF THAT ATTRIBUTE. Timestamps alone cannot separate a burst of writes
// that all land in the same millisecond (a run writing Result, ResultText, Flag=false, Processed
// back-to-back spawned its value-change child from the LAST of them on the rig, not from Flag).
var childTime = first.OccurredAtUtc;
var childPosition = position[first.EventId];
spawner = parentChain.LastOrDefault(p => Spawnable(p) && p.OccurredAtUtc <= childTime)
?? parentChain.LastOrDefault(p => Spawnable(p) && position[p.EventId] < childPosition)
?? parentChain[0];
var triggerAttribute = first.Extra?.TriggerAttribute;
if (!string.IsNullOrEmpty(triggerAttribute))
{
spawner = parentChain.LastOrDefault(p => WritesAttribute(p, triggerAttribute) && p.OccurredAtUtc <= childTime)
?? parentChain.LastOrDefault(p => WritesAttribute(p, triggerAttribute));
}
spawner ??= parentChain.LastOrDefault(p => Spawnable(p) && p.OccurredAtUtc <= childTime)
?? parentChain.LastOrDefault(p => Spawnable(p) && position[p.EventId] < childPosition)
?? parentChain[0];
}
if (spawner is null) continue;
@@ -228,6 +241,12 @@ public static class FlowGraphBuilder
private static bool Spawnable(FlowStep step)
=> step.Kind != FlowStepKind.Response && !IsAsync(step);
/// <summary>Is this an <c>AttributeWrite</c> step whose write list names <paramref name="attribute"/> (ordinal, case-insensitive)?</summary>
private static bool WritesAttribute(FlowStep step, string attribute)
=> step.Kind == FlowStepKind.AttributeWrite
&& step.Extra?.Writes is { Count: > 0 } writes
&& writes.Any(w => string.Equals(w.Attribute, attribute, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// A step the script did not wait for: a notification, or a cached operation — any API / DB step whose
/// first lifecycle row is not the sync one-shot kind (<c>ApiCall</c> / <c>DbWrite</c>), the same rule the
@@ -0,0 +1,49 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
/// <summary>
/// Reads the single monitored attribute of a <c>ValueChange</c> or <c>Conditional</c> script
/// trigger (Flow Debugger script entry, design 2026-09-08 §2). The config keys are the ones the
/// Central UI's <c>ScriptTriggerConfigCodec</c> writes (<c>attributeName</c>) and SiteRuntime's
/// <c>TriggerRouting</c> also accepts (<c>attribute</c>). Pure; never throws on bad JSON.
/// </summary>
public static class ScriptTriggerAttribute
{
/// <summary>True for the trigger kinds the Flow Debugger fires by WRITING the monitored attribute.</summary>
public static bool IsWriteKind(string? triggerType) =>
string.Equals(triggerType, "ValueChange", StringComparison.OrdinalIgnoreCase)
|| string.Equals(triggerType, "Conditional", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Returns the monitored attribute for a write-kind trigger with a readable configuration;
/// false (and null) for every other kind, and for a write kind whose config is missing,
/// malformed, not an object, or names no attribute.
/// </summary>
public static bool TryRead(string? triggerType, string? triggerConfigurationJson, out string? attribute)
{
attribute = null;
if (!IsWriteKind(triggerType) || string.IsNullOrWhiteSpace(triggerConfigurationJson))
return false;
try
{
using var doc = JsonDocument.Parse(triggerConfigurationJson);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
foreach (var key in new[] { "attributeName", "attribute" })
{
if (root.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
&& v.GetString() is { Length: > 0 } name)
{
attribute = name;
return true;
}
}
return false;
}
catch (JsonException)
{
return false;
}
}
}
@@ -3,6 +3,8 @@ using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
@@ -10,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;
@@ -37,6 +41,27 @@ 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>
/// Ceiling on a write-kind run's spawned-run wait, whatever the command asks for. It must stay
/// UNDER <c>ManagementServiceOptions.LongRunningCommandTimeout</c> (5 minutes by default), which
/// is the Ask the management endpoint wraps this command in: a longer wait would leave the poll
/// loop running on the node after the caller's Ask has already expired, holding its scoped
/// provider and pulling from the site for a result nobody can receive.
/// </summary>
public const int MaxWaitSeconds = 300;
/// <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
@@ -142,8 +167,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");
result = new InboundScriptResult(false, null, TimeoutMessage(timeout), TimeoutCode);
}
}
@@ -211,6 +235,180 @@ public sealed class FlowDebugRunner
.GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false)
?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode);
return await WriteAndPollAsync(
sp, user, instance, site, cmd.AttributeName, cmd.Value, cmd.WaitSeconds, triggerScript: null, ct)
.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;
// Null while the site ANSWERED (a success or a Success=false reply); otherwise the code to
// report for a routed Ask that threw instead of answering.
string? faultCode = null;
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)
{
faultCode = TimeoutCode;
response = new RouteToCallResponse(
request.CorrelationId, false, null, TimeoutMessage(timeout), DateTimeOffset.UtcNow);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// RouteToCallAsync is an Akka Ask bounded by CommunicationOptions.IntegrationTimeout,
// so it throws AskTimeoutException (NOT an OperationCanceledException) when the site
// never answers — and any transport fault throws too. "No answer" is NOT "did not
// happen": the site may already have run the script. Letting the exception escape
// would lose the root row entirely and show the operator MapFault's generic internal
// error for a call that ran, so it is converted into a failed response the same way
// RouteHelper.CallAsync types its Ask faults and WriteAndPollAsync reports a failed
// write. A cancellation of the CALLER's token still propagates (filtered out above).
faultCode = ex is Akka.Actor.AskTimeoutException ? TimeoutCode : CallFailedCode;
_logger.LogWarning(ex,
"Flow Debugger routed call to site {Site} for {Instance}.{Script} faulted",
site.SiteIdentifier, instance.UniqueName, script.CanonicalName);
response = new RouteToCallResponse(
request.CorrelationId, false, null, ex.Message, DateTimeOffset.UtcNow);
}
}
// Everything from here to WriteCentralAsync runs AFTER the site already executed the
// script — a real side effect. Nothing in this stretch may throw, or the root row is never
// written and the operator sees ManagementActor.MapFault's generic "internal error" for a
// call that actually succeeded. So the trace drain skips a faulting event and the return
// value falls back to a placeholder, the same way InboundScriptExecutor turns a script's
// own faults into a captured error rather than an escaping exception.
foreach (var e in response.TraceEvents ?? [])
{
try
{
collector.Add(e);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Flow Debugger dropped an inline trace event from the run of {Script} on {Instance}",
script.CanonicalName, instance.UniqueName);
}
}
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: SerializeReturnValue(response.ReturnValue),
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 : faultCode ?? 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
/// caller has already gated the active node, loaded and scope-checked the instance, and loaded the
/// site. <paramref name="triggerScript"/> is null for the attribute-level command and the script's
/// canonical name for the script entry — it only changes the NO_TRIGGER message.
/// </summary>
private async Task<FlowDebugRunResult> WriteAndPollAsync(
IServiceProvider sp, AuthenticatedUser user, Instance instance, Site site,
string attributeName, string? value, int waitSeconds, string? triggerScript, CancellationToken ct)
{
var root = Guid.NewGuid();
var startedAt = DateTimeOffset.UtcNow;
var sw = Stopwatch.StartNew();
@@ -223,7 +421,7 @@ public sealed class FlowDebugRunner
site.SiteIdentifier,
new RouteToSetAttributesRequest(
Guid.NewGuid().ToString(), instance.UniqueName,
new Dictionary<string, string> { [cmd.AttributeName] = cmd.Value ?? string.Empty },
new Dictionary<string, string> { [attributeName] = value ?? string.Empty },
DateTimeOffset.UtcNow, ParentExecutionId: root, Trace: true),
ct).ConfigureAwait(false);
@@ -232,18 +430,18 @@ public sealed class FlowDebugRunner
write.Success ? AuditStatus.Delivered : AuditStatus.Failed,
occurredAtUtc: startedAt.UtcDateTime,
actor: user.Username,
target: cmd.AttributeName,
target: attributeName,
sourceNode: sp.GetService<INodeIdentityProvider>()?.NodeName,
executionId: root,
sourceSiteId: site.SiteIdentifier,
sourceInstanceId: instance.UniqueName,
durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue),
errorMessage: write.ErrorMessage,
requestSummary: JsonSerializer.Serialize(new { attribute = cmd.AttributeName, value = cmd.Value }, ScriptTraceExtra.JsonOptions),
requestSummary: JsonSerializer.Serialize(new { attribute = attributeName, value = value }, ScriptTraceExtra.JsonOptions),
extra: new ScriptTraceExtra
{
Source = SourceTag,
Writes = [new AttributeWriteDetail(cmd.AttributeName, cmd.Value, write.Success, write.ErrorMessage)],
Writes = [new AttributeWriteDetail(attributeName, value, write.Success, write.ErrorMessage)],
}.ToJson());
await WriteCentralAsync(sp, rootRow, ct).ConfigureAwait(false);
var redactedRoot = redactor?.Apply(rootRow) ?? rootRow;
@@ -254,11 +452,26 @@ public sealed class FlowDebugRunner
[redactedRoot], false, startedAt, DateTimeOffset.UtcNow);
}
// The run that COUNTS as completion. For the script entry (design 2026-09-08 §4.3) that is
// the script the operator chose, matched on Target — the site stamps ScriptRunLauncher's
// `target: scriptName` on the row. Other scripts the same write happens to fire are still
// collected below (the graph draws the siblings) but must never report the CHOSEN script as
// having run. triggerScript == null is the attribute-level command, which expects no
// particular script, so any spawned run satisfies it.
bool IsAwaitedRun(AuditEvent e)
{
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
if (d.Kind != nameof(AuditKind.ScriptRun) || d.ParentExecutionId != root) return false;
return triggerScript is null
|| string.Equals(e.Target, triggerScript, StringComparison.OrdinalIgnoreCase);
}
// Poll the site for the traced run(s) the write spawned — matched by ParentExecutionId
// == root, then their children — until a ScriptRun parented to root exists AND the
// set has been quiet for SettleWindow, or WaitSeconds elapses.
// == root, then their children — until the awaited ScriptRun exists AND the set has been
// quiet for SettleWindow, or the (clamped) wait elapses.
var puller = sp.GetRequiredService<IPullTraceEventsClient>();
var deadline = _now() + TimeSpan.FromSeconds(Math.Max(1, cmd.WaitSeconds));
var wait = Math.Clamp(waitSeconds, 1, MaxWaitSeconds);
var deadline = _now() + TimeSpan.FromSeconds(wait);
var known = new Dictionary<Guid, AuditEvent>();
DateTimeOffset? lastNew = null;
var since = startedAt.UtcDateTime.AddSeconds(-5);
@@ -278,11 +491,7 @@ public sealed class FlowDebugRunner
}
if (added) lastNew = _now();
var spawned = known.Values.Any(e =>
{
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
return d.Kind == nameof(AuditKind.ScriptRun) && d.ParentExecutionId == root;
});
var spawned = known.Values.Any(IsAwaitedRun);
var now = _now();
if (spawned && lastNew is { } ln && now - ln >= SettleWindow) break;
if (now >= deadline) break;
@@ -291,14 +500,12 @@ public sealed class FlowDebugRunner
var ordered = new List<AuditEvent> { redactedRoot };
ordered.AddRange(known.Values.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId));
var completed = known.Values.Any(e =>
{
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
return d.Kind == nameof(AuditKind.ScriptRun) && d.ParentExecutionId == root;
});
var completed = known.Values.Any(IsAwaitedRun);
return new FlowDebugRunResult(
root, completed,
completed ? null : $"No traced run was spawned within {cmd.WaitSeconds}s (no value-change trigger on '{cmd.AttributeName}', or no device echo).",
completed ? null : triggerScript is null
? $"No traced run was spawned within {wait}s (no value-change trigger on '{attributeName}', or no device echo)."
: $"No traced run of '{triggerScript}' was spawned within {wait}s after writing '{attributeName}' (trigger condition not met, or no device echo).",
completed ? null : NoTriggerCode,
ordered, false, startedAt, DateTimeOffset.UtcNow);
}
@@ -322,6 +529,40 @@ public sealed class FlowDebugRunner
return new FlowDebugRunResult(rootId, true, null, null, events, false, startedAt, DateTimeOffset.UtcNow);
}
/// <summary>
/// The timed-out message shared by both Live entry points, so the two paths never drift.
/// </summary>
private static string TimeoutMessage(TimeSpan timeout) =>
$"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace";
/// <summary>
/// Serializes a routed call's return value for the root row's <c>ResponseSummary</c>, NEVER
/// throwing: <see cref="ScriptTraceExtra.JsonOptions"/> rejects named floating-point literals,
/// so a script returning <c>double.NaN</c> / an infinity — or any other value the serializer
/// cannot represent — would otherwise take down the audit write of a call that already ran.
/// The placeholder keeps the row (and the operator's Live result) intact.
///
/// <para>
/// The catch is deliberately broad (as <see cref="WriteCentralAsync"/>'s already is): the value
/// crossing this seam came from a user-authored script, and the serializer's failure mode varies
/// by value — <c>ArgumentException</c> for a non-finite number, <see cref="JsonException"/> for a
/// cycle, <see cref="NotSupportedException"/> for an unmappable type. A best-effort audit summary
/// must not be the thing that fails a call the site already ran.
/// </para>
/// </summary>
private static string? SerializeReturnValue(object? returnValue)
{
if (returnValue is null) return null;
try
{
return JsonSerializer.Serialize(returnValue, ScriptTraceExtra.JsonOptions);
}
catch (Exception ex)
{
return $"<unserializable return value: {ex.GetType().Name}>";
}
}
private static void EnsureActiveNode(IServiceProvider sp)
{
var gate = sp.GetService<IActiveNodeGate>();
@@ -197,7 +197,7 @@ public class ManagementActor : ReceiveActor
// arch-review F3: Flow Debugger Replay is an audit read, so it carries the same role
// set as the RequireFlowDebugger page policy ({Deployer, Administrator, Viewer}). The
// two Live run commands stay DeployerOnly.
// three Live run commands stay DeployerOnly.
private static readonly string[] FlowDebuggerReplayRoles = AuthorizationPolicies.FlowDebuggerAccessRoles;
// arch-review C6: area management is reconciled to any-of [Designer, Deployer].
@@ -310,12 +310,13 @@ public class ManagementActor : ReceiveActor
or RetryParkedMessageCommand or DiscardParkedMessageCommand
or DebugSnapshotCommand => DeployerOnly,
// Flow Debugger (design 2026-09-05 §9 / D6, arch-review F3). A Live run executes
// scripts and writes attributes on operator input, so it stays Deployer-only, like
// the Debug View. Replay only reads the audit chain, so it additionally accepts the
// OperationalAudit roles — the Audit Log / Execution Chain drill-ins are the
// feature's main entry for a past incident and an auditor must be able to use them.
FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand => DeployerOnly,
// Flow Debugger (design 2026-09-05 §9 / D6, arch-review F3; script entry added by
// design 2026-09-08). All three Live run commands execute scripts and write
// attributes on operator input, so they stay Deployer-only, like the Debug View.
// Replay only reads the audit chain, so it additionally accepts the OperationalAudit
// roles — the Audit Log / Execution Chain drill-ins are the feature's main entry for
// a past incident and an auditor must be able to use them.
FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand or FlowDebugRunScriptCommand => DeployerOnly,
FlowDebugReplayCommand => FlowDebuggerReplayRoles,
// Two-person secured write. Submit is an Operator action;
@@ -418,6 +419,7 @@ public class ManagementActor : ReceiveActor
// Flow Debugger (design 2026-09-05 §7.1) — one execution path for the UI and the CLI.
FlowDebugRunMethodCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().RunMethodAsync(sp, cmd, user, CancellationToken.None),
FlowDebugRunValueChangeCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().RunValueChangeAsync(sp, cmd, user, CancellationToken.None),
FlowDebugRunScriptCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().RunScriptAsync(sp, cmd, user, CancellationToken.None),
FlowDebugReplayCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().ReplayAsync(sp, cmd, CancellationToken.None),
// External Systems
@@ -58,6 +58,9 @@ public static class ManagementEndpoints
// Flow Debugger (design 2026-09-05 §7.1): a Live run lasts up to the method timeout
// (+ grace) or the value-change wait window — never the ordinary Ask timeout.
typeof(FlowDebugRunMethodCommand), typeof(FlowDebugRunValueChangeCommand),
// script entry (design 2026-09-08): a direct call lasts up to the script's timeout
// (+ grace), a write kind up to its wait window.
typeof(FlowDebugRunScriptCommand),
];
/// <summary>
@@ -14,7 +14,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddManagementService(this IServiceCollection services)
{
services.AddSingleton<ManagementActorHolder>();
// Flow Debugger (design 2026-09-05 §7.1): the runner behind the three FlowDebug* commands.
// Flow Debugger (design 2026-09-05 §7.1): the runner behind the four FlowDebug* commands (three Live runs + Replay).
services.AddSingleton<FlowDebug.FlowDebugRunner>();
services.AddOptions<ManagementServiceOptions>()
.BindConfiguration("ScadaBridge:ManagementService")
@@ -160,8 +160,9 @@ public static class AuthorizationPolicies
/// This is page ACCESS only, and is deliberately NOT sufficient for a Live
/// run: the Run controls, the Live entry-point pickers and the parked-node
/// Retry/Discard actions are gated on <see cref="RequireDeployment"/> in the
/// page, and <c>FlowDebugRunMethodCommand</c> /
/// <c>FlowDebugRunValueChangeCommand</c> stay Deployer-only in the
/// page, and <c>FlowDebugRunMethodCommand</c>,
/// <c>FlowDebugRunValueChangeCommand</c> and
/// <c>FlowDebugRunScriptCommand</c> stay Deployer-only in the
/// ManagementActor role matrix. Only <c>FlowDebugReplayCommand</c> accepts
/// <see cref="FlowDebuggerAccessRoles"/>.
/// </remarks>
@@ -24,13 +24,13 @@ public class FlowDebugCommandsTests
private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
[Fact]
public void Build_RegistersTheThreeSubcommands_WithTheirRequiredOptions()
public void Build_RegistersTheFourSubcommands_WithTheirRequiredOptions()
{
var group = FlowDebugCommands.Build(Url, Format, Username, Password);
Assert.Equal("flow-debug", group.Name);
var names = group.Subcommands.Select(c => c.Name).ToList();
Assert.Equal(["run-method", "run-value-change", "replay"], names);
Assert.Equal(["run-method", "run-script", "run-value-change", "replay"], names);
var runMethod = group.Subcommands.Single(c => c.Name == "run-method");
Assert.Contains(runMethod.Options, o => o.Name == "--method-id" && o.Required);
@@ -38,6 +38,14 @@ public class FlowDebugCommandsTests
Assert.Contains(runMethod.Options, o => o.Name == "--timeout-seconds" && !o.Required);
Assert.DoesNotContain(runMethod.Options, o => o.Name == "--wait-seconds");
var runScript = group.Subcommands.Single(c => c.Name == "run-script");
Assert.Contains(runScript.Options, o => o.Name == "--instance-id" && o.Required);
Assert.Contains(runScript.Options, o => o.Name == "--script" && o.Required);
Assert.Contains(runScript.Options, o => o.Name == "--params" && !o.Required);
Assert.Contains(runScript.Options, o => o.Name == "--value" && !o.Required);
Assert.Contains(runScript.Options, o => o.Name == "--wait-seconds" && !o.Required);
Assert.Contains(runScript.Options, o => o.Name == "--timeout-seconds" && !o.Required);
var runValueChange = group.Subcommands.Single(c => c.Name == "run-value-change");
Assert.Contains(runValueChange.Options, o => o.Name == "--instance-id" && o.Required);
Assert.Contains(runValueChange.Options, o => o.Name == "--attribute" && o.Required);
@@ -49,6 +57,37 @@ public class FlowDebugCommandsTests
Assert.Contains(replay.Options, o => o.Name == "--execution-id" && o.Required);
}
[Fact]
public void RunScript_Defaults_WaitSeconds45_TimeoutSeconds120()
{
var group = FlowDebugCommands.Build(Url, Format, Username, Password);
var parse = group.Parse("run-script --instance-id 7 --script Manual --params {\"n\":3}");
Assert.Empty(parse.Errors);
var cmd = group.Subcommands.Single(c => c.Name == "run-script");
Assert.Equal(45, parse.GetValue((Option<int>)cmd.Options.Single(o => o.Name == "--wait-seconds")));
Assert.Equal(120, parse.GetValue((Option<int>)cmd.Options.Single(o => o.Name == "--timeout-seconds")));
}
[Fact]
public async Task RunScript_WaitSecondsZero_IsRejectedWithInvalidArgument()
{
var group = FlowDebugCommands.Build(Url, Format, Username, Password);
var exitCode = await group.Parse("run-script --instance-id 7 --script Manual --wait-seconds 0").InvokeAsync();
Assert.Equal(1, exitCode);
}
[Fact]
public async Task RunScript_TimeoutSecondsZero_IsRejectedWithInvalidArgument()
{
var group = FlowDebugCommands.Build(Url, Format, Username, Password);
var exitCode = await group.Parse("run-script --instance-id 7 --script Manual --timeout-seconds 0").InvokeAsync();
Assert.Equal(1, exitCode);
}
[Fact]
public void RunMethod_TimeoutSecondsDefaultsTo120()
{
@@ -87,6 +126,7 @@ public class FlowDebugCommandsTests
// The CLI derives the wire command name from the record type; a rename on either
// side would throw here rather than at runtime.
Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunMethodCommand))));
Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunScriptCommand))));
Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunValueChangeCommand))));
Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugReplayCommand))));
}
@@ -12,15 +12,18 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
/// without hanging.
///
/// <para>
/// <b>The Enabled-only dropdown + deploy precondition (verified against
/// <c>DebugView.razor::LoadInstancesForSite</c>):</b> the instance <c>&lt;select&gt;</c>
/// lists ONLY instances whose config state is <c>Enabled</c>
/// <b>The Enabled-only instance picker + deploy precondition (verified against
/// <c>DebugView.razor::LoadInstancesForSite</c>):</b> the instance
/// <c>InstancePicker</c> combobox (prefix <c>debug-instance</c>) lists ONLY
/// instances whose config state is <c>Enabled</c>
/// (<c>i.State == InstanceState.Enabled</c>). A freshly-minted instance is
/// <c>NotDeployed</c> and would never appear, so Fact B first drives the instance
/// to <c>Enabled</c> over the CLI (<c>instance deploy</c>) before it can be picked
/// in the dropdown. Both <c>&lt;select&gt;</c>s carry the <c>data-test</c> hooks
/// (<c>debug-site-select</c> / <c>debug-instance-select</c>, option value = entity
/// id); Fact A asserts those hooks are served, which doubles as proof the cluster
/// in the picker. The site <c>&lt;select&gt;</c> carries the <c>data-test</c> hook
/// <c>debug-site-select</c> (option value = entity id); the instance picker carries
/// the shared <c>InstancePicker</c> hooks (<c>debug-instance-picker</c>/<c>-input</c>/
/// <c>-toggle</c>/<c>-panel</c>/<c>-row</c>, row <c>data-instance-id</c> = entity id).
/// Fact A asserts those hooks are served, which doubles as proof the cluster
/// rebuild that added them actually took.
/// </para>
///
@@ -65,7 +68,7 @@ public class DebugViewTests : IClassFixture<DeploymentFixture>
await Assertions.Expect(page.Locator("h4:has-text('Debug View')")).ToBeVisibleAsync();
// The data-test hooks from Task 4 must be served (proves the rebuild took).
await Assertions.Expect(page.Locator("[data-test='debug-site-select']")).ToHaveCountAsync(1);
await Assertions.Expect(page.Locator("[data-test='debug-instance-select']")).ToHaveCountAsync(1);
await Assertions.Expect(page.Locator("[data-test='debug-instance-picker']")).ToHaveCountAsync(1);
// No site/instance selected -> Connect is disabled.
var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')");
@@ -90,11 +93,11 @@ public class DebugViewTests : IClassFixture<DeploymentFixture>
await page.Locator("[data-test='debug-site-select']")
.SelectOptionAsync(new SelectOptionValue { Value = _cluster.SiteAId.ToString() });
var instanceOption = page.Locator("[data-test='debug-instance-select'] option",
new() { HasText = uniqueName });
await Assertions.Expect(instanceOption).ToHaveCountAsync(1, new() { Timeout = 10_000 });
await page.Locator("[data-test='debug-instance-select']")
.SelectOptionAsync(new SelectOptionValue { Value = instanceId.ToString() });
await page.Locator("[data-test='debug-instance-input']").FillAsync(uniqueName);
var instanceRow = page.Locator(
$"[data-test='debug-instance-row'][data-instance-id='{instanceId}']");
await Assertions.Expect(instanceRow).ToBeVisibleAsync(new() { Timeout = 10_000 });
await instanceRow.ClickAsync();
var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')");
await Assertions.Expect(connect).ToBeEnabledAsync(new() { Timeout = 10_000 });
@@ -76,11 +76,11 @@ public class DebugViewTreeTests : IClassFixture<DeploymentFixture>
// Select the seeded site + Enabled instance (same hooks as DebugViewTests).
await page.Locator("[data-test='debug-site-select']")
.SelectOptionAsync(new SelectOptionValue { Value = _cluster.SiteAId.ToString() });
var instanceOption = page.Locator("[data-test='debug-instance-select'] option",
new() { HasText = uniqueName });
await Assertions.Expect(instanceOption).ToHaveCountAsync(1, new() { Timeout = 10_000 });
await page.Locator("[data-test='debug-instance-select']")
.SelectOptionAsync(new SelectOptionValue { Value = instanceId.ToString() });
await page.Locator("[data-test='debug-instance-input']").FillAsync(uniqueName);
var instanceRow = page.Locator(
$"[data-test='debug-instance-row'][data-instance-id='{instanceId}']");
await Assertions.Expect(instanceRow).ToBeVisibleAsync(new() { Timeout = 10_000 });
await instanceRow.ClickAsync();
var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')");
await Assertions.Expect(connect).ToBeEnabledAsync(new() { Timeout = 10_000 });
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.Playwright;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
using Xunit;
@@ -12,6 +13,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
/// prediction path that used to deadlock the circuit (see <see cref="FlowDebuggerFixture"/>) —
/// under that bug the state chip never reached <c>Predicted</c> and every later click was lost,
/// so these tests are the browser-level guard for that fix as well.
///
/// <para>
/// The script entry (design 2026-09-08, Task 11) adds two more: a Live direct call of the rig's
/// <c>OutboundDemo</c> Call script on <c>DelmiaSim01</c>, and the write-kind Inputs card naming the
/// trigger's own attribute. Unlike the method tests those drive RIG-SEEDED objects the fixture does
/// not provision, so each picker step skips with a "run docker/seed-…" message when it is absent.
/// </para>
/// </summary>
[Collection("Playwright")]
public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
@@ -91,6 +99,186 @@ public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase));
}
[SkippableFact]
public async Task ScriptEntry_LiveRunOfACallScript_InBrowser_Completes()
{
// Flow Debugger script entry (design 2026-09-08 §5). Rig objects: DelmiaSim01 (SimMachine, site-a)
// carries the OutboundDemo Call script seeded by docker/seed-flow-outbound-demo.sh; skip when absent.
// A Call script is invoked DIRECTLY through the traced RouteToCall path, so this is the browser-level
// proof of the call-kind branch: picker -> prediction -> Inputs card -> real run -> graph.
Skip.IfNot(_flow.Available, ClusterAvailability.SkipReason);
var page = await _pw.NewAuthenticatedPageAsync();
var consoleErrors = CaptureConsoleErrors(page);
await page.GotoAsync(PlaywrightFixture.BaseUrl + FlowDebuggerUrl);
await page.ClickAsync("button[data-test='flow-entry-script']");
var scriptSelect = await SelectRigScriptAsync(page, RigCallScript);
// The prediction ran server-side (a RouteCall root + the site script's predicted steps).
await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Predicted", new() { Timeout = 15_000 });
// Call kinds render the shared ParameterValueForm, or the no-parameters note when the script
// declares none — OutboundDemo declares none today (the seed passes no --parameters), but it
// reads an optional WorkOrder, so fill the field if a later seed starts declaring it.
var paramsCard = page.Locator("[data-test='flow-script-params']");
var noParamsNote = page.Locator("[data-test='flow-script-no-params']");
await Assertions.Expect(paramsCard.Or(noParamsNote)).ToBeVisibleAsync(new() { Timeout = 15_000 });
if (await paramsCard.IsVisibleAsync())
{
var workOrder = page.Locator("#param-WorkOrder");
if (await workOrder.CountAsync() > 0)
{
await workOrder.FillAsync("WO-PW");
await workOrder.BlurAsync();
await page.WaitForTimeoutAsync(300);
}
}
await page.ClickAsync("button[data-test='flow-run']");
await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Completed", new() { Timeout = 60_000 });
var stepCountText = await page.Locator("[data-test='flow-step-count']").InnerTextAsync();
var stepCount = int.Parse(stepCountText.Trim(), CultureInfo.InvariantCulture);
Assert.True(stepCount >= 2, $"expected at least the RouteCall root + the site's ScriptRun, got {stepCount}");
// The picker kept the choice, and selecting a node fills the detail panel.
await Assertions.Expect(scriptSelect).ToHaveValueAsync(RigCallScript);
await page.Locator("g[data-test='flow-node']").Last.ClickAsync();
await Assertions.Expect(page.Locator("[data-test='flow-step-title']")).ToBeVisibleAsync();
Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase));
}
[SkippableFact]
public async Task ScriptEntry_WriteKindScript_ShowsTheValueFieldForTheTriggerAttribute()
{
// The write-kind branch of the same picker (design 2026-09-08 §5): a ValueChange/Conditional
// script is fired by writing ITS OWN trigger attribute, so the Inputs card offers one value
// field naming that attribute instead of a parameter form. DelmiaReceiverSimulator (ValueChange
// on DelmiaReceiver.RecipeDownloadFlag) is seeded by docker/seed-delmia-sim.sh. No run: the
// simulator has real side effects and the call-kind test above already covers execution.
Skip.IfNot(_flow.Available, ClusterAvailability.SkipReason);
var page = await _pw.NewAuthenticatedPageAsync();
var consoleErrors = CaptureConsoleErrors(page);
await page.GotoAsync(PlaywrightFixture.BaseUrl + FlowDebuggerUrl);
await page.ClickAsync("button[data-test='flow-entry-script']");
await SelectRigScriptAsync(page, RigValueChangeScript);
var valueField = page.Locator("input[data-test='flow-input-value']");
await Assertions.Expect(valueField).ToBeVisibleAsync(new() { Timeout = 15_000 });
await Assertions.Expect(page.Locator("[data-test='flow-script-params']")).ToHaveCountAsync(0);
// The card names the attribute the debugger would write — that is what makes the derived
// attribute (rather than an operator-picked one) visible to the operator.
var inputsCard = valueField.Locator("xpath=ancestor::div[contains(@class,'card-body')][1]");
await Assertions.Expect(inputsCard).ToContainTextAsync(RigValueChangeAttribute);
Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Rig instance the script-entry tests drive (<c>docker/seed-delmia-sim.sh</c>).</summary>
private const string RigInstanceName = "DelmiaSim01";
/// <summary>Call script on that instance (<c>docker/seed-flow-outbound-demo.sh</c>).</summary>
private const string RigCallScript = "OutboundDemo";
/// <summary>ValueChange script on that instance, and the attribute its trigger names.</summary>
private const string RigValueChangeScript = "DelmiaReceiverSimulator";
private const string RigValueChangeAttribute = "DelmiaReceiver.RecipeDownloadFlag";
/// <summary>
/// Walks the script entry's three pickers (site → instance → script) to the named script on the
/// rig's <see cref="RigInstanceName"/>, skipping the test with a rig-shaped message whenever an
/// option is missing — none of these objects is provisioned by the fixture. Site is matched by
/// LABEL because its id differs per rig (the seed names it "Test Plant A", identifier
/// <c>site-a</c>); the instance goes through the shared <c>InstancePicker</c> combobox (typed
/// name filters the tree, exact-text row click selects); the script is matched by option VALUE,
/// which is its canonical name.
/// </summary>
private static async Task<ILocator> SelectRigScriptAsync(IPage page, string scriptName)
{
var sites = page.Locator("select[data-test='flow-site-select']");
await Assertions.Expect(sites).ToBeVisibleAsync(new() { Timeout = 15_000 });
var siteLabel = await WaitForOptionLabelAsync(page, sites, l =>
l.Contains("Plant A", StringComparison.OrdinalIgnoreCase)
|| l.Contains("Site A", StringComparison.OrdinalIgnoreCase)
|| l.Contains("site-a", StringComparison.OrdinalIgnoreCase));
Skip.If(siteLabel is null, "The rig's site-a ('Test Plant A') is not in the Flow Debugger site list — run docker/seed-sites.sh.");
await sites.SelectOptionAsync(new SelectOptionValue { Label = siteLabel! });
// Instance selection goes through the shared InstancePicker combobox (prefix
// "flow-instance"): typing the name filters the area tree and expands its
// ancestors, then the exact-text row is clicked to select and close the panel. The
// test only knows the instance NAME (not its id), so the row is matched through the
// data-test contract (flow-instance-row) with an exact-text regex rather than a
// free-text GetByText scan.
var instanceInput = page.Locator("[data-test='flow-instance-input']");
await instanceInput.FillAsync(RigInstanceName);
var instanceRow = page.Locator("[data-test='flow-instance-row']",
new() { HasTextRegex = new Regex($"^\\s*{Regex.Escape(RigInstanceName)}\\s*$") });
Skip.IfNot(
await WaitForCountAsync(page, instanceRow),
$"Instance {RigInstanceName} is not on the rig's site-a — run docker/seed-delmia-sim.sh.");
await instanceRow.ClickAsync();
var scripts = page.Locator("select[data-test='flow-script-select']");
var option = scripts.Locator($"option[value='{scriptName}']");
Skip.IfNot(
await WaitForCountAsync(page, option),
$"Script {scriptName} is not on {RigInstanceName} — run docker/seed-delmia-sim.sh and docker/seed-flow-outbound-demo.sh.");
await scripts.SelectOptionAsync(scriptName);
return scripts;
}
/// <summary>
/// Polls a <c>&lt;select&gt;</c>'s option labels until one matches, returning it (or null on
/// timeout). Used for the site select, whose options are filled by a server round-trip after
/// the page loads, so the options do not exist at first render.
/// </summary>
private static async Task<string?> WaitForOptionLabelAsync(
IPage page, ILocator select, Func<string, bool> match, int timeoutMs = 15_000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (true)
{
foreach (var text in await select.Locator("option").AllTextContentsAsync())
{
var label = text.Trim();
if (label.Length > 0 && match(label))
{
return label;
}
}
if (DateTime.UtcNow >= deadline)
{
return null;
}
await page.WaitForTimeoutAsync(250);
}
}
/// <summary>Polls until <paramref name="locator"/> matches at least one element, or the timeout expires.</summary>
private static async Task<bool> WaitForCountAsync(IPage page, ILocator locator, int timeoutMs = 15_000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (await locator.CountAsync() == 0)
{
if (DateTime.UtcNow >= deadline)
{
return false;
}
await page.WaitForTimeoutAsync(250);
}
return true;
}
private string MethodIdText => _flow.MethodId.ToString(CultureInfo.InvariantCulture);
private async Task SelectMethodAsync(IPage page)
@@ -0,0 +1,231 @@
using System.Reflection;
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// bUnit tests for the Debug View's instance selector after it moved from a flat
/// <c>&lt;select&gt;</c> to the shared <c>InstancePicker</c> area-tree combobox
/// (design 2026-09-08). Pins that the page hands the picker the site's areas and
/// its Enabled-only instance list, that drilling down the tree selects an instance,
/// that the selection still enables <em>Connect</em>, and that a live connection
/// locks the picker down.
/// </summary>
public class DebugViewInstancePickerTests : BunitContext
{
private const int SiteId = 1;
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private void RegisterServices()
{
// DebugView touches localStorage on render; let bUnit answer loosely.
JSInterop.Mode = JSRuntimeMode.Loose;
_repo.GetInstancesBySiteIdAsync(SiteId).Returns(new List<Instance>
{
new("Sim01") { Id = 7, SiteId = SiteId, AreaId = 2, State = InstanceState.Enabled },
new("Off01") { Id = 8, SiteId = SiteId, State = InstanceState.Disabled },
});
_repo.GetAreasBySiteIdAsync(SiteId).Returns(new List<Area>
{
new("Line1") { Id = 1, SiteId = SiteId },
new("Cell A") { Id = 2, SiteId = SiteId, ParentAreaId = 1 },
});
Services.AddSingleton(_repo);
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync().Returns(new List<Site>
{
new("Site One", "site-a") { Id = SiteId },
});
Services.AddSingleton(siteRepo);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(comms);
var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
// An empty throwaway provider — these tests never call StartStreamAsync,
// so the provider is unused. (Services.BuildServiceProvider() would leak
// an undisposed provider.)
var debugStream = new DebugStreamService(
comms, new ServiceCollection().BuildServiceProvider(), grpcFactory,
NullLogger<DebugStreamService>.Instance);
Services.AddSingleton(debugStream);
// No SiteId claim => system-wide, so site 1 passes the scope re-check.
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
var authState = new AuthenticationState(new ClaimsPrincipal(identity));
var stubAuth = new StubAuthStateProvider(authState);
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
/// <summary>Renders the page, selects the seeded site and opens the picker panel.</summary>
private IRenderedComponent<DebugViewPage> RenderWithOpenPicker()
{
RegisterServices();
var cut = Render<DebugViewPage>();
cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString());
cut.Find("[data-test='debug-instance-toggle']").Click();
return cut;
}
private static void ClickRow(IRenderedComponent<DebugViewPage> cut, string text) =>
cut.FindAll("[data-test='debug-instance-row']")
.First(r => r.TextContent.Trim() == text)
.Click();
[Fact]
public void InstanceSelector_IsThePicker_NotAFlatSelect()
{
RegisterServices();
var cut = Render<DebugViewPage>();
Assert.Single(cut.FindAll("[data-test='debug-instance-picker']"));
Assert.Empty(cut.FindAll("select[data-test='debug-instance-select']"));
}
[Fact]
public void OpeningThePicker_ShowsOnlyTheRootArea()
{
var cut = RenderWithOpenPicker();
var rows = cut.FindAll("[data-test='debug-instance-row']");
Assert.Single(rows);
Assert.Equal("Line1", rows[0].TextContent.Trim());
Assert.False(rows[0].HasAttribute("data-instance-id"));
}
[Fact]
public void DrillingDown_RevealsTheEnabledInstanceOnly()
{
var cut = RenderWithOpenPicker();
ClickRow(cut, "Line1");
ClickRow(cut, "Cell A");
var instanceRow = cut.Find("[data-instance-id='7']");
Assert.Equal("Sim01 (Enabled)", instanceRow.TextContent.Trim());
// Off01 is Disabled, so the page filters it out before the picker sees it.
Assert.DoesNotContain(
cut.FindAll("[data-test='debug-instance-row']"),
r => r.TextContent.Contains("Off01"));
}
[Fact]
public void PickingAnInstance_FillsTheField_AndEnablesConnect()
{
var cut = RenderWithOpenPicker();
ClickRow(cut, "Line1");
ClickRow(cut, "Cell A");
cut.Find("[data-instance-id='7']").Click();
Assert.Equal(
"Sim01 (Enabled)",
cut.Find("[data-test='debug-instance-input']").GetAttribute("value"));
var connect = cut.FindAll("button.btn.btn-primary.btn-sm")
.First(b => b.TextContent.Contains("Connect"));
Assert.False(connect.HasAttribute("disabled"));
}
/// <summary>
/// The picker must stay disabled for the WHOLE site load, not just until the site id lands.
/// Blazor renders as soon as <c>LoadInstancesForSite</c> hits its first await, and at that point
/// the page is not <c>_connected</c> — so the field was already enabled while the site's lists
/// were still loading. Typing into it then (an operator, or Playwright's <c>FillAsync</c>, which
/// waits only for "enabled") lost the text: the picker reads the freshly-assigned lists as
/// "the site changed" and clears the field and closes the panel.
/// </summary>
[Fact]
public void PickerStaysDisabled_UntilTheSitesInstancesHaveLoaded()
{
RegisterServices();
// Hold the instance load open so the render that follows the first await is observable.
var gate = new TaskCompletionSource<IReadOnlyList<Instance>>();
_repo.GetInstancesBySiteIdAsync(SiteId).Returns(gate.Task);
var cut = Render<DebugViewPage>();
cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString());
Assert.True(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled"));
Assert.True(cut.Find("[data-test='debug-instance-toggle']").HasAttribute("disabled"));
cut.InvokeAsync(() => gate.SetResult(
[new Instance("Sim01") { Id = 7, SiteId = SiteId, AreaId = 2, State = InstanceState.Enabled }]));
cut.WaitForAssertion(() =>
Assert.False(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled")));
}
/// <summary>
/// The localStorage auto-reconnect must apply the same membership guard the
/// <c>?instanceId=</c> deep link does. Off01 is Disabled, so the page filters it out of the
/// list the picker is handed — restoring it anyway left the field blank while the page held
/// a non-zero id and auto-connected to it. The stored id is dropped instead, and the site
/// stays selected and loaded so the operator can pick again.
/// </summary>
[Fact]
public void AutoReconnect_FromStorage_SkipsAnInstanceThatIsNoLongerAvailable()
{
RegisterServices();
JSInterop.Setup<string>("localStorage.getItem", "debugView.siteId").SetResult(SiteId.ToString());
JSInterop.Setup<string>("localStorage.getItem", "debugView.instanceId").SetResult("8");
var cut = Render<DebugViewPage>();
cut.WaitForAssertion(() =>
Assert.Equal(string.Empty, cut.Find("[data-test='debug-instance-input']").GetAttribute("value")));
// Nothing is selected, so Connect stays disabled — and the stale id is forgotten.
var connect = cut.FindAll("button.btn.btn-primary.btn-sm").First(b => b.TextContent.Contains("Connect"));
Assert.True(connect.HasAttribute("disabled"));
JSInterop.VerifyInvoke("localStorage.removeItem");
}
[Fact]
public void WhileConnected_ThePickerIsDisabled()
{
RegisterServices();
var cut = Render<DebugViewPage>();
cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString());
// Mark the page connected the way the other DebugView page tests do —
// driving the real gRPC stream is not available to a bUnit render.
cut.InvokeAsync(() => typeof(DebugViewPage)
.GetField("_connected", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(cut.Instance, true));
cut.Render();
Assert.True(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled"));
Assert.True(cut.Find("[data-test='debug-instance-toggle']").HasAttribute("disabled"));
}
}
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
@@ -35,6 +36,7 @@ public class FlowDebuggerPageTests : BunitContext
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
private readonly IDialogService _dialog = Substitute.For<IDialogService>();
private readonly ISchemaLibraryQueryService _schemaLibrary = Substitute.For<ISchemaLibraryQueryService>();
private static readonly ApiMethod Delmia = new("DelmiaRecipeDownload", "return 1;")
{
@@ -91,6 +93,10 @@ public class FlowDebuggerPageTests : BunitContext
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DialogSeverity>(), Arg.Any<string?>()).Returns(true);
Services.AddSingleton(_dialog);
// The script entry's Inputs card renders the shared ParameterValueForm, which injects
// the schema library to resolve {"$ref":"lib:Name"} nodes. Empty map = no refs.
_schemaLibrary.GetSchemaMapAsync(Arg.Any<CancellationToken>()).Returns(new Dictionary<string, string>());
Services.AddSingleton(_schemaLibrary);
var identity = new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role), .. extraClaims ?? []], "TestCookie");
@@ -145,7 +151,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("button[data-test='flow-run']"));
Assert.Empty(cut.FindAll("button[data-test='flow-mode-live']"));
Assert.Empty(cut.FindAll("button[data-test='flow-entry-method']"));
Assert.Empty(cut.FindAll("button[data-test='flow-entry-value-change']"));
Assert.Empty(cut.FindAll("button[data-test='flow-entry-script']"));
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
}
@@ -589,4 +595,312 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any<CancellationToken>()));
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
}
// ---- Script entry (design 2026-09-08 §5): site → instance → script ----------------------
private static readonly FlowScriptOption OnFlag = new("OnFlag", "ValueChange", "Flag", null, null);
private static readonly FlowScriptOption Manual = new("Manual", "Call", null,
"""{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20);
private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null);
/// <summary>
/// Picks an instance through the <c>InstancePicker</c> combobox that replaced the flat
/// instance <c>&lt;select&gt;</c> (design 2026-09-08): open the panel, click the row. The
/// fixture instance has no area, so its row sits at the root and needs no expansion.
/// </summary>
private static void PickInstance(IRenderedComponent<FlowDebuggerPage> cut, int id)
{
cut.Find("[data-test='flow-instance-toggle']").Click();
cut.Find($"[data-test='flow-instance-row'][data-instance-id='{id}']").Click();
}
/// <summary>Clicks the picker row (an area label toggles it, an instance label selects it).</summary>
private static void ClickPickerRow(IRenderedComponent<FlowDebuggerPage> cut, string label) =>
cut.FindAll("[data-test='flow-instance-row']").First(r => r.TextContent.Trim() == label).Click();
private void WireInstance()
{
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
_templates.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Area>>([]));
_templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 });
_flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, OnFlag, Tick]));
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
}
[Fact]
public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance()
{
RegisterServices();
WireInstance();
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList();
Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']"));
}
[Fact]
public void ScriptEntry_WriteKind_ShowsTheValueField_ForTheTriggerAttribute_AndGatesRunOnIt()
{
RegisterServices();
WireInstance();
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("OnFlag");
Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent);
Assert.Empty(cut.FindAll("[data-test='flow-script-params']"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("input[data-test='flow-input-value']").Input("true");
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void ScriptEntry_CallKind_ShowsTheParameterForm_AndRunsWithParameters()
{
RegisterServices();
WireInstance();
_flow.RunScriptAsync(7, "Manual", Arg.Any<JsonElement?>(), null, Arg.Any<CancellationToken>()).Returns(GraphOutcome(true, true));
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("Manual");
Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']"));
Assert.Empty(cut.FindAll("input[data-test='flow-input-value']"));
cut.Find("#param-n").Input("3");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
_ = _flow.Received(1).RunScriptAsync(7, "Manual", Arg.Is<JsonElement?>(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any<CancellationToken>());
}
[Fact]
public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun()
{
RegisterServices();
WireInstance();
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("Tick");
Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent);
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void DeepLink_InstanceAndScript_PreselectsAndPredicts()
{
RegisterServices();
WireInstance();
var nav = Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/deployment/flow-debugger?instanceId=7&script=Manual");
var cut = Render<FlowDebuggerPage>();
cut.WaitForAssertion(() => Assert.Equal("Manual", cut.Find("select[data-test='flow-script-select']").GetAttribute("value")));
_ = _flow.Received().PredictScriptAsync(7, "Manual", Arg.Any<CancellationToken>());
}
/// <summary>
/// Two call-kind scripts declaring BYTE-IDENTICAL parameter definitions — the case that makes
/// <c>ParameterValueForm</c> skip its reparse (and its <c>ValidChanged</c>) and keeps the previous
/// script's raw text and parse errors. The page must remount the form per script instead.
/// </summary>
private static readonly FlowScriptOption Manual2 = new("Manual2", "Call", null,
"""{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20);
private void WireInstanceWithTwinScripts()
{
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
_flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, Manual2, Tick]));
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
}
private IRenderedComponent<FlowDebuggerPage> ChooseScriptWithAnInvalidParameter()
{
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("Manual");
cut.Find("#param-n").Input("abc"); // not an integer: the form parks a parse error
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
return cut;
}
[Fact]
public void ScriptEntry_SwitchingBetweenCallKindScripts_ResetsTheFormAndValidity()
{
RegisterServices();
WireInstanceWithTwinScripts();
var cut = ChooseScriptWithAnInvalidParameter();
cut.Find("select[data-test='flow-script-select']").Change("Manual2");
// A fresh form: no echo of the previous script's text, and no stale parse error keeping
// Run disabled invisibly — Run is disabled only because the required field is unset.
Assert.Equal(string.Empty, cut.Find("#param-n").GetAttribute("value"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("#param-n").Input("5");
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void ScriptEntry_SwitchingFromAnInvalidFormToANoParameterScript_EnablesRun()
{
RegisterServices();
WireInstanceWithTwinScripts();
var cut = ChooseScriptWithAnInvalidParameter();
cut.Find("select[data-test='flow-script-select']").Change("Tick");
Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent);
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void ScriptEntry_InstancePicker_ShowsAreasCollapsed_UntilExpanded()
{
RegisterServices();
WireInstance();
// Same instance, now filed under Line1 / Cell A so the picker has a tree to drill into.
_templates.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Area>>(
[new Area("Line1") { Id = 1, SiteId = 1 }, new Area("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }]));
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(
[new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 }]));
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("[data-test='flow-instance-toggle']").Click();
// The panel opens collapsed: only the root area, and the instance is not rendered at all.
Assert.Equal(["Line1"], cut.FindAll("[data-test='flow-instance-row']").Select(r => r.TextContent.Trim()).ToList());
Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']"));
ClickPickerRow(cut, "Line1");
Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']"));
ClickPickerRow(cut, "Cell A");
cut.Find("[data-test='flow-instance-row'][data-instance-id='7']").Click();
// Picking the row ran OnInstanceChangedAsync, so the script list is loaded.
var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList();
Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
}
[Fact]
public void ScriptEntry_DeepLink_InstanceId_ShowsItsNameInThePicker()
{
RegisterServices();
WireInstance();
var nav = Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/deployment/flow-debugger?instanceId=7");
var cut = Render<FlowDebuggerPage>();
cut.WaitForAssertion(() =>
Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value")));
}
/// <summary>
/// The same deep link, but the instance sits two areas deep. The picker resolves the field
/// text from <c>SelectedInstanceId</c> against the flat instance list, not by walking the
/// tree — so a nested instance must show its name with the panel never opened (and therefore
/// with Line1 / Cell A never expanded).
/// </summary>
[Fact]
public void ScriptEntry_DeepLink_InstanceIdInsideAnArea_ShowsItsNameWithoutOpeningThePanel()
{
RegisterServices();
WireInstance();
_templates.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Area>>(
[new Area("Line1") { Id = 1, SiteId = 1 }, new Area("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }]));
var nested = new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 };
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>([nested]));
_templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(nested);
var nav = Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/deployment/flow-debugger?instanceId=7");
var cut = Render<FlowDebuggerPage>();
cut.WaitForAssertion(() =>
Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value")));
// The panel was never opened, so nothing of the tree is rendered.
Assert.Empty(cut.FindAll("[data-test='flow-instance-panel']"));
Assert.Empty(cut.FindAll("[data-test='flow-instance-row']"));
}
/// <summary>
/// The picker must stay disabled for the WHOLE site load, not just until the site id lands.
/// Blazor renders as soon as <c>OnSiteChangedAsync</c> hits its first await, and at that point
/// <c>_selectedSiteId</c> is already non-zero while <c>_instances</c>/<c>_areas</c> are still the
/// previous site's lists — so an operator (or Playwright's <c>FillAsync</c>, which waits only for
/// "enabled") could type into the field and have the load wipe the text and close the panel a
/// moment later, because the picker reads a new list object as "the site changed".
/// </summary>
[Fact]
public void ScriptEntry_PickerStaysDisabled_UntilTheSitesInstancesHaveLoaded()
{
RegisterServices();
WireInstance();
// Hold the instance load open so the render that follows the first await is observable.
var gate = new TaskCompletionSource<IReadOnlyList<Instance>>();
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>()).Returns(gate.Task);
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
Assert.True(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled"));
Assert.True(cut.Find("[data-test='flow-instance-toggle']").HasAttribute("disabled"));
cut.InvokeAsync(() => gate.SetResult([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
cut.WaitForAssertion(() =>
Assert.False(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled")));
}
[Fact]
public void ScriptEntry_SiteChange_ClearsThePickedInstance()
{
RegisterServices();
WireInstance();
// A second site the operator can switch to, with no instances of its own.
_sites.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<Site>>(
[new Site("Plant A", "site-a") { Id = 1 }, new Site("Plant B", "site-b") { Id = 2 }]));
_templates.GetInstancesBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>([]));
_templates.GetAreasBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Area>>([]));
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value"));
cut.Find("select[data-test='flow-site-select']").Change("2");
// The pick is dropped on both sides: the page's id and the picker's field text, which
// it re-derives because the new site handed it a different instance list.
Assert.Equal(string.Empty, cut.Find("[data-test='flow-instance-input']").GetAttribute("value"));
Assert.Equal(["— choose —"], cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList());
}
}
@@ -282,4 +282,47 @@ public class ParameterValueFormTests : BunitContext
Assert.Single(cut.FindAll("button.param-list-remove"));
Assert.NotEmpty(cut.FindAll(".text-danger"));
}
[Fact]
public void ValidChanged_ReportsFalseOnAParseError_AndTrueOnceFixed()
{
var valid = new List<bool>();
var cut = Render<ParameterValueForm>(p => p
.Add(x => x.ParameterDefinitions, """{"type":"object","properties":{"n":{"type":"integer"}}}""")
.Add(x => x.ValidChanged, v => valid.Add(v)));
var input = cut.Find("#param-n");
input.Input("abc");
Assert.False(valid.Last());
input.Input("5");
Assert.True(valid.Last());
}
[Fact]
public void ValidChanged_ReportsFalseOnFirstRender_WhenARequiredFieldIsEmpty()
{
var valid = new List<bool>();
var cut = Render<ParameterValueForm>(p => p
.Add(x => x.ParameterDefinitions, """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""")
.Add(x => x.ValidChanged, v => valid.Add(v)));
// Reported without any input from the user...
Assert.False(valid.Last());
// ...and no red error text is painted for the untouched required field (the
// component has no "untouched" concept, so the validity check for
// ValidChanged deliberately does not populate _parseErrors on first render).
Assert.Empty(cut.FindAll(".text-danger"));
}
[Fact]
public void ValidChanged_ReportsTrueOnFirstRender_WhenNothingIsRequired()
{
var valid = new List<bool>();
var cut = Render<ParameterValueForm>(p => p
.Add(x => x.ParameterDefinitions, """{"type":"object","properties":{"n":{"type":"integer"}}}""")
.Add(x => x.ValidChanged, v => valid.Add(v)));
Assert.True(valid.Last());
}
}
@@ -17,7 +17,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
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;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.Security;
@@ -27,8 +30,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Tests for <see cref="FlowDebugService"/> (Flow Debugger design 2026-09-05 §5.5/§7.1):
/// the management-command bridge (holder null, error mapping, success → graph), the
/// prediction resolver wiring over substituted repositories, and the Retry/Discard relay
/// for a parked outbound node (follow-ups plan 2026-09-06 D4).
/// prediction resolver wiring over substituted repositories, the script entry (design
/// 2026-09-08 §5 — script list, prediction and run over the flattened configuration), and
/// the Retry/Discard relay for a parked outbound node (follow-ups plan 2026-09-06 D4).
/// </summary>
/// <remarks>
/// <see cref="CommunicationService"/> is a concrete class with non-virtual methods, so the
@@ -48,6 +52,7 @@ public sealed class FlowDebugServiceTests : IDisposable
private readonly ActorSystem _system = ActorSystem.Create("flow-debug-service-tests");
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly List<ManagementEnvelope> _received = new();
// Relay probes: everything the scripted Site Call Audit / Notification Outbox actors receive.
@@ -80,7 +85,21 @@ public sealed class FlowDebugServiceTests : IDisposable
comms.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))));
comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))));
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, comms, NullLogger<FlowDebugService>.Instance);
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger<FlowDebugService>.Instance);
}
/// <summary>
/// Wires the instance row plus the flattened configuration its scripts are read from
/// (script entry, design 2026-09-08 §2 — the picker and the prediction both go through
/// <see cref="IFlatteningPipeline"/>, never the raw template chain).
/// </summary>
private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts)
{
_templates.GetInstanceByIdAsync(instanceId, Arg.Any<CancellationToken>())
.Returns(new Instance(uniqueName) { Id = instanceId, TemplateId = 10, SiteId = 1 });
var cfg = new FlattenedConfiguration { InstanceUniqueName = uniqueName, TemplateId = 10, SiteId = 1, Scripts = scripts };
_pipeline.FlattenAndValidateAsync(instanceId, Arg.Any<CancellationToken>(), false, null)
.Returns(Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult())));
}
/// <summary>An External-lane step as <c>FlowStepProjector</c> would project it (positional per <see cref="FlowStep"/>).</summary>
@@ -337,19 +356,23 @@ public sealed class FlowDebugServiceTests : IDisposable
}
[Fact]
public async Task RunValueChange_AndReplay_SendTheirCommands()
public async Task RunWriteKindScript_AndReplay_SendTheirCommands()
{
WireFlattened(7, "Sim01",
new ResolvedScript { CanonicalName = "OnSpeed", Code = "x", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Speed\"}" });
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 1)));
var exec = Guid.NewGuid();
var vc = await svc.RunValueChangeAsync(7, "Speed", "42");
var vc = await svc.RunScriptAsync(7, "OnSpeed", null, "42");
var rp = await svc.ReplayAsync(exec);
Assert.True(vc.Success);
Assert.True(rp.Success);
Assert.Equal(2, _received.Count);
var vcCmd = Assert.IsType<FlowDebugRunValueChangeCommand>(_received[0].Command);
Assert.Equal((7, "Speed", "42"), (vcCmd.InstanceId, vcCmd.AttributeName, vcCmd.Value));
var vcCmd = Assert.IsType<FlowDebugRunScriptCommand>(_received[0].Command);
Assert.Equal((7, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value));
// A write kind fires through the attribute, so it carries no parameters.
Assert.Null(vcCmd.Parameters);
var rpCmd = Assert.IsType<FlowDebugReplayCommand>(_received[1].Command);
Assert.Equal(exec, rpCmd.ExecutionId);
}
@@ -519,22 +542,36 @@ public sealed class FlowDebugServiceTests : IDisposable
}
[Fact]
public async Task PredictValueChange_RootsAtTheWrite_AndAppendsEachTriggeredScript()
public async Task GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName()
{
_templates.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
[
new TemplateScript("OnFlag", """Attributes["Ack"] = true;""") { Id = 1, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Flag"}""" },
new TemplateScript("OnOther", "return 1;") { Id = 2, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Other"}""" },
new TemplateScript("Manual", "return 1;") { Id = 3, TemplateId = 10, TriggerType = "call" },
]));
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>())
.Returns(new Template("Base") { Id = 10 });
WireFlattened(9, "Sim01",
new ResolvedScript { CanonicalName = "Manual", Code = "return 1;", TriggerType = "Call", ParameterDefinitions = "{\"type\":\"object\"}", ExecutionTimeoutSeconds = 12 },
new ResolvedScript { CanonicalName = "OnFlag", Code = "x", TriggerType = "valuechange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" },
new ResolvedScript { CanonicalName = "Mod.Tick", Code = "x", TriggerType = "Interval", TriggerConfiguration = "{\"intervalMs\":1000}" });
var svc = CreateService(reply: null);
var flow = await svc.PredictValueChangeAsync(9, "Flag");
var options = await CreateService(reply: null).GetInstanceScriptsAsync(9);
Assert.Equal(["Manual", "Mod.Tick", "OnFlag"], options.Select(o => o.CanonicalName).ToList());
var onFlag = options.Single(o => o.CanonicalName == "OnFlag");
Assert.True(onFlag.IsWriteKind); Assert.Equal("Flag", onFlag.TriggerAttribute); Assert.Equal("valuechange: Flag", onFlag.TriggerLabel);
var manual = options.Single(o => o.CanonicalName == "Manual");
Assert.False(manual.IsWriteKind); Assert.Equal(12, manual.ExecutionTimeoutSeconds); Assert.Equal("Call", manual.TriggerLabel);
Assert.False(options.Single(o => o.CanonicalName == "Mod.Tick").IsWriteKind);
}
[Fact]
public async Task GetInstanceScripts_UnknownInstance_IsEmpty()
{
Assert.Empty(await CreateService(reply: null).GetInstanceScriptsAsync(404));
}
[Fact]
public async Task PredictScript_WriteKind_RootsAtTheWrite_AndAppendsTheScript()
{
WireFlattened(9, "Sim01",
new ResolvedScript { CanonicalName = "OnFlag", Code = """Attributes["Ack"] = true;""", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" });
var flow = await CreateService(reply: null).PredictScriptAsync(9, "OnFlag");
Assert.Equal(
[
@@ -542,25 +579,95 @@ public sealed class FlowDebugServiceTests : IDisposable
(FlowStepKind.ScriptRun, "Sim01.OnFlag"),
(FlowStepKind.AttributeWrite, "Ack"),
], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList());
Assert.Equal(2, flow.Edges.Count);
Assert.Equal(("n1", "n2"), (flow.Edges[0].From, flow.Edges[0].To));
Assert.Equal(("n2", "n3"), (flow.Edges[1].From, flow.Edges[1].To));
}
[Fact]
public async Task PredictValueChange_NoTriggeredScript_NotesIt()
public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript()
{
WireFlattened(9, "Sim01",
new ResolvedScript { CanonicalName = "Manual", Code = """Attributes["Ack"] = true;""", TriggerType = "Call" });
var flow = await CreateService(reply: null).PredictScriptAsync(9, "Manual");
Assert.Equal(FlowStepKind.RouteCall, flow.Nodes[0].Kind);
Assert.Equal(FlowLane.Central, flow.Nodes[0].Lane);
Assert.Equal("Sim01.Manual", flow.Nodes[0].Title);
Assert.Equal(FlowStepKind.ScriptRun, flow.Nodes[1].Kind);
Assert.True(flow.Edges[0].CrossesBoundary);
}
[Fact]
public async Task PredictScript_UnknownScript_IsEmpty()
{
WireFlattened(9, "Sim01");
Assert.Same(PredictedFlow.Empty, await CreateService(reply: null).PredictScriptAsync(9, "Nope"));
}
[Fact]
public async Task RunScript_SendsTheCommand_WithTheScriptShape()
{
WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call", ExecutionTimeoutSeconds = 40 });
var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson)));
using var doc = JsonDocument.Parse("""{"n":3}""");
var outcome = await svc.RunScriptAsync(9, "Manual", doc.RootElement.Clone(), null);
Assert.True(outcome.Success, outcome.Error);
var env = Assert.Single(_received);
var cmd = Assert.IsType<FlowDebugRunScriptCommand>(env.Command);
Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName));
// A call kind carries its parameters verbatim and no value.
Assert.Equal(3, cmd.Parameters!.Value.GetProperty("n").GetInt32());
Assert.Null(cmd.Value);
}
/// <summary>
/// One picker selection costs ONE flatten: the prediction and the run that follow it read the
/// entry <see cref="IFlowDebugService.GetInstanceScriptsAsync"/> just refreshed (design
/// 2026-09-08 §5). A regression here means every keystroke re-walks the template chain.
/// </summary>
[Fact]
public async Task GetInstanceScripts_ThenPredict_ThenRun_FlattenOnce()
{
WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" });
var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson)));
await svc.GetInstanceScriptsAsync(9);
await svc.PredictScriptAsync(9, "Manual");
await svc.RunScriptAsync(9, "Manual", null, null);
await _pipeline.Received(1).FlattenAndValidateAsync(9, Arg.Any<CancellationToken>(), false, null);
}
/// <summary>
/// A scoped service lives for the whole Blazor circuit, so re-selecting the instance must
/// re-flatten — otherwise a template edit or a redeploy mid-session leaves the picker, the
/// parameter form and the Ask sizing stale for the rest of the session.
/// </summary>
[Fact]
public async Task GetInstanceScripts_CalledAgain_ReflattensAndRefreshes()
{
_templates.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>([]));
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>()).Returns(new Template("Base") { Id = 10 });
static Result<FlatteningPipelineResult> Flattened(params ResolvedScript[] scripts)
=> Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(
new FlattenedConfiguration { InstanceUniqueName = "Sim01", TemplateId = 10, SiteId = 1, Scripts = scripts },
"h", new ValidationResult()));
_pipeline.FlattenAndValidateAsync(9, Arg.Any<CancellationToken>(), false, null).Returns(
Flattened(new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" }),
Flattened(
new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" },
new ResolvedScript { CanonicalName = "Added", Code = "x", TriggerType = "Call" }));
var svc = CreateService(reply: null);
var flow = await svc.PredictValueChangeAsync(9, "Flag");
var root = Assert.Single(flow.Nodes);
Assert.Contains("no value-change script", root.Note);
var first = await svc.GetInstanceScriptsAsync(9);
var second = await svc.GetInstanceScriptsAsync(9);
Assert.Equal(["Manual"], first.Select(o => o.CanonicalName).ToList());
Assert.Equal(["Added", "Manual"], second.Select(o => o.CanonicalName).ToList());
await _pipeline.Received(2).FlattenAndValidateAsync(9, Arg.Any<CancellationToken>(), false, null);
}
private sealed class StubAuth : AuthenticationStateProvider
@@ -456,20 +456,65 @@ public class FlowGraphViewTests : BunitContext
}
[Fact]
public void Render_AsyncEdgesAreDashedClass_SpawnEdgeUsesTheBoltMarker()
public void Render_AsyncEdgesAreDashedClass_SpawnEdgeKeepsAnArrowheadAndGetsABoltBadge()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, OutboundGraph()));
var graph = OutboundGraph();
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, graph));
Assert.NotNull(cut.Find("marker#fg-arrow-spawn"));
// Rig 2026-09-08: the bolt used to replace the arrowhead and read as a broken arrow on the upward
// Tags→Site hop. Every edge now ends in a real arrowhead; the bolt is a badge on the connector.
Assert.Empty(cut.FindAll("marker#fg-arrow-spawn"));
var edges = cut.FindAll("path[data-test='flow-edge']");
var async = edges.Where(e => e.ClassList.Contains("async")).ToList();
Assert.Equal(2, async.Count);
Assert.All(async, e => Assert.Equal("url(#fg-arrow)", e.GetAttribute("marker-end")));
Assert.All(async, e => Assert.DoesNotContain("external", e.ClassList));
var spawn = Assert.Single(edges, e => e.ClassList.Contains("spawn"));
Assert.Equal("url(#fg-arrow-spawn)", spawn.GetAttribute("marker-end"));
Assert.All(edges.Where(e => !e.ClassList.Contains("spawn")), e => Assert.NotEqual("url(#fg-arrow-spawn)", e.GetAttribute("marker-end")));
Assert.Equal("url(#fg-arrow)", spawn.GetAttribute("marker-end"));
Assert.All(edges, e => Assert.DoesNotContain(",", e.GetAttribute("d")));
var badge = Assert.Single(cut.FindAll("g[data-test='flow-edge-spawn']"));
Assert.NotNull(badge.QuerySelector("path.fg-spawn-bolt"));
Assert.NotNull(badge.QuerySelector("circle.fg-spawn-badge-bg"));
// The badge sits on the riser of the edge it belongs to: between the two nodes horizontally and
// between the two lanes vertically, so it never overlaps either box.
var layout = FlowGraphView.ComputeLayout(graph, null);
var a = layout.Nodes.Single(n => n.Key == spawn.GetAttribute("data-from"));
var b = layout.Nodes.Single(n => n.Key == spawn.GetAttribute("data-to"));
var (x, y) = FlowGraphView.SpawnBadgePoint(a, b, isAsync: false);
Assert.Equal($"translate({FlowGraphView.F(x)} {FlowGraphView.F(y)})", badge.GetAttribute("transform"));
Assert.InRange(x, a.X + FlowGraphView.NodeWidth, b.X);
Assert.InRange(y, Math.Min(a.Y, b.Y) + FlowGraphView.NodeHeight / 2, Math.Max(a.Y, b.Y) + FlowGraphView.NodeHeight / 2);
}
[Fact]
public void SpawnBadgePoint_SitsOnTheVerticalRunOfEachConnectorShape()
{
var w = FlowGraphView.NodeWidth; var h = FlowGraphView.NodeHeight;
FlowLayoutNode N(string k, FlowLane lane, int col, double x, double y) => new(k, lane, col, x, y, null, null, false);
var tagsWrite = N("w", FlowLane.Tags, 0, 160, 16 + 2 * 110);
var siteRun = N("r", FlowLane.Site, 1, 330, 16 + 110);
// Upward Tags→Site hop (the rig shape): mid-gap x, halfway between the two lane centres.
var (x, y) = FlowGraphView.SpawnBadgePoint(tagsWrite, siteRun, isAsync: false);
Assert.Equal(160 + w + (330 - (160 + w)) / 2, x);
Assert.Equal(((16 + 220 + h / 2) + (16 + 110 + h / 2)) / 2, y);
Assert.Contains($"H {FlowGraphView.F(x)} V", FlowGraphView.OrthogonalPath(tagsWrite, siteRun));
// Same lane: the middle of the straight line.
var next = N("n", FlowLane.Tags, 1, 330, tagsWrite.Y);
Assert.Equal((160 + w + 330) / 2, FlowGraphView.SpawnBadgePoint(tagsWrite, next, false).X);
Assert.Equal(tagsWrite.Y + h / 2, FlowGraphView.SpawnBadgePoint(tagsWrite, next, false).Y);
// Backwards: on the loop under both nodes.
var back = N("b", FlowLane.Site, 0, 160, siteRun.Y);
Assert.Equal(Math.Max(tagsWrite.Y, back.Y) + h + 8, FlowGraphView.SpawnBadgePoint(next, back, false).Y);
// Async drop to a lower lane: on the first vertical run out of the source's bottom-centre.
var external = N("e", FlowLane.External, 1, 330, 16 + 3 * 110);
var (ax, ay) = FlowGraphView.SpawnBadgePoint(siteRun, external, isAsync: true);
Assert.Equal(siteRun.X + w / 2, ax);
Assert.InRange(ay, siteRun.Y + h, external.Y - FlowGraphView.LanePad / 2);
}
[Theory]
@@ -0,0 +1,559 @@
using Bunit;
using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// bUnit tests for the <see cref="InstancePicker"/> combobox: the closed field, the
/// panel that opens with every area collapsed, area expansion, instance selection
/// (callback + close + field text), the disabled guard, the outside-click backdrop,
/// parent-driven selection changes, the <c>ItemLabel</c> hook, and the search filter
/// with its keyboard surface (Enter / Escape / ArrowDown).
/// </summary>
public class InstancePickerTests : BunitContext
{
public InstancePickerTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static List<Area> Areas() =>
[
new("Line1") { Id = 1, SiteId = 1 },
new("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 },
];
private static List<Instance> Instances() =>
[
new("P1") { Id = 7, SiteId = 1, AreaId = 2 },
new("Zulu") { Id = 8, SiteId = 1 },
];
private IRenderedComponent<InstancePicker> RenderPicker(
Action<int>? onChanged = null,
bool disabled = false,
Func<Instance, string>? itemLabel = null,
int selectedInstanceId = 0) =>
Render<InstancePicker>(ps =>
{
ps.Add(p => p.Areas, Areas());
ps.Add(p => p.Instances, Instances());
ps.Add(p => p.DataTestPrefix, "t");
ps.Add(p => p.Disabled, disabled);
ps.Add(p => p.SelectedInstanceId, selectedInstanceId);
if (itemLabel is not null) ps.Add(p => p.ItemLabel, itemLabel);
if (onChanged is not null) ps.Add(p => p.SelectedInstanceIdChanged, onChanged);
});
private static void Open(IRenderedComponent<InstancePicker> cut) =>
cut.Find("[data-test='t-toggle']").Click();
private static void ClickRow(IRenderedComponent<InstancePicker> cut, string text) =>
cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == text).Click();
private static string InputValue(IRenderedComponent<InstancePicker> cut) =>
cut.Find("[data-test='t-input']").GetAttribute("value") ?? string.Empty;
private static void Type(IRenderedComponent<InstancePicker> cut, string text) =>
cut.Find("[data-test='t-input']").Input(text);
private static void Press(IRenderedComponent<InstancePicker> cut, string key) =>
cut.Find("[data-test='t-input']").KeyDown(new KeyboardEventArgs { Key = key });
private static string[] Rows(IRenderedComponent<InstancePicker> cut) =>
cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
/// <summary>The nth treeitem in document order - i.e. the nth row the operator can see.</summary>
private static AngleSharp.Dom.IElement TreeItem(IRenderedComponent<InstancePicker> cut, int index) =>
cut.FindAll("[role='treeitem']")[index];
[Fact]
public void Closed_ShowsNothingButTheField()
{
var cut = RenderPicker();
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
var input = cut.Find("[data-test='t-input']");
Assert.Equal("combobox", input.GetAttribute("role"));
Assert.Equal("false", input.GetAttribute("aria-expanded"));
}
[Fact]
public void Toggle_OpensPanel_WithAreasCollapsed()
{
var cut = RenderPicker();
Open(cut);
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("true", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded"));
var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
Assert.Equal(new[] { "Line1", "Zulu" }, rows);
// P1 sits under a collapsed area, so its row is not rendered at all.
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
// Area rows must not carry a data-instance-id at all (Blazor drops a null attribute).
var areaRow = cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == "Line1");
Assert.False(areaRow.HasAttribute("data-instance-id"));
}
[Fact]
public void ClickingAnAreaLabel_ExpandsIt()
{
var cut = RenderPicker();
Open(cut);
ClickRow(cut, "Line1");
Assert.Contains(cut.FindAll("[data-test='t-row']"), r => r.TextContent.Trim() == "Cell A");
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
ClickRow(cut, "Cell A");
Assert.Single(cut.FindAll("[data-instance-id='7']"));
}
[Fact]
public void ClickingAnInstance_Selects_Closes_AndRaisesTheCallback()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
ClickRow(cut, "Line1");
ClickRow(cut, "Cell A");
cut.Find("[data-instance-id='7']").Click();
Assert.Equal(7, picked);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("P1", InputValue(cut));
}
[Fact]
public void Disabled_NeverOpens()
{
var cut = RenderPicker(disabled: true);
Assert.True(cut.Find("[data-test='t-input']").HasAttribute("disabled"));
Assert.True(cut.Find("[data-test='t-toggle']").HasAttribute("disabled"));
Open(cut);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
}
[Fact]
public void Backdrop_ClickClosesThePanel_WithoutChangingSelection()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
cut.Find("[data-test='t-backdrop']").Click();
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Null(picked);
Assert.Equal(string.Empty, InputValue(cut));
}
[Fact]
public void ParentDrivenSelection_UpdatesTheFieldText()
{
var cut = RenderPicker();
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 8));
Assert.Equal("Zulu", InputValue(cut));
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 0));
Assert.Equal(string.Empty, InputValue(cut));
}
[Fact]
public void SiteChange_NewInstancesList_ClearsAStaleSelectionText()
{
var cut = RenderPicker(selectedInstanceId: 8);
Assert.Equal("Zulu", InputValue(cut));
Open(cut);
// A different site: neither the areas nor the previously selected instance survive.
cut.Render(ps =>
{
ps.Add(p => p.Areas, new List<Area>());
ps.Add(p => p.Instances, new List<Instance> { new("Other") { Id = 99, SiteId = 2 } });
});
Assert.Equal(string.Empty, InputValue(cut));
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
}
[Fact]
public void ItemLabel_IsUsedForRowsAndField()
{
var cut = RenderPicker(itemLabel: i => i.UniqueName + " (x)");
Open(cut);
var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
Assert.Equal(new[] { "Line1", "Zulu (x)" }, rows);
ClickRow(cut, "Line1");
ClickRow(cut, "Cell A");
cut.Find("[data-instance-id='7']").Click();
Assert.Equal("P1 (x)", InputValue(cut));
}
[Fact]
public void Typing_Filters_AndExpandsToTheMatches()
{
var cut = RenderPicker();
Open(cut);
Type(cut, "p1");
// The match's ancestor areas are force-expanded, so its row renders without a click.
Assert.Single(cut.FindAll("[data-instance-id='7']"));
var rows = Rows(cut);
Assert.Contains("Line1", rows);
Assert.Contains("Cell A", rows);
Assert.DoesNotContain("Zulu", rows);
}
[Fact]
public void Clearing_RestoresTheFullTree_Collapsed()
{
var cut = RenderPicker();
Open(cut);
Type(cut, "p1");
Assert.Single(cut.FindAll("[data-instance-id='7']"));
Type(cut, string.Empty);
Assert.Equal(new[] { "Line1", "Zulu" }, Rows(cut));
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
}
[Fact]
public void Typing_OpensTheClosedPanel()
{
var cut = RenderPicker();
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Type(cut, "z");
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Assert.Equal(new[] { "Zulu" }, Rows(cut));
}
[Fact]
public void Enter_WithNoMatch_DoesNothing()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
Type(cut, "zzz");
Press(cut, "Enter");
Assert.Null(picked);
Assert.Single(cut.FindAll("[data-test='t-panel']"));
}
[Fact]
public void Enter_PicksTheFirstVisibleMatch()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
// "u" matches Zulu only — P1 drops out along with its now-empty ancestor areas.
Type(cut, "u");
Press(cut, "Enter");
Assert.Equal(8, picked);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("Zulu", InputValue(cut));
}
[Fact]
public void Enter_WithNoFilter_PicksTheFirstInstanceInDocumentOrder()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
Press(cut, "Enter");
// Areas render before root instances, but every area is collapsed — so the first
// instance the operator can actually see is the root-level Zulu, not P1.
Assert.Equal(8, picked);
Assert.Equal("Zulu", InputValue(cut));
}
[Fact]
public void Escape_ClosesAndRestoresTheSelectedText()
{
var picked = 0;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
ClickRow(cut, "Line1");
ClickRow(cut, "Cell A");
cut.Find("[data-instance-id='7']").Click();
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, picked));
Assert.Equal("P1", InputValue(cut));
Open(cut);
Type(cut, "zzz");
Assert.Equal("zzz", InputValue(cut));
Press(cut, "Escape");
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("P1", InputValue(cut));
}
[Fact]
public void ArrowDown_MovesFocusIntoTheTree()
{
var cut = RenderPicker();
Open(cut);
// The first row owns the tab stop by default, so move it off with the tree's own
// arrow navigation first — otherwise the assertion below could pass by accident.
ClickRow(cut, "Line1");
cut.FindAll("[role='treeitem']")[0].KeyDown(new KeyboardEventArgs { Key = "ArrowDown" });
Assert.Equal("-1", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex"));
Press(cut, "ArrowDown");
var items = cut.FindAll("[role='treeitem']");
Assert.Equal("0", items[0].GetAttribute("tabindex"));
Assert.All(items.Skip(1), i => Assert.Equal("-1", i.GetAttribute("tabindex")));
// The tab stop above is the behavioural assertion; this is the belt-and-braces check
// that real browser focus was requested too. ElementReference.FocusAsync surfaces as a
// JS invocation under Loose mode — drop this line if a bUnit upgrade stops recording it.
Assert.Contains(
JSInterop.Invocations,
i => i.Identifier.Contains("focus", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ArrowDown_OnAClosedPicker_OpensThePanel_AndFocusesTheFirstRow()
{
// The tree does not exist yet when the keydown is handled, so this pins the deferred
// path: the panel opens in this render pass and the focus lands in the next one.
var cut = RenderPicker();
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Press(cut, "ArrowDown");
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("0", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex"));
}
[Fact]
public void Escape_WhenClosed_IsANoOp()
{
var cut = RenderPicker(selectedInstanceId: 8);
Assert.Equal("Zulu", InputValue(cut));
Press(cut, "Escape");
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("Zulu", InputValue(cut));
}
[Fact]
public void FilterIsNotCarriedIntoSelection()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
Type(cut, "p1");
cut.Find("[data-instance-id='7']").Click();
Assert.Equal(7, picked);
Assert.Equal("P1", InputValue(cut));
Open(cut);
Assert.Equal(new[] { "Line1", "Zulu" }, Rows(cut));
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
}
[Fact]
public void Toggle_WhileOpen_ClosesThePanel()
{
var cut = RenderPicker();
Open(cut);
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Open(cut);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("false", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded"));
}
[Fact]
public void TheFieldSitsAboveTheBackdrop_SoItStaysClickableWhileOpen()
{
// The backdrop is a fixed full-viewport layer; the stylesheet lifts the field above
// it through this class. Without it a click on the input or the chevron would land on
// the backdrop instead and close the panel. Only the class can be pinned here — the
// z-index itself is scoped CSS, verified visually.
var cut = RenderPicker();
Open(cut);
var field = cut.Find("[data-test='t-picker'] .input-group");
Assert.Contains("ip-field", field.ClassList);
Assert.Single(cut.FindAll("[data-test='t-backdrop']"));
}
[Fact]
public void TwoPickers_GetDistinctPanelIds()
{
// The panel id (and the field's aria-controls pointing at it) is minted per component
// instance, so two pickers on one page cannot collide on a DOM id.
var first = RenderPicker();
var second = RenderPicker();
Open(first);
Open(second);
var firstPanel = first.Find("[data-test='t-panel']").Id;
var secondPanel = second.Find("[data-test='t-panel']").Id;
Assert.False(string.IsNullOrEmpty(firstPanel));
Assert.NotEqual(firstPanel, secondPanel);
Assert.Equal(firstPanel, first.Find("[data-test='t-input']").GetAttribute("aria-controls"));
Assert.Equal(secondPanel, second.Find("[data-test='t-input']").GetAttribute("aria-controls"));
}
[Fact]
public void SameInstancesReference_LeavesTheTypedTextAndOpenPanelAlone()
{
// Complement of SiteChange_NewInstancesList_ClearsAStaleSelectionText: an unrelated
// parameter change is NOT a site change, so a half-typed filter must survive it.
var cut = RenderPicker();
Open(cut);
Type(cut, "zu");
cut.Render(ps => ps.Add(p => p.Placeholder, "Pick an instance"));
Assert.Equal("zu", InputValue(cut));
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Assert.Equal(new[] { "Zulu" }, Rows(cut));
}
// -- Keyboard activation on the tree itself --------------------------------
[Fact]
public void KeyboardActivation_OnATreeRow_SelectsTheInstance()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
Press(cut, "ArrowDown");
// Areas render before root instances, so Zulu is the SECOND treeitem - the first
// one is the Line1 area (see KeyboardActivation_OnAnAreaRow_TogglesIt).
TreeItem(cut, 1).KeyDown(new KeyboardEventArgs { Key = "Enter" });
Assert.Equal(8, picked);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("Zulu", InputValue(cut));
}
[Fact]
public void KeyboardActivation_OnAnAreaRow_TogglesIt()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
Press(cut, "ArrowDown");
TreeItem(cut, 0).KeyDown(new KeyboardEventArgs { Key = "Enter" });
// An area activates as an expand, not as a pick: its children appear and the
// panel stays open with no callback raised.
Assert.Contains("Cell A", Rows(cut));
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Assert.Null(picked);
// The same key on the same row closes it again - activation is a toggle.
TreeItem(cut, 0).KeyDown(new KeyboardEventArgs { Key = "Enter" });
Assert.DoesNotContain("Cell A", Rows(cut));
}
[Fact]
public void KeyboardActivation_AndMouseClick_RaiseExactlyOneSelection()
{
// The label click and Enter both run through TreeView's single selection path,
// so neither input mode may double-fire the callback.
var picks = new List<int>();
var cut = RenderPicker(onChanged: picks.Add);
Open(cut);
ClickRow(cut, "Zulu");
Assert.Equal(new[] { 8 }, picks);
Open(cut);
TreeItem(cut, 1).KeyDown(new KeyboardEventArgs { Key = "Enter" });
Assert.Equal(new[] { 8, 8 }, picks);
}
// -- Accessible name / labelling -------------------------------------------
[Fact]
public void Input_CarriesAnAccessibleName_ByDefaultAndFromTheParameter()
{
var cut = RenderPicker();
Assert.Equal("Instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label"));
cut.Render(ps => ps.Add(p => p.AriaLabel, "Target instance"));
Assert.Equal("Target instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label"));
}
[Fact]
public void InputId_DefaultsToAGeneratedId_AndHonoursTheParameter()
{
// A page that wants its own <label for> supplies the id; two pickers left to the
// generated one must still not collide.
var first = RenderPicker();
var second = RenderPicker();
var generated = first.Find("[data-test='t-input']").Id;
Assert.False(string.IsNullOrEmpty(generated));
Assert.NotEqual(generated, second.Find("[data-test='t-input']").Id);
first.Render(ps => ps.Add(p => p.InputId, "flow-instance"));
Assert.Equal("flow-instance", first.Find("[data-test='t-input']").Id);
}
[Fact]
public void AriaControls_IsRenderedOnlyWhileThePanelIsOpen()
{
var cut = RenderPicker();
// Closed: the panel element does not exist, so a dangling aria-controls would
// point screen readers at nothing.
Assert.False(cut.Find("[data-test='t-input']").HasAttribute("aria-controls"));
Open(cut);
Assert.Equal(
cut.Find("[data-test='t-panel']").Id,
cut.Find("[data-test='t-input']").GetAttribute("aria-controls"));
}
}
@@ -0,0 +1,221 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Unit tests for <see cref="InstanceTreeBuilder"/> — the pure area/instance forest
/// behind the Instance Picker combobox (design 2026-09-08).
/// </summary>
public class InstanceTreeBuilderTests
{
private static Area MakeArea(int id, string name, int? parentAreaId = null) =>
new(name) { Id = id, SiteId = 1, ParentAreaId = parentAreaId };
private static Instance MakeInstance(int id, string uniqueName, int? areaId = null) =>
new(uniqueName) { Id = id, SiteId = 1, AreaId = areaId };
[Fact]
public void Build_NestsAreasByParent_AndPutsRootInstancesAfterRootAreas()
{
var line1 = MakeArea(1, "Line1");
var cellA = MakeArea(2, "Cell A", parentAreaId: 1);
var p1 = MakeInstance(10, "P1", areaId: 2);
var zulu = MakeInstance(11, "Zulu");
var alpha = MakeInstance(12, "Alpha");
var roots = InstanceTreeBuilder.Build([line1, cellA], [p1, zulu, alpha], filter: null);
Assert.Equal(new[] { "a:1", "i:12", "i:11" }, roots.Select(n => n.Key));
var areaNode = roots[0];
Assert.Equal("Line1", areaNode.Label);
Assert.False(areaNode.IsInstance);
Assert.Same(line1, areaNode.Area);
Assert.Null(areaNode.Instance);
Assert.True(areaNode.HasChildren);
var cellNode = Assert.Single(areaNode.Children);
Assert.Equal("a:2", cellNode.Key);
Assert.Equal("Cell A", cellNode.Label);
Assert.Same(cellA, cellNode.Area);
var p1Node = Assert.Single(cellNode.Children);
Assert.Equal("i:10", p1Node.Key);
Assert.Equal("P1", p1Node.Label);
Assert.True(p1Node.IsInstance);
Assert.Same(p1, p1Node.Instance);
Assert.Null(p1Node.Area);
Assert.False(p1Node.HasChildren);
Assert.Equal("Alpha", roots[1].Label);
Assert.Same(alpha, roots[1].Instance);
Assert.Equal("Zulu", roots[2].Label);
Assert.Same(zulu, roots[2].Instance);
}
[Fact]
public void Build_SortsAreasAndInstancesCaseInsensitively()
{
var beta = MakeArea(1, "beta");
var alphaArea = MakeArea(2, "Alpha");
var zebra = MakeInstance(10, "zebra");
var apple = MakeInstance(11, "Apple");
var roots = InstanceTreeBuilder.Build([beta, alphaArea], [zebra, apple], filter: null);
Assert.Equal(new[] { "Alpha", "beta", "Apple", "zebra" }, roots.Select(n => n.Label));
}
[Fact]
public void Build_OrphanedArea_BecomesARoot()
{
// ParentAreaId points at an area that is not in the supplied list.
var orphan = MakeArea(5, "Orphan", parentAreaId: 99);
var child = MakeInstance(10, "OrphanChild", areaId: 5);
var roots = InstanceTreeBuilder.Build([orphan], [child], filter: null);
var root = Assert.Single(roots);
Assert.Equal("a:5", root.Key);
var leaf = Assert.Single(root.Children);
Assert.Equal("i:10", leaf.Key);
}
[Fact]
public void Build_InstanceWithUnknownArea_SurfacesAtRoot()
{
// AreaId points at an area that is not in the supplied list — the instance must not
// vanish; it is listed at the root like an instance with no area at all.
var line1 = MakeArea(1, "Line1");
var stranded = MakeInstance(10, "Stranded", areaId: 99);
var roots = InstanceTreeBuilder.Build([line1], [stranded], filter: null);
Assert.Equal(new[] { "a:1", "i:10" }, roots.Select(n => n.Key));
Assert.Same(stranded, roots[1].Instance);
}
[Fact]
public void Build_InstanceWithUnknownArea_StaysFilterable()
{
var line1 = MakeArea(1, "Line1");
var stranded = MakeInstance(10, "Stranded", areaId: 99);
var matched = InstanceTreeBuilder.Build([line1], [stranded], filter: "rand");
Assert.Equal("i:10", Assert.Single(matched).Key);
Assert.Empty(InstanceTreeBuilder.Build([line1], [stranded], filter: "nomatch"));
}
[Fact]
public void Build_DeepNesting_WiresChildrenAtEveryLevel()
{
var plant = MakeArea(1, "Plant");
var line = MakeArea(2, "Line", parentAreaId: 1);
var cell = MakeArea(3, "Cell", parentAreaId: 2);
var deep = MakeInstance(10, "Deep", areaId: 3);
var roots = InstanceTreeBuilder.Build([plant, line, cell], [deep], filter: null);
var plantNode = Assert.Single(roots);
Assert.Equal("a:1", plantNode.Key);
var lineNode = Assert.Single(plantNode.Children);
Assert.Equal("a:2", lineNode.Key);
var cellNode = Assert.Single(lineNode.Children);
Assert.Equal("a:3", cellNode.Key);
var leaf = Assert.Single(cellNode.Children);
Assert.Equal("i:10", leaf.Key);
Assert.False(leaf.HasChildren);
}
[Fact]
public void Build_CyclicParentAreaIds_DoNotLoopOrThrow()
{
// A bad row pair: each area names the other as its parent. Neither is reachable
// from a root, so the forest is simply empty — and building it must terminate.
var a = MakeArea(1, "A", parentAreaId: 2);
var b = MakeArea(2, "B", parentAreaId: 1);
var inA = MakeInstance(10, "InA", areaId: 1);
var inB = MakeInstance(11, "InB", areaId: 2);
var roots = InstanceTreeBuilder.Build([a, b], [inA, inB], filter: null);
Assert.Empty(roots);
}
[Fact]
public void Build_Filter_MatchesInstanceNamesOnly_AreaNameMatchIsNotEnough()
{
// "Line1" contains the needle but holds no matching instance — it is dropped.
var line1 = MakeArea(1, "Line1");
var line2 = MakeArea(2, "Line2");
var other = MakeInstance(10, "Pump", areaId: 1);
var hit = MakeInstance(11, "Line1Feeder", areaId: 2);
var roots = InstanceTreeBuilder.Build([line1, line2], [other, hit], filter: "line1");
var root = Assert.Single(roots);
Assert.Equal("a:2", root.Key);
Assert.Equal("i:11", Assert.Single(root.Children).Key);
}
[Fact]
public void Build_Filter_KeepsMatchingInstancesWithAncestors_DropsEmptyBranches()
{
var line1 = MakeArea(1, "Line1");
var cellA = MakeArea(2, "Cell A", parentAreaId: 1);
var emptyArea = MakeArea(3, "Line2");
var p1 = MakeInstance(10, "P1", areaId: 2);
var zulu = MakeInstance(11, "Zulu");
var alpha = MakeInstance(12, "Alpha");
var roots = InstanceTreeBuilder.Build([line1, cellA, emptyArea], [p1, zulu, alpha], filter: "p1");
var root = Assert.Single(roots);
Assert.Equal("a:1", root.Key);
var cellNode = Assert.Single(root.Children);
Assert.Equal("a:2", cellNode.Key);
var leaf = Assert.Single(cellNode.Children);
Assert.Equal("i:10", leaf.Key);
}
[Fact]
public void Build_Filter_WhitespaceMeansNoFilter()
{
var line1 = MakeArea(1, "Line1");
var p1 = MakeInstance(10, "P1", areaId: 1);
var zulu = MakeInstance(11, "Zulu");
var roots = InstanceTreeBuilder.Build([line1], [p1, zulu], filter: " ");
Assert.Equal(new[] { "a:1", "i:11" }, roots.Select(n => n.Key));
Assert.Single(roots[0].Children);
}
[Fact]
public void Build_NullOrEmptyInputs_ReturnEmpty()
{
Assert.Empty(InstanceTreeBuilder.Build(null, null, filter: null));
Assert.Empty(InstanceTreeBuilder.Build([], [], filter: null));
Assert.Empty(InstanceTreeBuilder.Build(null, [], filter: "anything"));
var orphanInstances = InstanceTreeBuilder.Build(null, [MakeInstance(1, "Solo")], filter: null);
Assert.Equal("i:1", Assert.Single(orphanInstances).Key);
var areasOnly = InstanceTreeBuilder.Build([MakeArea(1, "Line1")], null, filter: null);
Assert.Equal("a:1", Assert.Single(areasOnly).Key);
}
[Fact]
public void Build_AreaWithNoInstancesAndNoFilter_IsStillShown()
{
var empty = MakeArea(1, "Empty");
var roots = InstanceTreeBuilder.Build([empty], [], filter: null);
var root = Assert.Single(roots);
Assert.Equal("a:1", root.Key);
Assert.False(root.HasChildren);
}
}
@@ -472,6 +472,47 @@ public class FlowGraphBuilderTests
Assert.Equal(cascade.EventId, spawn.ToEventId);
}
[Fact]
public void SimultaneousWrites_TriggerCascade_SpawnsFromTheWriteOfTheTriggerAttribute_NotTheLastWrite()
{
// Rig 2026-09-08: a run wrote Result, ResultText, Flag=false and Processed inside the same millisecond,
// and the value-change child (trigger attribute Flag) was drawn spawning from Processed — the last write
// at or before the child. The child names its trigger attribute, so that write wins outright.
var run = SiteRunRow(0);
var result = WriteRow(10, "DelmiaReceiver.Result");
var flag = WriteRow(10, "DelmiaReceiver.Flag");
var processed = WriteRow(10, "DelmiaReceiver.Processed");
var cascade = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(10), actor: "OnFlag", target: "OnFlag", sourceNode: "node-a",
executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 3,
extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "delmiareceiver.flag" }.ToJson());
var graph = FlowGraphBuilder.Build([run, result, flag, processed, cascade]);
var spawn = Assert.Single(graph.Edges, e => e.Spawn);
Assert.Equal(flag.EventId, spawn.FromEventId);
Assert.Equal(cascade.EventId, spawn.ToEventId);
}
[Fact]
public void TriggerCascade_WhoseAttributeTheParentNeverWrote_FallsBackToTheTimeRule()
{
// The device echo of a DCL-sourced change, or a trace from an older site that names an attribute
// the parent chain has no write for: the attribute rule finds nothing and the time rule still applies.
var run = SiteRunRow(0);
var writeA = WriteRow(10, "DelmiaReceiver.A");
var writeB = WriteRow(30, "DelmiaReceiver.B");
var cascade = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(15), actor: "OnX", target: "OnX", sourceNode: "node-a",
executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 3,
extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "DelmiaReceiver.X" }.ToJson());
var graph = FlowGraphBuilder.Build([run, writeA, writeB, cascade]);
var spawn = Assert.Single(graph.Edges, e => e.Spawn);
Assert.Equal(writeA.EventId, spawn.FromEventId);
}
[Fact]
public void FoldedNode_TakesTheEmissionIndexOfItsEarliestEmittedRow()
{
@@ -588,4 +629,34 @@ public class FlowGraphBuilderTests
var into = Assert.Single(graph.Edges, e => e.ToEventId == graph.Steps[2].EventId);
Assert.Equal(graph.Steps[1].EventId, into.FromEventId);
}
// ---- Script entry point (2026-09-08 plan, Task 6) -----------------------------------------
[Fact]
public void RouteCallRoot_WithoutInboundRow_BuildsCentralToSite_AndNoResponse()
{
// Flow Debugger script entry (design 2026-09-08 §4): a direct script call's root is an
// operator-attributed RouteCall row — there is no InboundRequest and no Response.
var root = Guid.NewGuid();
var run = Guid.NewGuid();
var events = new List<AuditEvent>
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: T0, actor: "ops", target: "M1.Manual", sourceNode: "central-a", executionId: root,
sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 50, requestSummary: "{\"n\":3}", responseSummary: "42",
extra: new ScriptTraceExtra { Source = "flow-debugger", ChildExecutionId = run }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(5), actor: "Manual", target: "Manual", sourceNode: "node-a",
executionId: run, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 40,
extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()),
};
var graph = FlowGraphBuilder.Build(events);
Assert.Equal([FlowStepKind.RouteCall, FlowStepKind.ScriptRun], graph.Steps.Select(s => s.Kind).ToList());
Assert.Equal([FlowLane.Central, FlowLane.Site], graph.Steps.Select(s => s.Lane).ToList());
var edge = Assert.Single(graph.Edges);
Assert.Equal((graph.Steps[0].EventId, graph.Steps[1].EventId, true), (edge.FromEventId, edge.ToEventId, edge.CrossesBoundary));
Assert.DoesNotContain(graph.Steps, s => s.Kind == FlowStepKind.Response);
}
}
@@ -0,0 +1,46 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Scripts;
/// <summary>Flow Debugger script entry (design 2026-09-08 §2): the monitored attribute of a ValueChange/Conditional trigger.</summary>
public class ScriptTriggerAttributeTests
{
[Theory]
[InlineData("ValueChange", """{"attributeName":"Level"}""", "Level")]
[InlineData("valuechange", """{"attribute":"Level"}""", "Level")]
[InlineData("Conditional", """{"attributeName":"Level","operator":">","threshold":"5"}""", "Level")]
[InlineData("CONDITIONAL", """{"attribute":"Flag"}""", "Flag")]
public void WriteKinds_ReturnTheMonitoredAttribute(string kind, string config, string expected)
{
Assert.True(ScriptTriggerAttribute.TryRead(kind, config, out var attribute));
Assert.Equal(expected, attribute);
Assert.True(ScriptTriggerAttribute.IsWriteKind(kind));
}
[Theory]
[InlineData("Call", """{"attributeName":"Level"}""")]
[InlineData("Interval", """{"intervalMs":1000}""")]
[InlineData("Expression", """{"expression":"true"}""")]
[InlineData(null, null)]
[InlineData("", "")]
public void OtherKinds_ReturnFalse_EvenWhenTheConfigNamesAnAttribute(string? kind, string? config)
{
Assert.False(ScriptTriggerAttribute.TryRead(kind, config, out var attribute));
Assert.Null(attribute);
Assert.False(ScriptTriggerAttribute.IsWriteKind(kind));
}
[Theory]
[InlineData("ValueChange", null)]
[InlineData("ValueChange", "")]
[InlineData("ValueChange", "not json")]
[InlineData("ValueChange", "[1,2]")]
[InlineData("ValueChange", """{"attributeName":""}""")]
[InlineData("ValueChange", """{"other":"x"}""")]
public void WriteKind_WithoutAReadableAttribute_ReturnsFalse_ButIsStillAWriteKind(string kind, string? config)
{
Assert.False(ScriptTriggerAttribute.TryRead(kind, config, out var attribute));
Assert.Null(attribute);
Assert.True(ScriptTriggerAttribute.IsWriteKind(kind));
}
}
@@ -10,9 +10,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
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.Flattening;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug;
@@ -340,10 +343,15 @@ public class FlowDebugRunnerTests
}
[Fact]
public void RoleGate_TheTwoRunCommands_AreDeployerOnly()
public void RoleGate_TheThreeRunCommands_AreDeployerOnly()
{
// arch-review F3: a Live run executes scripts and writes attributes, so it stays Deployer-only.
foreach (var cmd in new object[] { new FlowDebugRunMethodCommand(1, null), new FlowDebugRunValueChangeCommand(1, "a", "b") })
foreach (var cmd in new object[]
{
new FlowDebugRunMethodCommand(1, null),
new FlowDebugRunValueChangeCommand(1, "a", "b"),
new FlowDebugRunScriptCommand(1, "Manual", null, null),
})
Assert.Equal(new[] { "Deployer" }, ManagementActor.GetRequiredRoles(cmd));
}
@@ -448,6 +456,331 @@ public class FlowDebugRunnerTests
Assert.Equal(501, result.Events.Count); // inbound row + the 500 kept
}
// --- Flow Debugger script entry (design 2026-09-08) ---
//
// The runner resolves the script from the FLATTENED configuration and branches on its
// server-side trigger kind: ValueChange/Conditional fire by WRITING the monitored attribute
// (the shared WriteAndPollAsync helper), every other kind is a direct traced routed call
// with ONE operator-attributed RouteCall root row.
/// <summary>Wires instance 5 / site-a like <see cref="WireValueChange"/> plus a flattened configuration holding the given scripts.</summary>
private (ITemplateEngineRepository Templates, ISiteRepository Sites, IPullTraceEventsClient Puller, IFlatteningPipeline Pipeline)
WireScript(params ResolvedScript[] scripts)
{
var (templates, sites, puller) = WireValueChange();
var pipeline = Substitute.For<IFlatteningPipeline>();
var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", TemplateId = 10, SiteId = 2, Scripts = scripts };
pipeline.FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), false, null)
.Returns(Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(config, "hash", new ValidationResult())));
_services.AddSingleton(pipeline);
return (templates, sites, puller, pipeline);
}
private static ResolvedScript CallScript(string name, string? parameterDefinitions = null, int? timeout = null) =>
new() { CanonicalName = name, Code = "return 1;", TriggerType = "Call", ParameterDefinitions = parameterDefinitions, ExecutionTimeoutSeconds = timeout };
private static ResolvedScript WriteScript(string name, string kind, string attribute) =>
new() { CanonicalName = name, Code = "return 1;", TriggerType = kind, TriggerConfiguration = $$"""{"attributeName":"{{attribute}}"}""" };
[Fact]
public async Task RunScript_WriteKind_WritesTheTriggerAttribute_ThenPolls()
{
var (_, _, puller, _) = WireScript(WriteScript("OnLevel", "Conditional", "Level"));
RouteToSetAttributesRequest? write = null;
_router.RouteToSetAttributesAsync("site-a", Arg.Do<RouteToSetAttributesRequest>(r => write = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToSetAttributesResponse(((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));
// Mirrors RunValueChange_TracedWrite_ThenPollsUntilSpawnedRunSettles: nothing on the first
// poll, then the SAME row (fixed event id) from the second on — a fresh id per poll would
// re-arm the settle window forever and the loop would only ever end on the deadline.
var calls = 0;
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
calls++;
return calls >= 2
? (IReadOnlyList<AuditEvent>)[ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
eventId: Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001"),
executionId: Guid.NewGuid(), parentExecutionId: ((IReadOnlyCollection<Guid>)ci[1]).First(), target: "OnLevel")]
: Array.Empty<AuditEvent>();
});
var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "onlevel", null, "7", WaitSeconds: 10), Deployer, CancellationToken.None);
Assert.True(result.Completed, result.Error);
Assert.Equal("7", write!.AttributeValues["Level"]);
Assert.True(write.Trace);
Assert.Equal(result.RootExecutionId, write.ParentExecutionId);
Assert.Equal(2, result.Events.Count);
Assert.Equal(AuditKind.AttributeWrite, result.Events[0].AsRow().Kind);
Assert.Equal(AuditKind.ScriptRun, result.Events[1].AsRow().Kind);
// Settled: at least one quiet poll (2 s window) after the last new row, then stopped.
Assert.True(calls >= 3, $"expected settle polls, got {calls}");
await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default);
}
[Fact]
public async Task RunScript_OnStandby_RefusesWithStandbyCode_BeforeTouchingTheInstance()
{
var (templates, _, _, pipeline) = WireScript(CallScript("Manual"));
_gate.IsActiveNode.Returns(false);
var ex = await Assert.ThrowsAsync<ManagementCommandException>(() => Runner().RunScriptAsync(
_services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None));
Assert.Equal(FlowDebugRunner.StandbyNodeCode, ex.Code);
Assert.Contains("ACTIVE central node", ex.Message);
await templates.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default, default);
await pipeline.DidNotReceiveWithAnyArgs().FlattenAndValidateAsync(default, default, default, default);
}
[Fact]
public async Task RunScript_WriteKind_UnreadableTriggerConfig_FailsValidation()
{
// A write-kind trigger whose configuration names no attribute: there is nothing to write,
// so the run is refused rather than guessing an attribute.
WireScript(new ResolvedScript
{
CanonicalName = "OnLevel", Code = "return 1;", TriggerType = "ValueChange", TriggerConfiguration = "{}",
});
var ex = await Assert.ThrowsAsync<ManagementCommandException>(() => Runner().RunScriptAsync(
_services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "OnLevel", null, "7"), Deployer, CancellationToken.None));
Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code);
Assert.Contains("trigger configuration", ex.Message);
await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
}
[Fact]
public async Task RunScript_WriteKind_WithoutValue_FailsValidation_BeforeAnyWrite()
{
WireScript(WriteScript("OnLevel", "ValueChange", "Level"));
var ex = await Assert.ThrowsAsync<ManagementCommandException>(() => Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "OnLevel", null, null), Deployer, CancellationToken.None));
Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code);
await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
}
[Fact]
public async Task RunScript_WriteKind_SiblingScriptRun_DoesNotCountAsCompletion()
{
// The write may fire several scripts. A sibling's run is still COLLECTED (the graph draws
// it) but must not report the script the operator chose as having run.
var (_, _, puller, _) = WireScript(WriteScript("OnLevel", "ValueChange", "Level"));
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => (IReadOnlyList<AuditEvent>)[ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
eventId: Guid.Parse("cccccccc-0000-0000-0000-000000000001"),
executionId: Guid.NewGuid(), parentExecutionId: ((IReadOnlyCollection<Guid>)ci[1]).First(), target: "OtherScript")]);
var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "OnLevel", null, "7", WaitSeconds: 3), Deployer, CancellationToken.None);
Assert.False(result.Completed);
Assert.Equal(FlowDebugRunner.NoTriggerCode, result.ErrorCode);
Assert.Contains("OnLevel", result.Error);
// ...but the sibling is in the events, so the operator can see what DID run.
Assert.Equal(2, result.Events.Count);
Assert.Equal("OtherScript", result.Events[1].Target);
}
[Fact]
public async Task RunScript_CallKind_RoutesATracedCall_AndReturnsRootRouteCallRow_ThenSiteRows()
{
WireScript(CallScript("Manual", """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", timeout: 5));
var child = Guid.NewGuid();
var siteRun = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
executionId: child, target: "Manual");
RouteToCallRequest? captured = null;
_router.RouteToCallAsync("site-a", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 42, null, DateTimeOffset.UtcNow, child, [siteRun]));
var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "Manual", Json("{\"n\":3}"), null), Deployer, CancellationToken.None);
Assert.True(result.Completed, result.Error);
Assert.Null(result.ErrorCode);
Assert.True(captured!.Trace);
Assert.Equal("Pump1", captured.InstanceUniqueName);
Assert.Equal("Manual", captured.ScriptName);
Assert.Equal(result.RootExecutionId, captured.ParentExecutionId);
Assert.Equal(3L, Convert.ToInt64(captured.Parameters!["n"]));
Assert.Equal(2, result.Events.Count);
var root = result.Events[0].AsRow();
Assert.Equal(AuditKind.RouteCall, root.Kind);
Assert.Equal(result.RootExecutionId, root.ExecutionId);
Assert.Equal("ops", result.Events[0].Actor);
Assert.Equal("Pump1.Manual", result.Events[0].Target);
Assert.Equal("site-a", root.SourceSiteId);
Assert.Equal("flow-debugger", ScriptTraceExtra.TryParse(root.Extra)!.Source);
Assert.Equal(child, ScriptTraceExtra.TryParse(root.Extra)!.ChildExecutionId);
Assert.Contains("\"n\":3", root.RequestSummary);
Assert.Contains("42", root.ResponseSummary);
Assert.Same(siteRun, result.Events[1]);
Assert.Single(_central); // the root row was written centrally
}
[Fact]
public async Task RunScript_CallKind_InvalidParameters_FailsBeforeRouting()
{
WireScript(CallScript("Manual", """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}"""));
var ex = await Assert.ThrowsAsync<ManagementCommandException>(() => Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "Manual", Json("{}"), null), Deployer, CancellationToken.None));
Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code);
await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default);
Assert.Empty(_central);
}
[Fact]
public async Task RunScript_CallKind_SiteFailure_ReportsCallFailed_WithTheRootRow()
{
WireScript(CallScript("Manual"));
_router.RouteToCallAsync("site-a", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, false, null, "Script 'Manual' not found on instance 'Pump1'.", DateTimeOffset.UtcNow));
var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None);
Assert.False(result.Completed);
Assert.Equal(FlowDebugRunner.CallFailedCode, result.ErrorCode);
Assert.Contains("not found", result.Error);
Assert.Single(result.Events);
Assert.Equal(AuditStatus.Failed, result.Events[0].AsRow().Status);
}
[Fact]
public async Task RunScript_UnknownScript_IsNotFound()
{
WireScript(CallScript("Manual"));
var ex = await Assert.ThrowsAsync<ManagementCommandException>(() => Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "Nope", null, null), Deployer, CancellationToken.None));
Assert.Equal(FlowDebugRunner.NotFoundCode, ex.Code);
Assert.Contains("Nope", ex.Message);
}
[Fact]
public async Task RunScript_OutOfScopeSite_IsRefused_BeforeFlattening()
{
var (_, _, _, pipeline) = WireScript(CallScript("Manual"));
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "9" });
await Assert.ThrowsAsync<SiteScopeViolationException>(() => Runner().RunScriptAsync(
_services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Manual", null, null), scoped, CancellationToken.None));
await pipeline.DidNotReceiveWithAnyArgs().FlattenAndValidateAsync(default, default, default, default);
}
[Fact]
public async Task RunScript_CallKind_UnserializableReturnValue_StillWritesTheRootRow()
{
// The site already RAN the script by the time the root row is built, so a return value the
// serializer refuses (ScriptTraceExtra.JsonOptions rejects named floating-point literals)
// must not lose the audit row and surface as MapFault's generic "internal error".
WireScript(CallScript("Manual"));
_router.RouteToCallAsync("site-a", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, double.NaN, null, DateTimeOffset.UtcNow));
var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None);
Assert.True(result.Completed, result.Error);
Assert.Null(result.ErrorCode);
var root = Assert.Single(result.Events).AsRow();
Assert.Equal(AuditKind.RouteCall, root.Kind);
Assert.Contains("unserializable", root.ResponseSummary);
var written = Assert.Single(_central);
Assert.Equal(AuditKind.RouteCall, written.AsRow().Kind);
}
[Fact]
public async Task RunScript_CallKind_TransportFault_StillWritesTheRootRow_AsCallFailed()
{
// A throwing Ask is NOT proof the script did not run — the site may have executed it and
// the reply been lost. The root row must still be written against the operator's name.
WireScript(CallScript("Manual"));
_router.RouteToCallAsync("site-a", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromException<RouteToCallResponse>(new InvalidOperationException("channel is broken")));
var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None);
Assert.False(result.Completed);
Assert.Equal(FlowDebugRunner.CallFailedCode, result.ErrorCode);
Assert.Contains("channel is broken", result.Error);
var root = Assert.Single(result.Events).AsRow();
Assert.Equal(AuditKind.RouteCall, root.Kind);
Assert.Equal(AuditStatus.Failed, root.Status);
Assert.Equal("ops", result.Events[0].Actor);
var written = Assert.Single(_central);
Assert.Equal(AuditKind.RouteCall, written.AsRow().Kind);
}
[Fact]
public async Task RunScript_CallKind_AskTimeout_ReportsTimeoutCode()
{
// The communication layer's own signal that the site never answered — an AskTimeoutException,
// NOT an OperationCanceledException, so it needs its own classification.
WireScript(CallScript("Manual"));
_router.RouteToCallAsync("site-a", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromException<RouteToCallResponse>(
new Akka.Actor.AskTimeoutException("no response from site-a")));
var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None);
Assert.False(result.Completed);
Assert.Equal(FlowDebugRunner.TimeoutCode, result.ErrorCode);
var root = Assert.Single(result.Events).AsRow();
Assert.Equal(AuditStatus.Failed, root.Status);
Assert.Single(_central);
}
/// <summary>A routed call that never answers until its token is cancelled — the timeout path.</summary>
private static async Task<RouteToCallResponse> NeverAnswersAsync(CancellationToken ct)
{
await Task.Delay(Timeout.Infinite, ct);
throw new InvalidOperationException("unreachable");
}
[Fact]
public async Task RunScript_CallKind_Timeout_ReportsTimeoutCode()
{
// NOTE: this test really waits ExecutionTimeoutSeconds (1 s) + RunGrace (10 s) — the runner
// arms a real CancelAfter, which the injectable delay/clock does not drive. ~11 s is the
// price of the only coverage of this path.
WireScript(CallScript("Manual", timeout: 1));
_router.RouteToCallAsync("site-a", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => NeverAnswersAsync((CancellationToken)ci[2]));
var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None);
Assert.False(result.Completed);
Assert.Equal(FlowDebugRunner.TimeoutCode, result.ErrorCode);
Assert.Contains("timed out", result.Error);
var root = Assert.Single(result.Events).AsRow();
Assert.Equal(AuditKind.RouteCall, root.Kind);
Assert.Equal(AuditStatus.Failed, root.Status);
var written = Assert.Single(_central);
Assert.Equal(AuditStatus.Failed, written.AsRow().Status);
}
[Fact]
public async Task RunScript_CallKind_RootRow_IsRedacted_WhenARedactorIsRegistered()
{
WireScript(CallScript("Manual"));
_router.RouteToCallAsync("site-a", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 1, null, DateTimeOffset.UtcNow));
_services.AddSingleton<IAuditRedactor>(new UpperRedactor());
var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None);
var root = Assert.Single(result.Events);
var written = Assert.Single(_central);
Assert.Equal(written.DetailsJson!.ToUpperInvariant(), root.DetailsJson);
Assert.NotEqual(written.DetailsJson, root.DetailsJson);
}
/// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary>
private sealed class UpperRedactor : IAuditRedactor
{
@@ -151,6 +151,7 @@ public class RequiredRoleMatrixTests
// for a past incident and an auditor must be able to follow them.
["FlowDebugRunMethod"] = [Roles.Deployer],
["FlowDebugRunValueChange"] = [Roles.Deployer],
["FlowDebugRunScript"] = [Roles.Deployer],
["FlowDebugReplay"] = FlowDebuggerReplayers,
// ---- Two-person secured write ----------------------------------------------