Commit Graph

194 Commits

Author SHA1 Message Date
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 f1db90cb8a Merge branch 'worktree-agent-a5e1884475a5b785e' into feat/flow-debugger-script-entry 2026-09-08 01:16:11 -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 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 6c9ea39f12 test(central-ui): drill-in test pins the method form's updated '7–10 audit rows per call' help text
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 14:36:45 -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 17096d6fe2 Merge branch 'worktree-agent-aaa0474d0e4c3af87' into fix/flow-debugger-arch-review 2026-09-06 14:13:18 -04:00
Joseph Doherty 97b0a4ba16 fix(central-ui,cli): Flow Debugger client waits derive from the method timeout / wait window instead of a fixed 2 minutes (arch-review F4)
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 14:13:02 -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 2025ec346f feat(central-ui): FlowDebugService relays Retry/Discard for a parked outbound step
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 11:04:03 -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 4769e192e3 fix(central-ui): Flow Debugger prediction no longer deadlocks the Blazor circuit
FlowDebugService.PredictMethodAsync resolved route targets with a sync-over-async
GetAwaiter().GetResult() inside the predictor's synchronous resolver — on the circuit's
single-threaded renderer sync context that blocked the dispatcher forever the moment a
method whose script routes to a site script was chosen (every later click and JS-interop
reply queued behind it; the page looked inert with a healthy websocket and no error).
Misdiagnosed on 2026-09-06 as a login-disabled localStorage stall. Now a resolve-and-rewalk
loop: each pass records the targets it could not answer, they are awaited between passes,
and the walk converges once a pass asks for nothing new (bounded by MaxPredictionPasses).

Regression test drives the service under a single-thread pump sync context (fails in 10 s
on the old code, passes now) plus a nested Scripts.Call resolve-across-passes test.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 10:57:09 -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 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 f9b8a2cf8f feat(central-ui): FlowDebugService — management-command bridge + predicted graph
IFlowDebugService/FlowDebugService (scoped, registered beside IDataConnectionMoveService).
Run/replay go to the per-node ManagementActor through ManagementActorHolder with the Blazor
principal projected to AuthenticatedUser, exactly as DataConnectionMoveService does; the
FlowDebugRunResult payload is deserialised (camelCase) and fed to FlowGraphBuilder, while
ManagementError/Unauthorized/holder-null/transport faults collapse into FlowDebugOutcome
with the server's error code verbatim (STANDBY_NODE reaches the page untouched). Prediction
is DB-only: PredictMethodAsync hands FlowShapePredictor a memoised resolver that maps a
route target → Instance.UniqueName → template parent chain (nearest definition wins, cycle
guarded) → TemplateScript.Code; PredictValueChangeAsync roots at the write and appends each
script whose value-change trigger names the attribute (same attributeName/attribute keys
the site's TriggerRouting parser reads).

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
2026-09-06 00:20:22 -04:00
Joseph Doherty 1790f67ebd test(centralui): pin Topology Disable/Delete confirm verbs (register row 28)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 07:45:46 -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 9261fa09fa feat(centralui): TemplatePathFormatter — folder-path label for template pickers
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:53:40 -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 8d17af37ac feat(centralui): CreateBindingPlanner — prefill/tail-strip/binding-list logic for create-time bindings
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:05:41 -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 2fb1c8ee14 feat(centralui): BindingGroupBuilder.ApplyObject — per-module bulk rewrite to <object>.<member>, blank clears 2026-08-20 15:54:47 -04:00
Joseph Doherty 5da7003e88 feat(centralui): BindingGroupBuilder — group flattened binding rows by module, infer the shared MxAccess object prefix, count unbound 2026-08-20 15:53:59 -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 b34ec10f8b fix(deploymentmanager): bulk site deploy skips up-to-date instances and un-poisons the shared DbContext (arch-review Low P7-5 + P7-6)
P7-6 (behaviour fix, plan decision 1): DeploySiteAsync redeployed EVERY
deployable instance unconditionally. A redeploy is not idempotent at the site —
it resets static attribute overrides and clears native alarm state by design — so
one site-wide push destroyed live operational state on every unchanged instance
in the batch, which is normally most of them. Bulk deploy now pre-filters with
IStaleInstanceProbe (the same seam the bundle importer and Deployments page use;
its memo timing was fixed by the Medium wave's P7-4): an instance whose freshly
computed revision hash still matches its DeployedConfigSnapshot is reported in the
existing additive SkippedInstances list with reason "up-to-date" and never
flattened, staged or sent. The filter is deliberately conservative — every
uncertainty deploys: only an Enabled instance qualifies, a missing snapshot
disqualifies, a prior record that ShouldQuerySiteBeforeRedeploy flags goes through
prepare so the reconcile that HEALS it still runs, and a null/faulting probe means
"indeterminate". New additive MgmtDeploySiteCommand.Force (default false = the new
filtered behaviour) plus CLI `deploy site --force` restores the unconditional push
for the case central cannot see (restored volume, rebuilt node). Composes with the
Medium wave's P7-2: Disabled wins — force suppresses the up-to-date filter only,
never the Disabled skip.

P7-5 (verified, then fixed): the cascade REPRODUCES. All three bulk phases share
one scoped DbContext, and a failed SaveChanges does not untrack the offending
entity — EF re-attempts it on the next save, including MarkDeploymentFailedAsync's
cleanup write. Measured on a real SQLite-backed ScadaBridgeDbContext: a permanent
save failure on instance 1's post-success side effects failed instances 2, 3 and 4
and left all three deployment records stranded InProgress. MarkDeploymentFailedAsync
now detaches the pending changes (new IDeploymentManagerRepository.DetachPendingChanges)
when the cleanup save fails and retries the Failed write once on the clean tracker,
so later instances save cleanly and the record reaches a terminal status. The
DeployFailed audit row moved outside the status-write try (best-effort, must not be
lost because the status write failed).

Tests: 5 new DeploySiteAsyncTests (skip + reason + no site round-trip; --force
deploys; Disabled skipped under both force values; the three not-enough-to-skip
guards), 2 new IntegrationTests over a real EF context (the poisoned-context
cascade, and the narrow EF tracking fact the fix rests on), CLI --force flag
parse test, and a MgmtDeploySite wire-default test. Proof of failure: both P7-6
behaviour tests and both P7-5 assertions fail against the unfixed code.
2026-08-19 16:41:04 -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 9a3f006721 fix(management)+fix(cli): neutralize CSV formula injection + align export columns; deadline + Ctrl-C on audit export streaming (arch-review 2026-08-19 P6-7 + P6-8)
P6-7 (CWE-1236): AuditEndpoints CSV export quoted cells on comma/quote/CR/LF
but never neutralized a leading =, +, -, @, TAB, or CR — inbound-API-caller
text landing in Target/ErrorMessage/RequestSummary/ResponseSummary/Extra
could execute as a formula/DDE trigger when opened in Excel. AppendField now
prefixes a literal single quote on free-text columns only (never the
GUID/enum/numeric/date/bool columns the exporter formats itself), applied
before RFC 4180 quoting. The CSV was also missing ExecutionId/
ParentExecutionId/SourceNode that the JSONL export already carried; both are
now 24 columns in the same AuditExportRow order. A second, independently
maintained CSV writer (CentralUI's AuditLogExportService, feeding the Audit
Log page's bulk-export button) had the identical gap and got the identical
fix + column alignment.

P6-8: `audit export` was the one CLI HTTP path with no deadline at all — a
residual gap from the ManagementHttpClient timeout-honesty fix, which made
HttpClient.Timeout infinite and the per-call token the single deadline
everywhere except this streamed GET. RunExportAsync now takes a headers-
received deadline (CancelAfter, reusing ManagementHttpClient.
DefaultConnectTimeout) around the initial GetAsync, an inactivity deadline
around the streaming copy (rearmed on every successful read, so large exports
are bounded by silence, not total size), and an external CancellationToken
wired to Console.CancelKeyPress in AuditCommands so Ctrl-C cancels promptly.
A stalled/faulted stream now produces a friendly error noting the partial
output file instead of hanging or dumping a stack trace.
2026-08-19 15:01:45 -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