Commit Graph

217 Commits

Author SHA1 Message Date
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 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 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 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 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 9404ff87ca Merge branch 'worktree-agent-a6dd4922425ca12c2' into feat/flow-debugger-script-entry 2026-09-08 01:21:55 -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 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 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 e15aa0e430 docs: Flow Debugger design reconciled with the implementation (arch-review F12, F13)
The Flow Debugger design doc still described the pre-remediation shape: a
debug-stream subscription for the value-change entry, Deployer everywhere, an
unredacted inline trace copy and "~7 audit rows per call". Reconciled with what
shipped on this branch (Tasks 1-12):

- design D6 / §5.4 / §9 / §10: RequireFlowDebugger page policy with Deployer-only
  execution (F3), the PullTraceEvents polling path with AttributeValueUpdate
  fields 7/8 recorded as RESERVED and unconsumed (F12), the redacting + byte-
  capped shared FlowTraceCollector (F1), route-time site scope (F2), the derived
  client waits (F4), the 7-10 row estimate, the doubled central audit write per
  routed call, and the inbound-row-after-the-run ordering.
- sitestream.proto: fields 7/8 marked reserved-but-populated, numbers not to be
  reused (comment only; no regeneration needed).
- Component-InboundAPI: SkipBodyCapture on the RouteCall row (F11),
  SchemaRefResolver's public visibility, WithSiteScope / SITE_SCOPE_DENIED (F2),
  Replay's wider role set (F3).
- Component-CentralUI: page policy vs Deployer-only controls (F3), route-time
  site scope (F2), client wait derivation (F4).
- "7-10 audit rows per call (3 of them trace-only)" everywhere the estimate is
  quoted: the method form, both CLI --trace-all-calls help strings, the CLI
  README, the ApiMethod entity doc, the manual migration script and the two
  Flow Debugger plans.
- arch-review doc: new §7 Resolution listing F1-F13 with the fixing commit, plus
  the tracking-store test race fixed alongside them (86451382).

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 14:35:10 -04:00
Joseph Doherty a7ed9d98ce Merge branch 'worktree-agent-a349413889618d94c' into fix/flow-debugger-arch-review 2026-09-06 14:13:56 -04:00
Joseph Doherty f86eb61d1a fix(central-ui): Flow Debugger Replay opens to OperationalAudit roles; Live run and Retry/Discard stay Deployer-only (arch-review F3)
Design D6 / §9 says a Live run is a Deployer act while Replay needs only
OperationalAudit, but the page and all three FlowDebug* commands were gated on
Deployer. The Audit Log and Execution Chain pages -- themselves OperationalAudit
-- show an "Open in Flow Debugger" drill-in that an auditor could not follow.

Implements the split:

- New `RequireFlowDebugger` policy over `FlowDebuggerAccessRoles`
  {Deployer, Administrator, Viewer}; the page now carries it instead of
  `RequireDeployment`.
- `FlowDebugReplayCommand` takes the same role set in the ManagementActor
  matrix; `FlowDebugRunMethodCommand` / `FlowDebugRunValueChangeCommand` stay
  DeployerOnly.
- The page resolves `_canLive` (Deployer) in OnInitializedAsync and, without it,
  pins the mode to Replay, hides the entry-point and mode toggles, the Run
  button and the Live pickers, honours only the `?executionId=` drill-in, and
  leaves the parked-node Retry/Discard callbacks unset so those buttons never
  render. `CanRun`, `SetMode(Live)` and `ActOnStepAsync` repeat the check so the
  split does not rest on markup alone.

The NavMenu entry stays inside the Deployer-gated Deployment section -- auditors
arrive through the audit drill-ins, which is the point of the finding.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 14:13:32 -04:00
Joseph Doherty 688136de85 fix(central-ui): Flow Debugger prediction overlay matches by kind and title (arch-review F10)
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 14:10:44 -04:00
Joseph Doherty 7caf56f332 fix(central-ui): Flow Debugger outcome refresh merges rows by EventId instead of replacing the graph
Found in a browser Live run on the rig: the run drew API, DB and Notify nodes from
its inline rows; the 5 s pending-refresh re-read the tree through Replay before the
site's cached-call telemetry (12 s) had reached central, swapped the graph wholesale,
and the resolved DB node vanished (7 -> 6 steps) until a manual refresh. Polling had
already stopped because nothing was Pending.

MergeRefreshedGraph now unions the re-read rows into the run's known rows by EventId
(fresh copy wins), stores the union back on _raw and rebuilds through
FlowGraphBuilder, so a refresh only ever adds nodes. The union is exact: the inline
rows are the same AuditEvent objects the site writer persists (FlowTraceCollector).

Tests: new PendingRefresh_KeepsStepsTheLiveRunAlreadyHad_WhenReplayLagsBehindTelemetry
(fails on the old code). Two existing fixtures were internally inconsistent and only
passed under the old replace semantics: ParkedThenResolved built its Resolved outcome
with the Parked raw rows, and OutboundOutcome minted fresh event/correlation ids per
call, so no row could ever match across live and replay. Both now share rows per run.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 11:55:52 -04:00
Joseph Doherty 91d14ebdee fix(central-ui): notification deep link and relay use the stored N-form NotificationId
Notify.Send mints NotificationId as Guid "N" (no dashes) and the outbox parses it back into
the audit row's CorrelationId, so the Flow Debugger's Notify node linked and relayed a "D"
string the Notifications table never matches (found live: the report opened empty). The
link and RetryNotificationRequest now use "N", and the Notification report normalises any
Guid-shaped ?correlationId= to "N" so Audit Log and Flow Debugger callers both land.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 11:16:24 -04:00
Joseph Doherty 7f557b0ee5 feat(central-ui): Retry/Discard a parked outbound node from the Flow Debugger with scope check, confirm and re-poll
The page wires FlowStepDetail's OnRetry/OnDiscard to IFlowDebugService.RetryOutboundAsync /
DiscardOutboundAsync behind the same site-scope re-check and confirm wording the Site Calls /
Notification report pages use, toasts the relay outcome, and on success re-polls Replay (same
5 s / 2 min loop) until the acted node leaves Parked. Also drops a duplicated ?correlationId=
test block that a concurrent index-only commit left in QueryStringDrillInTests.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 11:08:05 -04:00
Joseph Doherty 33f9c97030 feat(central-ui): Site Calls page accepts ?correlationId= and opens the operation's detail
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 11:04:39 -04:00
Joseph Doherty 8abc0eca13 feat(central-ui): Notification report accepts ?correlationId= and opens the notification's detail
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 11:04:29 -04:00
Joseph Doherty 9c31f70105 feat(central-ui): Flow Debugger step detail links to the report page and exposes Retry/Discard on a parked node
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 11:01:08 -04:00
Joseph Doherty c3b4068de2 feat(central-ui): Flow Debugger refreshes pending outbound outcomes after a Live run
After a successful Live run whose graph has Pending outbound steps the page
re-reads the tree through the existing Replay command every 5 s for up to
2 min (page constants), merging by replacing the graph and keeping the
selection by event id; never in Replay mode; cancelled by a new run, a mode
switch or Dispose. Status strip gains a Pending count, a 'Refresh outcomes'
button (both modes) and an 'outcomes still arriving' note while polling. The
run's own timing and the Completed chip are left alone — pending outbound
work is not a failed run. Plan 2026-09-06 D5.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 02:03:16 -04:00
Joseph Doherty 8f6f7ed92b feat(central-ui): FlowStepDetail Lifecycle tab + outbound request/response labels
A folded outbound step shows a Lifecycle tab (time · kind · status · node ·
error, oldest first; a Parked step adds the 'retry or discard from Site Calls /
Notification report' hint), replaces the never-populated Writes tab for those
steps, labels the Input tab Request / SQL + parameters / Subject + body by kind,
and 'Where this ran' names the System / Connection / List plus 'Delivered by'
(the terminal row's node). Pending/Parked outcome badges.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 01:59:55 -04:00
Joseph Doherty 56154fd1d2 feat(central-ui): FlowGraphView External lane, outbound status pills, async/spawn connectors
Four lanes (LaneCount 4; External sublabel = distinct targets, three then +n);
ApiCall/DbCall/Notify nodes carry a status pill (Ok/Pending/Parked/Failed —
pending pulses, reduced-motion aware) plus a sync/cached/notify mode; folded
lifecycles show '+4.1 s' submit→terminal wall time. Async edges are dashed
and routed through the inter-lane strip with a gutter tag; spawn edges use a
bolt marker. Predicted async edges (Task 6's PredictedEdge.Async) render the
same way. de-DE height pinned at 319.2 = (4×110+16)×0.7.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 01:57:47 -04:00
Joseph Doherty 29129ef17a feat(commons): FlowGraphBuilder folds outbound lifecycles into one node; async/spawn edges; Response from the inbound execution
Fold rule: outbound rows sharing (kind, CorrelationId) become one FlowStep with
a Lifecycle list — first row's identity/time/input, latest row's outcome/
subtitle/output/error, DurationMs = sync row's own or submit→terminal wall time
(null while pending). Chain skips async steps (Notify, or first row
CachedSubmit) and links them with Async edges from the preceding chain step.
Spawner edges from a non-RouteCall are Spawn: true. The Response links from the
last non-async step of the inbound execution (the RouteCall), not the globally
last cascaded step (D9). FlowGraphView places a Response at max column + 1 so
the D9 origin does not pull it left (pulled forward from Task 7 to keep the
layout test green).

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 01:42:48 -04:00
Joseph Doherty cc1bbf3975 feat(commons): Flow Debugger outbound kinds, External lane, Pending/Parked outcomes, FlowLifecycleRow
FlowLane.External (3); FlowStepKind.External replaced by ApiCall/DbCall/Notify;
FlowStepOutcome gains Pending/Parked; new FlowLifecycleRow + additive
FlowStep.Lifecycle; FlowEdge gains Async/Spawn (default false). Predictor and
graph-view references renamed compile-only (full work in Tasks 6/7).

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 01:37:29 -04:00
Joseph Doherty 038eda5d91 fix(central-ui): Flow Debugger node text readability — HTML node bodies, shared-prefix gutter, wrap hints
The swimlane's node text was clipped by character count (20/24), so dotted attribute
paths showed only their shared head ("DelmiaReceiver.Reci…" five times in a row) and
the subtitle spilled past the box. It was also unstyled: the SVG <text> elements are
emitted as raw markup (Razor reserves the <text> tag), so they never carried the CSS
isolation scope attribute and every .fg-* rule silently missed.

- Node body is now HTML inside an SVG foreignObject: the browser measures the text,
  the title wraps to two lines and ellipsizes by width, the subtitle by width on one
  line, nothing can overflow the box, and the div's title attribute carries the full
  unstripped text as a tooltip. Node height 78 -> 84.
- A lane whose real titles share a dotted head (CommonDottedPrefix) shows it once in
  the lane gutter ("DelmiaReceiver.*") and each node shows only the tail that tells
  it apart; the write subtitle strips the same head per item. Predicted-only nodes
  are never stripped.
- WrapHints inserts zero-width spaces at camel-case and ./_/ boundaries so a wrapped
  title breaks at "DelmiaRecipe|Download" rather than mid-word.
- Gutter widened 130 -> 160 with the sublabel clip raised to 22 so "site-a · DelmiaSim01"
  fits; the remaining SvgText gutter labels are styled through ::deep.

Verified live on the rig (replay of the Task 22 run): all three lanes readable, no
overflow, gutter shows the factored prefix. CentralUI suite 1199 pass.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
2026-09-06 01:15:56 -04:00
Joseph Doherty 66d5a67e4f feat(central-ui): Flow Debugger drill-ins from Audit Log + Execution Chain; TraceAllCalls checkbox on the method form
'Open in Flow Debugger' (data-test open-flow-debugger) beside 'View execution chain' in
AuditEventDetail (rendered only with an ExecutionId) and beside 'View this execution in
the Audit Log' on the Execution Chain page — both target
/deployment/flow-debugger?executionId=… which replays on first render. ApiMethodForm gains a
'Trace all calls (Flow Debugger)' form-check under Timeout, loaded from and saved to
ApiMethod.TraceAllCalls on both the update and create paths, with the ~7-rows-per-call
guidance as help text.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
2026-09-06 00:33:37 -04:00
Joseph Doherty 254118a467 feat(central-ui): Flow Debugger page (predict, live run, replay, step detail) + nav entry
/deployment/flow-debugger (RequireDeployment): entry toggle (inbound method | value-change
trigger), method picker or Site → Instance → Attribute pickers (site-scope filtered), Live |
Replay mode, inputs form generated from the method's ParameterDefinitions via
InboundApiSchema (values coerced by declared type into the request JsonElement), status
strip (state chip, run #, start, total, steps/boundaries/writes, root ExecutionId link to
the Execution Chain page), the FlowGraphView with −/Fit/+ zoom, and FlowStepDetail (tabs
Input · Output · Writes · Timing, 'Where this ran' column, Open in Debug View when the
instance is known, Audit rows (n) → /audit/log?executionId=). State machine Idle →
Predicted → Running → Completed | Failed | TimedOut; STANDBY_NODE renders as an inline
warning alert with the server message verbatim; ?methodId= / ?instanceId=&attribute= /
?executionId= drill-ins (the last replays on first render); last entry + inputs persisted
per method in localStorage; 'site not traced' label when a run's route call came back with
no child execution and no Site-lane steps. NavMenu: Deployment → Flow Debugger.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
2026-09-06 00:31:55 -04:00
Joseph Doherty ba15a38327 feat(central-ui): FlowGraphView SVG swimlane renderer
Dependency-free inline SVG: three fixed 110px lanes (Central / Site / Tags, sublabelled
from the steps' site/instance), columns from a topological pass over the graph edges with
same-lane collision bumping so independent siblings never overlap, 150x78 nodes on a
130+col*170 grid, orthogonal connectors with arrow markers (accent for boundary crossings,
dashed for external actors, light for predicted edges). Renders a real FlowGraph, a
PredictedFlow (dashed nodes, input-dependent finer-dashed), or both — predicted nodes
matched to real steps by kind in order, the leftovers appended greyed as ghosts. Selected
node = accent 2px on --active-bg; failed/timed-out/not-traced outcomes styled; each node a
role=button tabindex=0 <g> with click + Enter/Space selection; svg role=img with a summary
aria-label. Layout is a pure static ComputeLayout, every coordinate InvariantCulture.
Razor reserves the <text> tag, so SVG text elements are emitted through a small
HTML-encoding MarkupString helper.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
2026-09-06 00:25:54 -04:00
Joseph Doherty 7a77d38abf test(playwright): key the Configure bulk-Apply click by data-test (strict-mode collision with the grouped 'Apply to n' button)
The grouped Connection Bindings panel (77f4af0d) added a per-group
'Apply to n' button, so InstanceConfigureTests' Name = "Apply" role
locator started matching two elements and failing Playwright's strict
mode. Adds data-test="binding-bulk-apply" alongside the existing
binding-bulk-select / binding-group-apply keys and clicks that instead.

Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 08:22:03 -04:00
Joseph Doherty 7c2582d105 feat(centralui): Templates tree context menu — "New Instance…" (Deployer-gated)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 07:44:15 -04:00
Joseph Doherty 5b4a27b23e feat(centralui): Create Instance picks its template from the folder tree (+ ?templateId preselect)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 07:42:14 -04:00
Joseph Doherty 4bd2651925 feat(centralui): TemplatePickerDialog — folder-tree template picker hosted by IDialogService
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 07:08:37 -04:00
Joseph Doherty 8df6bcb6e0 feat(centralui): TemplateFolderTree.RevealKeys — expand to a preselected node
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:55:03 -04:00
Joseph Doherty 80b9ab2d57 feat(centralui): Create saves bindings (upsert) and lands on Configure with a created/bound/bindError notice
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:12:53 -04:00
Joseph Doherty 74c7bd2fa8 feat(centralui): Create Instance — Connection Bindings card (preview flatten, per-group connection/Object/Browse, Bind-all prefill)
Tasks 4 and 5 of the plan land together: the card's state fields are write-only
until the markup consumes them, and the project treats unused-field warnings as
errors, so neither task builds on its own.

Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:11:53 -04:00
Joseph Doherty ab6e3432bd feat(centralui): NodeBrowserDialog.SelectableNodeClasses — opt-in Object node selection (default unchanged)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:07:12 -04:00
Joseph Doherty 546250096d fix(centralui): grouped bindings review fixes — Apply with the header at none/mixed keeps each member's connection (never unbinds; upsert Save could not persist it), object inference runs over effective references (bare-default members make an overridden group mixed, default-only prefixes infer), bindings to a deleted connection count as unbound, 'n changed · unsaved' diffs against last-saved state; bUnit behaviour tests for fallback banner, grouping and mixed-group Apply+Save 2026-08-20 16:09:34 -04:00
Joseph Doherty 77f4af0d64 feat(centralui): grouped Connection Bindings panel — rows sourced from the flattened config (composed module members were invisible; flatten failure falls back to direct attributes with a banner), per-module connection + Object apply, unbound/mixed pills, show-only-unbound filter, Test Bindings covers composed rows, per-row Browse retained 2026-08-20 15:59:51 -04:00
Joseph Doherty 57c8973b0d fix(centralui)+test(centralui): Low-wave integration — T12 Topology race fixture gains T13's IStaleInstanceProbe dependency; T11 sandbox-run continuation skips StateHasChanged after Dispose
Cross-task regression caught by the combined sweep: TopologyLiveUpdatesTimerDisposeRaceTests
(T12) builds a real DeploymentService, whose constructor grew an IStaleInstanceProbe
parameter in T13 (P7-6 up-to-date pre-filter) — 4 DI-resolution failures on the
integration branch that neither task saw in isolation. Registered a substitute, matching
TopologyPageTests.

Bundle review Minor: ApiMethodForm/SharedScriptForm copied TemplateScriptDialog's sandbox
pattern exactly, whose finally re-renders unconditionally; the plan asked the continuation
to be guarded on disposal, so a _disposed flag now suppresses the post-cancel
StateHasChanged (the reference dialog keeps its original shape — out of T11's scope).
2026-08-20 08:42:56 -04:00
Joseph Doherty 6d0d8b6258 fix(centralui): close register row 103 — take-once session/timer discipline for TransportImport + Topology (P6-6 lesser instances)
Mirrors the Medium wave's DebugView P6-6 fix (_sessionLock + _disposed,
publish-then-recheck-disposal under one lock) for the two TTL-bounded/
unbounded lesser instances the Medium wave deferred:

- TransportImport.razor.cs: TryLoadAsync awaits IBundleImporter.LoadAsync and
  used to publish the result into _session unconditionally. A circuit torn
  down mid-await left Dispose() with nothing to release (session still null),
  so the freshly-opened server-side BundleSession stayed registered, stranding
  one of MaxConcurrentImportSessions (register row 43) for the full session
  TTL. Now publishes and re-checks disposal atomically under _sessionLock, so
  exactly one side (TryLoadAsync or Dispose) abandons the session.

- Topology.razor: OnInitializedAsync awaits LoadDataAsync before calling
  StartLiveUpdatesTimer. A circuit torn down mid-await left Dispose() with no
  timer to stop yet, so the timer StartLiveUpdatesTimer creates once the await
  resumes was never disposed — it ticked against a torn-down component every
  15s until process restart. StartLiveUpdatesTimer/StopLiveUpdatesTimer/
  Dispose now create/replace/take the timer atomically under _timerLock
  together with a _disposed check, so a late-arriving (re)start refuses to
  create a timer once disposed.

Tests (bUnit, both proven to fail against the unfixed code):
- TransportImportSessionDisposeRaceTests: dispose-during-load abandons the
  just-opened session immediately; dispose-after-successful-load abandons
  exactly once.
- TopologyLiveUpdatesTimerDisposeRaceTests: a late StartLiveUpdatesTimer call
  after Dispose leaves no live timer; Dispose stops the timer exactly once
  (idempotent double-dispose); OnLiveUpdatesToggled(off) + Dispose leaves no
  live timer. All assert on deterministic field/call-order state, no sleeps.

Register row 103 stays open for integration to close per the plan's shared
constraints (register edits happen at integration only).
2026-08-19 16:41:04 -04:00
Joseph Doherty f1ef95f4da fix(centralui): P6-12 — dispose sandbox-run CTS in ApiMethodForm + SharedScriptForm
Neither Test Run form implemented IDisposable, so navigating away mid-run left
_runCts uncancelled: the sandbox kept the run alive until its own internal
script timeout, and the eventual continuation touched a torn-down component's
_running/_runResult fields. TemplateScriptDialog already got this right — both
forms now mirror it exactly: @implements IDisposable plus a Dispose() that
cancels + disposes _runCts.

ScriptAnalysisService.RunInSandboxAsync is marked virtual (test-only seam,
mirroring the existing virtual on DebugStreamService.StartStreamAsync/
StopStream) so a TaskCompletionSource-gated stub can hold a run open
deterministically. New bUnit tests (ScriptFormSandboxDisposalTests) prove, for
both forms: disposing mid-run cancels the token the sandbox observed, and the
cancelled continuation resolves with no unhandled exception and no _runResult/
_running mutation. Proof of failure: reverting only the two .razor changes
(keeping the virtual seam) reproduces all 4 failures — the token is never
cancelled and the no-op assertions time out.
2026-08-19 16:41:04 -04:00
Joseph Doherty 21d50d4406 fix(centralui): P6-11 — request-generation guard on Deployments page
Three fetch initiators (search debounce, paging, push/coalesce reload) could
each have a LoadDataAsync flight in flight at once; completion order was not
call order, so a slow, stale flight could resolve after a newer one and
clobber the grid the operator was already looking at. Add a monotonic
_requestGeneration counter, bumped at fetch start and checked at apply —
last-write-wins by generation, not by whichever await resolves first.

Separately, the filter-debounce timer wrote `_currentPage = 1` directly on
its raw System.Threading.Timer callback thread — a Blazor threading
violation, since component state may only be mutated on the renderer's
sync context — and that write was not covered by the generation guard at
all. LoadDataAsync now takes a resetToFirstPage flag, resolves the target
page into a local, and applies it only behind the same generation check as
every other piece of state, via a new DispatchFilterReloadAsync that
marshals through InvokeAsync like the existing push-reload path.

Tests (DeploymentsRequestGenerationTests, bUnit): a delayed stale flight
arriving after a newer one does not overwrite the grid; a stale
filter-debounce flight does not reset the page after a newer navigation.
Both driven deterministically via TaskCompletionSource-backed repository
stubs. Verified to fail against the unfixed code (swapped via
`git show HEAD:<path>`, not `git stash` — repo-wide and unsafe across the
parallel worktree wave).
2026-08-19 16:41:04 -04:00
Joseph Doherty 561582747e fix(centralui): stop debug-stream sessions abandoned mid-start — dispose-vs-connect race (arch-review 2026-08-19 P6-6)
A debug-stream session created after the consumer had already gone away was
leaked permanently. Both consumers awaited StartStreamAsync (up to 30s) and only
then published the session; a circuit teardown / client disconnect inside that
window ran the release path first, found nothing registered and stopped nothing.
The session stayed registered in the singleton DebugStreamService, whose
keepalive renews registered sessions forever (the 5-minute orphan reaper only
collects UNregistered ones), pinning the site gRPC subscription, the
DebugStreamBridgeActor and the dead consumer's object graph until process
restart.

Both call sites now publish the session and re-check liveness under a single
monitor, so exactly one side tears it down and neither can miss it:

- DebugView.Connect/Dispose/Disconnect share a _sessionLock; Dispose sets
  _disposed and takes the session in one step, Connect publishes only if not
  disposed and otherwise stops the session it just created. Dispose can run off
  the renderer's thread, so the Blazor sync context is not relied on. Disconnect
  and the onTerminated callback go through the same take-once helper, which also
  makes a repeated Dispose stop-once instead of stop-again.
- DebugStreamHub uses the per-connection Items monitor: OnDisconnectedAsync marks
  the connection gone before releasing, SubscribeInstance publishes only if
  neither that flag nor ConnectionAborted is set, and UnsubscribeInstance takes
  the id atomically.

DebugStreamService.StartStreamAsync/StopStream become virtual purely as a test
seam (no behaviour change, keepalive/orphan contract untouched) so the race can
be driven deterministically by a gated stub.

Tests: DebugViewConnectDisposeRaceTests (3) + DebugStreamHubSubscribeRaceTests
(3). Verified fail-unfixed: with the two source fixes stashed, both
dispose-mid-start tests report zero StopStream calls and the DebugView
double-dispose test reports two.
2026-08-19 14:53:01 -04:00
Joseph Doherty 64e6ba70e8 fix(management): site-scope ListDataConnections + hoist scope checks above resolve/status oracles (arch-review 2026-08-19 P6-4 + P6-5)
P6-4: HandleListDataConnections took no user at all and returned EVERY
site's connections to any authenticated caller, nullifying the row-98
fail-closed gate on HandleGetDataConnection — Get denied exactly what List
handed over. DataConnectionPublicShape already scrubs secrets, so the leak
was site topology plus a connection-id existence oracle, but it still
defeated scoping. List now takes the AuthenticatedUser: an explicit SiteId
goes through EnforceSiteScope (the ListAreas shape), and an unfiltered list
from a site-scoped non-Administrator caller is narrowed to their permitted
sites (the ListSites shape). GetRequiredRoles is untouched — scoping is not
a role gate, so the frozen 138-command authz matrix is unaffected.

The Central UI Design > Connections page bypasses the actor entirely
(ISiteRepository.GetAllDataConnectionsAsync direct) and IS reachable by a
scoped user: it is gated RequireDesign, and SiteScopeService explicitly does
NOT exempt Designer (register row 86). It now filters sites through
SiteScope.FilterSitesAsync and keeps only their connections, mirroring
EventLogs/AlarmSummary.

P6-5: four handlers resolved their target — and on approve/reject reported
its status — BEFORE the scope check, so an out-of-scope caller could tell a
real id from a fake one, and read a secured write's lifecycle status, from
the error shape alone. The scope check is hoisted above the not-found and
status checks in HandleSubmitSecuredWrite, HandleApproveSecuredWrite,
HandleRejectSecuredWrite and HandleDebugSnapshot, with the unresolvable case
routed through the existing DenyUnresolvedTargetIfScoped so the denial is
identical whether or not the target exists. System-wide callers keep their
ordinary not-found/status answers, and the P6-1 CAS semantics on
approve/reject are unchanged (only the checks above the CAS moved).

Tests: 8 new ManagementService tests + 2 CentralUI bUnit tests, all verified
to FAIL against the unfixed handlers (src reverted, tests re-run) and pass
after — including the unscoped/Administrator control cases proving behaviour
for system-wide callers is unchanged. Full ManagementService.Tests 545 green,
CentralUI.Tests 1073 green.
2026-08-19 14:49:24 -04:00
Joseph Doherty 042e2efd1d fix(centralui): enforce site scoping on AlarmSummary — filter site list + deny out-of-scope alarm fetches, mirroring DebugView (arch-review 2026-08-19 P6-2 High)
AlarmSummary.razor was gated RequireDeployment but never consulted
SiteScopeService: the site picker listed every site unfiltered and
GetSiteAlarmsAsync ran for whatever siteId the onchange event carried,
so a Deployer scoped to one site could watch any site's live alarms
(including CurrentValue/LimitValue process data). The page was missed
by the ad0100f2 scoping survey.

Fix mirrors EventLogs.razor/DebugView.razor: OnInitializedAsync filters
_sites through SiteScope.FilterSitesAsync, and both the site-change
handler and RefreshAsync (the shared fetch path used by the manual
refresh button and the 15s poll timer) independently re-check the
selected site against the filtered list before ever calling
AlarmSummaryService, denying/resetting an out-of-scope id rather than
merely hiding it from the dropdown.

Tests: bUnit render tests added to AlarmSummaryRenderTests.cs proving
(1) a scoped Deployer's picker omits the out-of-scope site and (2) a
forged out-of-scope onchange value is denied — no row renders and
GetSiteAlarmsAsync is never called for that site. AlarmSummaryVirtualizeTests.cs
updated to register the now-required SiteScopeService.
2026-08-19 13:54:45 -04:00
Joseph Doherty 4d39ad3cd7 Merge rows 80+73+71: nullable LocalDb health fields on unreplicated sites (Host supplies ReplicationIsConfigured), StartupReconcilerActive on the health report (proto field 29) with central No-self-heal badge, standby active-node ERR downgraded via narrowly-keyed Serilog filter
# Conflicts:
#	CLAUDE.md
2026-08-16 04:17:40 -04:00