Commit Graph

1694 Commits

Author SHA1 Message Date
Oliver Kieselbach 97397a9836 fix(agent-v2): resolve desktop owner via WTS session query, WMI as fallback
Session 4d5a0b78: WMI Win32_Process.GetOwner failed on every single DAD poll
(wmiErrorCount == pollCount from the first poll on), so the explorer.exe
owner was never resolved, desktop_arrived never fired, and the Desktop half
of the completion AND-gate starved — the session sat in AwaitingDesktop
forever even though the gate-starvation synthesis (d3abbacd) had correctly
opened the AccountSetup gate.

Owner resolution is now WTSQuerySessionInformation(WTSUserName/WTSDomainName)
keyed by the process's session id as the primary path — a single kernel
round-trip with no WinMgmt dependency, and semantically the right question
anyway (explorer.exe runs as the session's logged-on user). The previous WMI
GetOwner path remains as fallback when WTS yields no user.

- New WtsNativeMethods interop (wtsapi32) with buffer-safe QuerySessionString
- DesktopArrivalDetector.ResolveOwner: WTS first, WMI fallback, injectable
  test seams (SessionOwnerResolver/ProcessOwnerResolver)
- first_poll/no_candidate liveness payloads now carry wtsErrorCount alongside
  wmiErrorCount so the broken path is visible per device

V2.Core 1327/1327 green; WTS path live-verified (AzureAD\user shape matches
the existing IsExcludedUser contract).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:28:06 +02:00
Oliver Kieselbach 4377d35fef feat(web): show RealmJoin packages in Install Progress with RJ: prefix
realmjoin_package_started/completed events now render as first-class
install rows in the session-detail Install Progress, alongside Intune
(app_install_*) and Office (office_install_*) entries. The "RJ: " name
prefix keeps mixed Intune/RealmJoin installs distinguishable and doubles
as the map key, avoiding collisions with IME app names.

RealmJoin has no separate failed event — completed with success="false"
routes to the Failed branch, and lastExitCode maps onto the existing
exit-code badge (error-code catalog lookup included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:17:22 +02:00
Oliver Kieselbach 9e51e439dc feat(web): show RealmJoin agent version in session System panel
Renders productVersion (+ releaseChannel when not "release") from the
existing realmjoin_detected event, right below the IME Agent Version
row. Pure client-side read of the event stream - no agent or backend
change needed, works retroactively for existing sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:15:51 +02:00
Oliver Kieselbach d3abbacd90 fix(agent-v2): complete Classic sessions when a skipped user-ESP app starves the AccountSetup gate
Session caa6cf50: a policy-skipped user-ESP app leaves the ESP registry's
Apps subcategory permanently inProgress, so Windows never writes
AccountSetupCategory.Status.categorySucceeded before tearing down the ESP
page. Both registry-driven AccountSetupProvisioningComplete paths (normal +
all-subcategories fallback) starve, ShouldTransitionToAwaitingHello never
opens, and the session stalls after 60 min despite a successful enrollment
(esp_exiting + desktop_arrived + helloPolicy=false all recorded).

Fix, in three parts:
- ImeLogTracker.AreUserEspAppsSettled(): conservative probe — true only in
  AccountSetup phase with >=1 required app, all required apps terminal
  (Installed/Skipped/Postponed) and zero errors. Exposed via ImeLogHost.
- EspAndHelloTracker: on every Shell-Core exit forward, fire-once synthesis
  of AccountSetupProvisioningComplete when the probe reports settled, plus
  an esp_provisioning_status observability event
  (fallbackReason=esp_exited_user_apps_settled_category_unresolved).
  Wired through a new optional userEspAppsSettledProbe ctor param;
  DefaultComponentFactory connects it to ImeLogHost via closure.
- HandleAccountSetupProvisioningCompleteV1: deferred-completion parity.
  When the strong gate arrives last (Win11 ordering: explorer.exe runs
  under the User-ESP page, so Desktop arrives before the final exit) and
  Hello is resolved or policy-disabled, complete through Finalizing with
  HelloOutcome="Skipped" instead of parking in AwaitingHello until
  HelloSafety stamps a misleading "Timeout" 300 s later.

Sessions with failed or in-flight user apps keep the existing conservative
stall behaviour. DecisionCore 366/366, V2.Core 1321/1321 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:26:36 +02:00
Oliver Kieselbach 0e16704c66 feat(agent-v2): report RealmJoin release channel alongside version
RealmJoin.exe inverts the usual .NET SDK version-resource layout: the
FileVersion STRING carries the full SemVer including the channel tag
("4.21.6-canary+476277.d320cac0") while ProductVersion is the bare
"4.21.6". The previous ProductVersion-first read therefore never saw
the channel.

- RealmJoinInfo: read FileVersion first, parse SemVer into bare version
  + release channel (prerelease tag = channel; untagged = "release",
  confirmed by the RJ developer; build metadata dropped)
- realmjoin_detected signal + timeline event carry releaseChannel
- RealmJoinFacts.ReleaseChannel set-once fact + audit-trail field
  realmjoinReleaseChannel

Suites: DecisionCore 360, V2.Core 1307 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:59:03 +02:00
Oliver Kieselbach e4187f6873 Merge pull request #71 from okieselbach/wave4-v2-agent-review
Wave 4 V2-agent-review remediation: ARCH-F7, MON-C2, ARCH-F1, ARCH-F4+F2
2026-06-11 10:54:22 +02:00
Oliver Kieselbach fb472b518e refactor(agent-v2): group termination-handler ctor into 4 surfaces (ARCH-F2)
EnrollmentTerminationHandler's 25-parameter constructor (17 optional,
9 Func<> accessors, wired via ~80 lambda lines in AgentRuntimeHost)
becomes 11 parameters: four cohesive surfaces plus direct infra deps.

- TerminationSessionContext (concrete, pure data): configuration,
  stateDirectory, agentStartTimeUtc, agentVersion, sessionPersistence,
  dialogExePathOverride, lazy IsWhiteGlovePart2.
- IAppTrackingReadModel: currentState, packageStates, appTimings,
  ignoredCount, promoteActiveInstallsToStuck, lastEspTerminalFailure.
  Production: OrchestratorAppTrackingReadModel over the orchestrator's
  CollectorSurfaces (builds on ARCH-F4).
- IDrainStatus: spool/ingress drain counts behind CanObserve* capability
  flags (replaces the accessor-null wiring variants); production:
  OrchestratorDrainStatus with the V1-parity 10s budget.
- IShutdownGate: signalShutdown, triggerReboot, writeCleanExitMarker,
  tryClaimShutdownEvent; production: RuntimeShutdownGate in the host
  project (owns the shutdown.exe logic formerly DefaultTriggerReboot).

Per-parameter rationale comments (c117946b, 080edee9, 7dd4e593, Codex
Finding 2, Option 1/2 hardening, plan §11) moved onto the interface
members. Test rig builds recording fakes of the same interfaces, so the
optional-default wiring variants production never ran are gone.

No behavior change. V2.Core 1297/1297, DecisionCore 359/359, full
solution build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:28:33 +02:00
Oliver Kieselbach cb9e2cf501 refactor(agent-v2): factory returns CollectorSurfaces, kill state-bag + downcast (ARCH-F4)
CreateCollectorHosts now returns an immutable CollectorSurfaces bundle
(hosts + typed live read-model views) and takes the telemetry spool as a
parameter. Deletes DefaultComponentFactory's mutable host fields, all
lazy read-model properties, and SetTelemetrySpool; deletes the
'is DefaultComponentFactory' downcast in EnrollmentOrchestrator (the
IComponentFactory seam is the contract again). The orchestrator stores
the surfaces and exposes them via CollectorSurfaces; AgentRuntimeHost
reads IME app tracking / EspAndHelloHost / AadJoinHost from there.

No behavior change. V2.Core tests 1297/1297 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:14:01 +02:00
Oliver Kieselbach b9f0a232e8 fix(agent-v2): address Wave-4 review findings P2 + P3
P2 (MON-C2): HelloTracker.ResetForEspResumption did not clear the new
_helloUnknownGraceUsed one-shot flag. After a grace-consumed run, a fresh
post-resume Hello-wait that hit an undetected policy would skip its grace
re-arm and resolve straight to not_configured. Reset the flag + add a
regression test (unknown timeout -> reset -> unknown timeout re-grants grace).

P3 (ARCH-F1): the completion-gate docs overpromised "add a gate to
s_completionGates and every site inherits it". Tightened to state precisely
that the collection governs only the completion-ATTEMPT sites routed through
CompleteThroughFinalizingOrDefer; the RealmJoin release path (pre-resolution
state) and the SelfDeploying deadline path (gate-specific deferral) do NOT
consult the collection and need explicit wiring for a second gate. Doc-only.

V2.Core.Tests 1297/1297; DecisionCore.Tests 359/359.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:33:20 +02:00
Oliver Kieselbach 1126913d1c refactor(decisioncore): centralize completion-gate weaving (ARCH-F1)
Wave 4 / ARCH-F1. The cross-cutting "may the session complete now?" AND-gate
was hand-inlined at every terminal site across the Classic / Shared engine
partials — each with a near-identical RealmJoinGateOpen check plus its own
":RealmJoinGateClosed" defer block. That duplication was the origin of the
premature/duplicate enrollment_complete regressions (8b8d611d, 08c99638,
330f73f3) and meant a second completion gate (WDP v2 device-association
settle) would have to be woven into every site.

New DecisionEngine.Completion.cs introduces:
  * CompletionGate (name + IsOpen predicate) + an append-only s_completionGates
    collection (RealmJoin is the sole gate today) + FirstClosedCompletionGate.
  * CompleteThroughFinalizingOrDefer — the single router every "both
    prerequisites in -> finish via Finalizing" site now calls. All gates open
    -> TransitionToFinalizing; a gate closed -> the canonical defer step with
    the trigger tagged ":<GateName>Closed" (derived from the gate, not
    hardcoded).

The four identical Classic/Shared sites (HelloResolved, DesktopArrived normal
+ Hello-disabled fast-path, Hello-safety timeout) now route through the helper.
The SelfDeploying deadline path keeps its gate-specific deferral (it owns the
SelfDeployingDeferredCompletion flag, released by the RealmJoin handlers); the
RealmJoin release path completes directly because the completion gates read the
pre-resolution state where RJ is still closed.

No behavior change — proven by the replay-deterministic scenario tests passing
bit-identically (same terminal stages, outcomes, hashes). Adding a gate is now
one entry in s_completionGates. New forward-proof test locks the gate-name ->
trigger-suffix derivation.

DecisionCore.Tests: 359/359 (was 358 + 1 new). Full solution builds; V2.Core
engine integration green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:50:09 +02:00
Oliver Kieselbach c40e136660 fix(agent-v2): don't force not_configured on undetected Hello policy (MON-C2)
HelloTracker.OnHelloWaitTimeout branched on `if (_isHelloPolicyEnabled)`,
whose false case force-resolved HelloOutcome=not_configured and fired
HelloCompleted. But `_isHelloPolicyEnabled == false` also holds when the
policy has simply not been detected yet (_isPolicyConfigured == false) —
a slow MDM/CSP sync that lands the WHfB policy read after the 30s wait
would then prematurely resolve Hello on a device that is actually
configured.

Make the timeout a 3-way decision:
  * policy known ENABLED            -> extended wait (long completion timer)
  * policy NOT YET DETECTED         -> one bounded grace re-arm (~wait window)
                                       so a slow policy sync can land; only
                                       then fall through to not_configured
  * policy known DISABLED (or still
    unknown after the grace)        -> not_configured completion (unchanged)

The bounded grace (a single re-arm of the existing one-shot wait timer)
is deliberately NOT the 300s completion timer: DetectHelloPolicy returns
(null,null) forever on a genuinely-unconfigured device, so an unconditional
extended wait would add a 5-minute completion delay to every such device.
The grace caps the extra wait and still closes the race.

Telemetry: hello_wait_timeout events now carry policyDetected + a distinct
action ("unknown_policy_grace") so the undetected path is observable.

V2.Core.Tests: 1296/1296 (3 new MON-C2 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:40:24 +02:00
Oliver Kieselbach b4d5a78036 refactor(agent-v2): delete test-only sub-tracker adapters (ARCH-F7)
Wave 4 / ARCH-F7. HelloTrackerAdapter, ShellCoreTrackerAdapter and
ProvisioningStatusTrackerAdapter had zero production instantiations —
production runs solely through EspAndHelloTrackerAdapter (wired in
EspAndHelloHost), which already exposes every Trigger*FromTest seam the
sub-adapter tests exercised. Tests on the sub-adapters proved nothing
about production behavior.

- Delete the 3 sub-adapter sources + their test files.
- Port the meaningful coverage to EspAndHelloTrackerAdapterTests:
  HelloOutcome verbatim-propagation theory + empty-failureType fallback.
- Rewrite ShellCoreToEngineEspExitingTests test 1 to assert the tracker's
  EspExited contract directly (HandleBackfillRecord -> EspExited + source
  timestamp); drop the redundant adapter-driven reducer test (coordinator
  test 5 already covers the full production chain).
- Drop the duplicate ProvisioningStatusTrackerAdapter test in
  EspFailureDetectedHResultTests (coordinator test above it is identical).
- Refresh stale ShellCoreTrackerAdapter references in ShellCoreTracker +
  DefaultComponentFactory doc comments.

V2.Core.Tests: 1293/1293 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:34:47 +02:00
Oliver Kieselbach b45384fd01 feat(agent-v2): V2 agent review remediation (waves 1-3)
Read-only V2 agent review remediation; V2-only, net48-safe, durability/recovery
guarantees preserved (SignalLog + Journal still fsync per step).

Wave 1 (small/independent):
- PERF-H3+MON-A2: shared SignalActivityClassifier (excludes DeadlineFired/classifier_tick
  + periodic & agent-health events) - fixes idle-stop defeated by the 30s tick and StallProbeHost
  measuring uptime instead of idleness (+ ResetProbes on real activity, generation-guarded).
- LIFE-F2: cleanup reboot uses shutdown.exe /r (Restart-Computer -Comment/-Delay never rebooted).
- MON-D1: one-shot collector_degraded on event-log watcher arming failure (ShellCore/Hello/ModernDeployment).
- TRACE-M1: AdminPreemption terminal events carry the decision audit trail.
- ARCH-F8: DesktopArrivalDetector reentrancy guard.
- MON-B8: stall active-install age filter (CMTrace timestamp, conservative).

Wave 2 (durability efficiency, guarantee-preserving):
- H1a: snapshot only on stage-change/effect-bearing steps (pass-throughs replayed; floor-bounded).
- H2: classifier_tick armed reactively on first WhiteGlove signal, not eagerly at SessionStarted.
- M1: IME state-save throttled (>=2s) + atomic Formatting.None File.Replace.
- M2: single IME directory enumeration per tick + guarded Trace lines.
- M5: cached DecisionCoreJsonSettings.Shared.

Wave 3 (robustness, minus LIFE-F4/F5):
- MON-A1: gather phase_change/on_event triggers driven by SignalIngress.SignalPosted.
- TRACE-H2: quarantine contract - TriggerQuarantine persists a marker honoured fail-closed on Start.
- TRACE-H1 (defensive per review P1): explicit item-level backend poison signal (body flag) is the
  only authorized discard; 413 -> split/shrink; unknown permanent 4xx -> retain + telemetry_upload_blocked
  (local diagnostic); auth-permanent retained. Survivor re-upload honours backend control signals;
  poison forensics distinguish Oversize vs BackendRejected.
- LIFE-F3: drain spool before launching the self-destruct cleanup script.
- LIFE-F6: cleanup verifies task removal (+schtasks fallback) before deleting files.

New event types: collector_degraded, state_quarantine_recovered, telemetry_upload_poisoned,
telemetry_upload_blocked. De-flaked OfficeBinaryWatcher bound test (synchronise-on-bound).
Tests: V2.Core 1314/1314, DecisionCore 358/358, MCP tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:09:11 +02:00
Oliver Kieselbach d68f6d23e0 fix(backend): cut ingest-path observability noise (stuck-warning, index 404, batch dup-rowkey)
Three sources flooded the App Insights warning/exception channels and worked
against traceability without any functional value:

1. Per-batch "still InProgress — may be stuck" warning removed from both ingest
   sites (EventIngestProcessor + IngestEventsFunction.Processing). It was
   observability-only (no status/StalledAt/OpsEvent) and fired on every ingest
   of a >4h session (~440 warnings/10h for 7 sessions). Stuck handling is owned
   authoritatively by MaintenanceService.MarkStalledSessionsAsTimedOutAsync
   (Stalled@2h silence, Failed@SessionTimeoutHours + SessionTimeouts OpsEvent),
   which acts strictly earlier than the 4h log — zero functional loss.

2. MergeSessionIndexAsync: specialize the catch on RequestFailedException 404
   (benign race — index row not created yet / awaiting dual-write backfill) and
   downgrade to LogDebug so the handled exception stops polluting the exceptions
   table. Other failures still log as Warning.

3. StoreEventsBatchAsync: dedup each chunk by RowKey ({Timestamp:ms}_{Sequence})
   before SubmitTransactionAsync. Colliding events collapse under UpsertReplace
   anyway, but Azure rejects the whole batch with InvalidDuplicateRow, forcing a
   per-event fallback and tracking a noisy TableTransactionFailedException.
   Keeping last-occurrence (matches UpsertReplace last-wins) lets the batch
   succeed cleanly.

Build: 0 warnings/errors. Tests: 123 (SessionIndex/StoreEvents/EventIngest/
Maintenance/Stalled) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:23:50 +02:00
Oliver Kieselbach fb5866a9fe fix(web): restore deep link after re-auth redirect
MSAL runs with navigateToLoginRequestUrl=false and redirectUri=origin, so
after an interactive login the user lands on the bare root and AuthGate routes
them role-based to /dashboard — dropping the deep link they opened (e.g. a
/sessions/[id] link in a new tab, which has no shared sessionStorage token and
must re-auth).

Stash the intended in-app path before login() in ProtectedRoute and restore it
in AuthGate after auth settles. New lib/postLoginReturn.ts persists it in
sessionStorage (survives the same-tab redirect round-trip) and guards against
open redirects (same-origin "/..." paths only). Do NOT flip
navigateToLoginRequestUrl — it is intentional for the www->portal cross-origin
flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:11:59 +02:00
Oliver Kieselbach dca4da50ed fix re-auth for session detail and diag page 2026-06-10 00:33:29 +02:00
Oliver Kieselbach 59fa22a28e add View Session button to OpsEvent dialog 2026-06-10 00:17:43 +02:00
Oliver Kieselbach d5f68ea320 fix OfficeInstallDetector completion race condition 2026-06-09 23:35:27 +02:00
Oliver Kieselbach bc1f11d2db revert(backend): undo L4 telemetry sampling — ineffective in isolated worker
Deploy-verified on f4d51ea4: the worker-side adaptive-sampling exclusion
(EnableAdaptiveSampling=false + manual Configure<TelemetryConfiguration>
UseAdaptiveSampling(excludedTypes:"Request;Event")) does NOT take effect in the
Functions isolated worker. Live data showed the enriched worker request copy
(Source=WorkerMiddleware) sampled to ~63% while the redundant host request item
(Source="") stayed fully billed — strictly worse (lossy enriched copy, host
duplicate unchanged). host.json "Host.Results": "Error" likewise did not drop
the host request item (emitted by the host process, not governed by that level).

Restores known-good: default worker sampling (enriched copy complete at normal
traffic, burst-capped) + host.json Host.Results back to Information. L1 storage
dependency filter and L2 Function-trace suppression are unaffected and still
verified live. L4 deferred pending a mechanism verified against the isolated-
worker pipeline. H2/H3/O1/O2 from f4d51ea4 verified healthy (0 exceptions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:17:17 +02:00
Oliver Kieselbach f4d51ea499 perf(backend): cut App Insights cost + ingest hot-path reads (review follow-through)
Telemetry cost (L4): worker-side adaptive sampling now excludes Request;Event
so the enriched RequestTelemetryMiddleware copy is never thinned; host.json
Host.Results=Error drops the duplicate unenriched host request item. One
complete request row per call instead of two (one unsampled+unenriched, one
sampled+enriched).

Ingest hot path (H2/H3): BlockedDeviceService caches negative answers (30s
revalidation, symmetric with positive entries) so healthy devices no longer
pay a BlockedDevices point-read per request; SessionDeletionGuard returns its
already-loaded row and IncrementSessionEventCountAsync returns the post-merge
snapshot, collapsing ~4 Sessions reads/batch to ~2.

Queue-worker correctness (O1/O2): new GetSessionEventsStrictAsync propagates
storage failures on the analyze + vulnerability-correlate paths so a transient
read fault retries/poisons instead of silently deleting the message after
"analyzing" an empty stream. Fail-soft variant kept for UI/metrics surfaces.

Review fixes: parse prefetched Status as string (the actual wire format, was
int -> optimization never fired); session-aware block over a cached negative
entry keeps its session scope instead of widening to whole-device.

2274/2274 backend tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:43:39 +02:00
Oliver Kieselbach 5e6d3f303d adjusted AppInsights telemetry filter 2026-06-09 21:12:38 +02:00
Oliver Kieselbach 9750d918aa fix(backend): drop dead client_IP enrichment (App Insights masks it)
Setting requestTelemetry.Context.Location.Ip never reached the requests
table: App Insights uses the IP only for geo-lookup then masks the stored
client_IP to 0.0.0.0 (verified — every request in the resource shows
0.0.0.0). On top of that, the only IP reaching the isolated worker is an
Azure-infra hop (West Europe / Amsterdam), not the device's real egress IP
(device geo = Falkenstein). A usable device IP would need a non-masked
custom property AND the correct forwarded header (Front-Door-topology
dependent) — not worth it: TenantId + CorrelationId already isolate a
device (they dissolved the IngestTelemetry "multiplication" illusion).

Remove the dead Context.Location.Ip block + now-unused Security using,
replace with a NOTE documenting why client_IP is intentionally absent so it
is not re-added. ClientIpExtractor.GetClientEgressIp stays (still used by
ReportDistressFunction's stored SourceIp, which is not App-Insights-masked).

Full backend suite 2249/2249 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:40:16 +02:00
Oliver Kieselbach 2a5f9e133e feat(backend): enrich request telemetry (real client IP, tenant, 429, distress IP)
Makes the requests table actually usable for diagnosing agent traffic and rate
limits, without adding any new telemetry items — only properties on the already-
emitted, sampling-excluded Request telemetry (host.json excludedTypes "Request;
Event"), consistent with StorageDependencyFilterProcessor.

RequestTelemetryMiddleware:
- client_IP: the isolated worker behind Front Door/App Service left it 0.0.0.0.
  Set Context.Location.Ip from X-Azure-ClientIP (Front Door's true client egress
  IP) else trusted rightmost X-Forwarded-For. Header values are joined via
  StringValues.ToString() so the rightmost trusted hop wins (not a spoofable
  left entry). Diagnostic only — the rate limiter independently uses the trusted
  hop, so a spoofed value here cannot affect throttling.
- TenantId: agent ingest skips PolicyEnforcementMiddleware so reqCtx.TenantId was
  empty. Fall back to X-Tenant-Id, but ONLY on an exact allowlist of device
  endpoints that actually validate it (agent telemetry/ingest/error/distress,
  bootstrap ingest/error) and only when it is a well-formed GUID — a prefix match
  would let anonymous/management routes (bootstrap/validate, bootstrap/sessions,
  agent/config, …) be tagged with a spoofed tenant.

RateLimitService: 429 warning is now structured ({RequestsInWindow}/{MaxRequests}
/{WindowSeconds}/{RateLimitKeyPrefix}) so it's queryable; privacy-safe (8-char
prefix only — the key may be a cert thumbprint, bootstrap token, or UPN).

ReportDistressFunction: store the real device egress IP in SourceIp (table entry
+ AgentDistressReport event) via new ClientIpExtractor.GetClientEgressIp; the
rate-limit key keeps the un-spoofable trusted hop.

Full backend suite 2249/2249 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:28:08 +02:00
Oliver Kieselbach b5fad16eae feat(agent-v2): per-request X-Correlation-ID for end-to-end tracing
The agent never sent X-Correlation-ID, so the backend's CorrelationIdMiddleware
minted a fresh id per inbound request — meaning a single logical upload that
fans out into multiple backend executions could not be tied back together (the
blocker that left the IngestTelemetry request-multiplication question
unanswerable). Wire up the correlation the infra was designed for:

- BackendTelemetryUploader: generate a unique X-Correlation-ID per upload (per
  attempt, so a retry is a distinct traceable request) and log `corr=` on every
  outcome line (flushing/OK/TRANSIENT/TIMEOUT/NETWORK/PERMANENT).
- BackendApiClient.AddSecurityHeaders: same per-request id for register/config/
  diag/error, with a Debug line carrying corr + path.

Payoff: `requests | where name=='IngestTelemetry' | summarize count() by
tostring(customDimensions.CorrelationId)` now gives the per-request
multiplication factor deterministically (1 = clean, N = multiplied).

Test: updated the uploader logging assertion to the new `ingest OK (corr=` +
`items=N` format. Full V2.Core suite 1266/1266 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:27:53 +02:00
Oliver Kieselbach f4840bfd35 fix(agent-v2): RequestStop must not consume the registration before Stop waits
Follow-up to 28885f7b. RequestStop() called UnregisterWait(), nulling
_registeredWait. When a Changed handler calls RequestStop() and then
queues Stop()/Dispose() to another thread while the callback is still
running — exactly the RealmJoinWatcher appearance-handler shape — the
queued Stop() then saw a null _registeredWait, skipped the blocking
Unregister(waitObject), and returned BEFORE the in-flight callback
finished. That defeated the Thread.Join() parity guarantee on the most
important RequestStop path.

RequestStop() now only sets _stopped and Set()s the signal, leaving the
live registration in place so Stop()/Dispose() can wait on it. The Set()
wakes any still-pending wait, which drains as a no-op (proceed is false
once _stopped is set); a callback already in flight is awaited by Stop().
No deadlock: Stop runs on a different thread and never holds _sync during
the wait.

Test: deterministic guard — park the callback inside the handler, call
RequestStop(), then assert Stop() blocks until the handler completes
(release fired 250ms later on another thread). Proven to FAIL when the
UnregisterWait() is reintroduced into RequestStop(). Full V2.Core suite
1266/1266 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:36:54 +02:00
Oliver Kieselbach 28885f7bef fix(agent-v2): force REG_NOTIFY_THREAD_AGNOSTIC + wait for in-flight callback on Stop
Two review findings on the ThreadPool-wait RegistryWatcher (993ebbaa):

P1 (correctness, would silently break in production): force
REG_NOTIFY_THREAD_AGNOSTIC into the effective filter regardless of the
caller's argument. Without a dedicated long-lived thread, the first
RegNotifyChangeKeyValue is issued on the transient Start() caller thread
and re-armed on rotating ThreadPool threads; without this flag the kernel
ties the registration to the issuing thread and cancels the notification
once it ends/recycles. AadJoinWatcher / ProvisioningStatusTracker /
RealmJoinWatcher all pass filters WITHOUT the flag — OR'ing it in the
ctor keeps them correct. (Win8+ only; all Autopilot devices are Win10+.)

P2 (semantic parity with the old Thread.Join): Stop() now issues a
blocking Unregister(waitObject) and waits for any in-flight OnSignalled
callback to finish BEFORE disposing _signal / _keyHandle, so Changed can
no longer fire (nor a freed handle be used) after Stop()/Dispose()
returns. The wait runs outside _sync so the callback's re-arm path can
still observe _stopped; it is deadlock-free because Stop() is never
called from within a handler (callers use RequestStop or queue Stop to
another thread). OnSignalled no longer nulls _registeredWait up front so
Stop can wait on the live registration.

Test: deterministic guard asserting the watcher forces ThreadAgnostic
into its effective filter for callers that omit it. A behavioural
thread-death test is unreliable — without the flag the kernel emits a
*spurious* signal on arming-thread death that a Changed-based test cannot
distinguish from a real change. Full V2.Core suite 1265/1265 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:20:32 +02:00
Oliver Kieselbach 993ebbaa67 perf(agent-v2): RegistryWatcher on ThreadPool wait + disposal hygiene
Reduce the V2 agent footprint during enrollment without touching any
capability or the deliberate live-tracking cadences (DO 3s poll, IME
100ms poll, download_progress ImmediateUpload — all product USP).

RegistryWatcher: replace the dedicated background thread (new Thread +
WaitHandle.WaitAny on RegNotifyChangeKeyValue) with a ThreadPool wait
(ThreadPool.RegisterWaitForSingleObject), mirroring the proven pattern
in Monitoring/Telemetry/Office/RegistryChangeWatcher. Removes up to
~7-8 dedicated ~1MB thread stacks (Enrollments + CloudDomainJoin +
AadJoin + Provisioning + up to 4x RealmJoin). The public contract is
preserved verbatim: ctor signature, Changed/Error events, IsRunning,
Start()/RequestStop()/Stop() (incl. deferred rethrow of the captured
background failure) and Dispose(). Arming stays synchronous in Start();
Changed is raised outside the lock; the watcher re-arms after each
single-shot notification. All four consumers run unchanged.

Disposal hygiene:
- WmiCollector: dispose each ManagementObject per iteration (native
  WMI/COM memory) via using (obj).
- AgentSelfMetricsCollector: wrap both Process.GetCurrentProcess() in
  using var (handle leak in the 60s metrics loop).

Verified: V2.Core + V2 agent build clean; full V2.Core suite 1264/1264
green, incl. the RegistryWatcher Win32 integration tests (value write,
subtree subkey, repeated successive changes) and the RealmJoin /
AadJoin / Provisioning / TenantId consumer tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:31:20 +02:00
Oliver Kieselbach 5586963bcc feat(autopilot): show "not registered" devices (14-day window) under enrollment validation
Adds a tenant-scoped, informational overview of devices that failed enrollment
with HTTP 403 because they were not in the tenant's Autopilot / Corporate
Identifier registry. Mirrors the Hardware Whitelist rejection insights but
aggregates per device (serial number), matching how Autopilot registers
individual devices rather than models. Read-only, no webhook.

Backend
- GetDeviceNotRegisteredFunction: route audit/device-not-registered, MemberRead,
  filters distress reports on ErrorType == "DeviceNotRegistered", aggregates by
  serial (case-insensitive; empty serial -> single bucket); 14-day horizon is
  the existing distress-report retention.
- Register route in EndpointAccessPolicyCatalog (fail-closed).
- 8 unit tests for BuildAggregatedResult.

Frontend
- api.distress.deviceNotRegistered().
- NotRegisteredDevicesInsights component (serial/mfr/model/attempts/first/last
  seen, green empty-state, unverified-data notice).
- Wired below AutopilotValidationSection.

DeviceNotRegistered (403) covers both Autopilot and Corporate Identifier
validation; the agent cannot distinguish which failed, so copy is neutral.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:15:23 +02:00
Oliver Kieselbach 403d042f18 updated agent changelog 2026-06-09 10:52:12 +02:00
Oliver Kieselbach cd6dfe7c3c feat(office): render Office as an install row + fix started/completed ordering
UI — Office in Install Progress:
- The office_install_* lifecycle now renders in the existing InstallProgress
  card as a first-class install row (live elapsed timer while installing +
  final duration). page.tsx feeds office_install_started/completed/failed into
  the component; InstallProgress maps them onto the same Installing/Installed/
  Failed flow via isStarted/isCompleted/isFailed (Office has no postponed/
  skipped; appName "Microsoft 365 Apps").

Fix — completed-before-started (field session a7525e97, Office CSP / Win32
wrapper):
- When Office is already on disk as the C2R worker runs (CSP install / a Win32
  package that re-runs C2R), the host's OfficeBinaryWatcher initial scan
  completes the lifecycle synchronously. BeginIfIdle armed that watcher (via
  ObserveInstallationPath) BEFORE emitting started, so completed was emitted
  first. Reorder: emit started, then observe the path — guaranteed started ->
  completed ordering. Start is still gated by real evidence (process / registry
  Scenario\INSTALL / DO job); only the emit order changed.
- InstallProgress defensive guard: a late started on an already-Installed item
  backfills startedAt and recomputes durationMs instead of dropping it, so
  out-of-order / network-reordered delivery still yields a correct duration.

V2-only + web. ConfigVersion unchanged. V2.Core 1264/1264, web 452/452, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:11:44 +02:00
Oliver Kieselbach e922ab7bf0 feat(office): filesystem-watcher completion + drop noisy progress event
Rev 4 of the Office detector, from field session 7da7dead (agent 2.0.980):
early DO-CDN start worked, but there was no completion and the progress
events were useless.

Completion (was never firing):
- The DO-"ended" heuristic is structurally unreliable for Office — many
  parallel CDN jobs churn, finished ones drop out of the list, so the
  aggregate never reaches 100% and the job count never cleanly hits zero.
  onOfficeDownloadEnded never fired, so the completion/binary check never ran.
- Replace it with an OfficeBinaryWatcher: once the InstallationPath is known
  from the registry, a FileSystemWatcher on {InstallationPath} fires when any
  core Office binary (WINWORD/EXCEL/POWERPNT/OUTLOOK.EXE — any-of, products can
  be excluded) appears on disk -> TryFinalizeCompletion emits completed. Initial
  on-arm scan + bounded retry-arm cover armed-late / dir-not-yet-present. The
  whole DO-ended close machinery (host close/settle/probe timer,
  collector onOfficeDownloadEnded) is removed.

Progress (was noise):
- Connected-Cache delivery is near-instant and the multi-job aggregate never
  yields a clean 0..100 bar (7da7dead showed one/five identical 62% events).
- Drop office_install_progress emission entirely. Only started / completed /
  failed now. The valuable Delivery-Optimization data ships ONCE as a doSummary
  on the completed event: total bytes + Cache-Server / peer / pure-CDN split
  (CDN = BytesFromHttp - BytesFromCacheServer on MCC networks) + download mode,
  alongside durationSeconds and versionReached.

Log spam:
- The registry watcher fires on every ClickToRun value churn; 7da7dead logged
  ~4000 "Office install expected" INFO lines. Now OfficeExpected is only raised
  until the lifecycle starts, and NotifyOfficeExpected logs at Debug and only
  when the probe window re-opens from closed.

V2-only. ConfigVersion unchanged (29). The office_install_progress constant +
MCP/web catalog entries are left in place (now never emitted; optional prune).
V2.Core 1263/1263 green; V2 runtime builds. The FileSystemWatcher itself is
OS-bound and not unit-tested (consistent with the process/registry watchers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:34:59 +02:00
Oliver Kieselbach 735b27db52 feat(office): detect Office install from DO-CDN job + filesystem completion
Rev 3 of the Office detector. Stops anchoring the lifecycle on the late,
transient OfficeC2RClient.exe worker (field sessions 8353e03b / 58d52632:
the 3.4 GB Office-CDN download ran ~10 min BEFORE the worker appeared, which
then lived ~5 s — so we only ever saw the download tail, never completion).

Early detection (no new polling):
- DeliveryOptimizationCollector now classifies Office-CDN jobs UNCONDITIONALLY
  (dropped the process-driven _officeActive gate). The download is already
  visible in the IME-driven polls long before the worker. An event-driven wake
  (NotifyOfficeExpected, fired when the registry shows Scenario\INSTALL) covers
  the isolated-Office case via a bounded probe window.

Earliest-signal start, paired:
- office_install_started now fires on the first of {Office-CDN DO job,
  Scenario\INSTALL registry, OfficeC2RClient.exe} — idempotent, one lifecycle,
  with startedTrigger recorded in the payload.

Real completion (filesystem proof):
- On window close (download ended AND worker gone), the host runs a bounded
  completion probe: read Configuration\InstallationPath and, if any core Office
  binary (WINWORD/EXCEL/POWERPNT/OUTLOOK.EXE) exists under {InstallationPath}\
  root\* (version folder enumerated, not hardcoded), emit office_install_completed.
  The integrate phase lays binaries down after the stream ends, so the probe
  retries (settle, then <=8x15s). No on-disk proof -> stop silently (never a
  false completed/failed). Explicit error code -> failed (unchanged).

Wiring: OfficeInstallDetectorHost arms the RegistryChangeWatcher at Start (not
worker-start) and owns the close/probe schedule + an OfficeExpected event; the
DO host/factory take the whole officeHost and route SubmitDoSample /
NotifyOfficeDownloadEnded / OfficeExpected.

V2-only. ConfigVersion unchanged (29). 29 Office tests; V2.Core 1265/1265 green.
Field-validate next enrollment (startedTrigger=do at download start, completed
with coreBinariesPresent); Scenario\INSTALL pre-download timing is TODO(office-followup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:22:46 +02:00
Oliver Kieselbach a188e7cdb2 fix(office): pace office_install_progress to observable movement only
Field session 58d52632 showed a burst of 5 office_install_progress events
within the same second, all carrying an identical, frozen 62% download —
not real progress.

Root cause: Office progress has two triggers, the 3s DO poll and the subtree
RegNotifyChangeKeyValue push on the ClickToRun key. During an active C2R
operation the worker rewrites the undocumented Scenario\* values many times
per second, firing RegNotify each time. BuildProgressSignature included the
raw ScenarioValues dict, so every churn passed the dedup gate and emitted a
progress event — while the folded DO download-% stayed frozen (the DO poll
had not re-sampled within that 1s window) and the raw scenario values are
never surfaced in the payload. Pure noise.

Fix: key the progress dedup signature only on the observable progress
dimensions we actually surface and that represent real forward movement —
VersionToReport, the active scenario name, and the DO download (bytes /
percent). Progress now paces naturally to the DO sampling cadence; the
registry push still drives error-code discovery (and, later, TasksState
phase detection).

V2-only. 22/22 Office detector tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:38:46 +02:00
Oliver Kieselbach 5edd7293ff chore: bump .claude/commands submodule (fetch-diag-zip account-key auth fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:10:54 +02:00
Oliver Kieselbach 01de1790e0 fix(agent): stop Office detector false-failed; defer completion to TasksState capture
Field session 8353e03b declared a successful Office install as failed. Root cause
(diag zip + do-status.jsonl + registry):
- OfficeC2RClient.exe is a transient launcher (~2s in the field) that kicks off the
  operation; the OfficeClickToRun.exe service installs for minutes after it exits.
  Treating worker-exit as completion → false office_install_failed.
- The registry has NO StreamingFinished value (so the completion branch could never
  fire and the "abandoned" branch always did), and Scenario\INSTALL persists
  post-install (so presence is not an "active" signal).
- Office C2R content comes from f.c2r.ts.cdn.office.net AND officecdn.microsoft.com;
  the old DO matcher only checked "officecdn" and would never attribute Office jobs.

Conservative fix (completion/failure detection deferred until a live-install capture
of Scenario\INSTALL\TasksState transitions — the real signal: per-task
TASKSTATE_COMPLETED/_INPROGRESS):
- OnWorkerStopped no longer finalizes — emits no terminal. started + progress only.
- Removed the StreamingFinished-based completed/abandoned logic; only an explicit
  numeric error code finalizes as failed.
- phase label is "Installing" (cannot distinguish Streaming/Finalizing without a real
  signal).
- DO Office matcher now keys on the stable CDN hosts cdn.office.net +
  officecdn.microsoft.com (dropped the version-fragile _16_0_ FileId marker).

V2 suite 1257/1257 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:21:02 +02:00
Oliver Kieselbach da15079f34 fix(web): stop session timeline scroll-jumping on event refresh
The progressive paged event fetch replaced the list with the first 200
rows on every SignalR/30s refresh, then re-grew it page by page. On
sessions with >200 events this shrink-then-grow changed the page height
repeatedly, making the scrollbar/viewport jump while reading and dropping
open "Details" panels.

Make the event list append-only and monotonic via mergeNewEvents: only
genuinely new events (keyed identically to the React row key) are appended
at the bottom; existing rows are never replaced. When a refresh brings
nothing new the same array reference is returned, so React skips the
re-render and scroll position + open details stay put. Native scroll
anchoring then keeps the viewport pinned as new events append below.

Session telemetry is append-only/immutable, so keeping existing rows is
safe. The transient-empty-refresh guard is now subsumed by the merge; the
dead sessionIdRef param (only used by the removed warning) is dropped.

Adds mergeNewEvents unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:07:44 +02:00
Oliver Kieselbach cf8fadfc17 fix(rules): drop AutoLogon false-positive on normal Autopilot enrollments
ANALYZE-SEC-002 fired Warning (confidence 90) on every normal user-driven
Entra-join enrollment. During ESP, Windows writes AutoAdminLogon=1 +
DefaultUserName + AutoLogonSID + no DefaultPassword for the enrolling user —
the exact fingerprint the sysinternals_autologon_suspected heuristic targeted
(enabled && user && !password). AutoLogon-enabled is registry-indistinguishable
from a Sysinternals/kiosk setup during Autopilot, so the heuristic and SEC-002
could never tell a benign enrollment from a real one.

Declare AutoLogon only when a plaintext password is actually present:
- delete ANALYZE-SEC-002 (broad AutoLogon-active warning)
- remove the non-discriminating sysinternals heuristic from AutoLogonAnalyzer
  (computation + finding label + checks.sysinternals_autologon_suspected)
- strip the sysinternals condition+factor from SEC-004 (kiosk allow-list, still
  a disabled opt-in template) and refresh its now-stale SEC-002 guidance
- ANALYZE-SEC-003 (plaintext DefaultPassword -> high) is now the sole default-on
  AutoLogon grade; the autologon_analysis event still carries all raw facts at Info

Tests: assert SEC-002 absent + no rule references the sysinternals signal; add
analyzer regression guard for enabled+user+no-password. V2-only, no ConfigVersion
bump. Build 0 err, AutoLogon 19/19, BuiltInRulesTests 8/8, web tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:06:53 +02:00
Oliver Kieselbach adc8078de9 fix(agent): silence CS8600 nullable warnings in Office host wiring
#nullable enable: declare officeHost as nullable and make the no-Office
onOfficeDoSample fallback a nullable cast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:21:02 +02:00
Oliver Kieselbach dbfdc9b239 feat(agent): event-driven Office (M365 Apps C2R) install detector + DO telemetry
Surfaces the real Microsoft 365 Apps Click-to-Run install lifecycle that the
Intune "integrated" app hides (IME reports done in 1-2 min while C2R keeps
streaming in the background). V2-only.

Event-driven, no idle polling:
- OfficeProcessWatcher: WMI Win32_ProcessStartTrace push on OfficeC2RClient.exe
  (+ startup probe) for start; Process.Exited for stop, debounced by a settle
  window (OfficeInstallSettleSeconds, default 10s) so short worker gaps in one
  install don't trigger a premature office_install_failed.
- RegistryChangeWatcher: RegNotifyChangeKeyValue push on ...\Office\ClickToRun
  (Configuration value + Scenario subkey churn); bootstrap fallback watches the
  nearest existing ancestor non-recursive + name-only until ClickToRun appears.
- OfficeInstallDetector: pure state machine (Idle->Active->Terminal), emits
  office_install_started/progress/completed/failed (progress on real change only,
  no heartbeat). Error code only from non-zero numeric/hex Scenario values.

Office Delivery Optimization:
- DeliveryOptimizationCollector extended with a 2nd wake source (Office active),
  classifies non-IME Office-CDN jobs and folds aggregated DO bytes/peers/real
  download-% into the office_install_* events (not emitted as download_progress
  to avoid a phantom app in the backend AppInstallSummary).

Plumbing: office_install_* event-type constants, EnableOfficeInstallDetector +
OfficeInstallSettleSeconds config (ConfigVersion 28->29), MCP + web event-type
catalogs, ANALYZE-OFFICE-001 (office_install_failed -> Warning). 22 detector unit
tests; full V2 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:17:53 +02:00
Oliver Kieselbach ad345f5869 fix(ingest): dedup duplicate RowKeys before batch transaction in V2 ingest repos
A telemetry batch containing two rows with the same RowKey made Azure Table
Storage reject the entire entity-group-transaction with HTTP 400 /
InvalidDuplicateRow, surfacing as HTTP 500 on POST IngestTelemetry. Observed
as a 23-minute spike (36x 500) from a single session replaying an overlapping
SessionSignalOrdinal; self-resolved when the agent stopped retrying.

The three V2 ingest write paths (Signals, DecisionTransitions, Index) built
the transaction without deduping duplicate RowKeys and had no fallback (unlike
the Events path). Add a shared TableBatchDedup.ByRowKey helper that collapses
duplicate (PartitionKey, RowKey) entities last-write-wins (idempotent
UpsertReplace) and logs a WARN with the dropped count, so bad agents become
visible in logs instead of failing the batch. SubmitTransaction stays
fail-loud for any other batch error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:09:46 +02:00
Oliver Kieselbach 291789cd54 audit default to 15 entries 2026-06-08 15:53:07 +02:00
Oliver Kieselbach 7d1b3c02f1 perf(web): fleet-health consumes server-aggregated metrics endpoint
Replace the client-side aggregation (drain up to 200k raw sessions over up
to 200 HTTP round-trips, then 7 useMemo passes on the main thread) with a
single call to the new /api/metrics/fleet-health (+ global variant).

- New useFleetHealth hook mirrors useDashboardStats: initial fetch on
  scope/range change + 3s-debounced refetch on in-scope SignalR
  newSession/newevents + reconnect recovery + stale-race generation guard.
- page.tsx drops the sessions state, the eager-pagination loop
  (MAX_EAGER_PAGES/FLEET_PAGE_SIZE) and all aggregation useMemos; it now
  renders the server payload. Presentation-only derivations (bar maxima,
  axis labels) stay client-side. App-metrics (slowest/failing apps) is
  unchanged. Displayed numbers are unchanged (semantics preserved).
- api.ts: metrics.fleetHealth / metrics.globalFleetHealth.

Net effect is runtime/network, not bundle: one small JSON response instead
of a multi-megabyte session drain + main-thread aggregation on large
tenants. Verified: tsc clean, next build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:39:27 +02:00
Oliver Kieselbach 904a53461f feat(web): show Reboots, Enrollment Type, Join Type on Session Info card
Adds a third row to the session detail summary card:
- Reboots: session.rebootCount (V2 only; 0 for V1 / pre-feature sessions)
- Enrollment Type: "Device Preparation" (v2) / "Autopilot" (v1), with a
  "(PreProvisioned)" qualifier for White Glove; "Gather Rules" for diagnostic runs
- Join Type: "Hybrid Join" / "Entra Join" from the session's isHybridJoin flag

Status moves to the third row so the grid stays a clean 4-column flow. These
complement (do not duplicate) the DeviceDetailsCard System/Autopilot-Profile
sections. Adds rebootCount to the Session web type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:33:43 +02:00
Oliver Kieselbach dbb4b3f5a7 feat(metrics): server-side Fleet Health aggregation endpoints
Fleet Health previously drained up to 200k raw sessions into the browser
(MAX_EAGER_PAGES=200 x 1000) over as many HTTP round-trips, then computed
7 aggregates in useMemo on the main thread. Move that aggregation to the
backend, mirroring the dashboard-stats server-aggregation path.

- MetricsMath.BuildFleetHealthPayload: single pass over the windowed
  session list producing stats, daily timeline, top failure reasons, and
  model health/slowest/top-failing breakdowns. Semantics ported verbatim
  from the client (success rate over ALL sessions; avg duration over
  non-in-progress sessions incl. failures), so displayed numbers are
  unchanged. Typed FleetHealthMetrics DTO in Shared for a typed contract.
- GET /api/metrics/fleet-health (MemberRead) and
  GET /api/global/metrics/fleet-health (GlobalAdminOnly, optional
  ?tenantId=) reuse the existing ISessionRepository window readers.
- Registered both routes in EndpointAccessPolicyCatalog (fail-closed).
- 9 xUnit tests covering the aggregation semantics.

Frontend swap to these endpoints lands in a follow-up commit. App-metrics
(slowest/failing apps) is already backend-aggregated and untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:32:44 +02:00
Oliver Kieselbach 85fd8bc111 fix(web): gate diagnosis loading on both session + analysis fetches
Review follow-up to the diagnosis waterfall fix. With eager tenant
hydration the session and analysis fetches now run in parallel, but
fetchAnalysisResults alone cleared `loading`. A fast analysis response (or
a slow/failed session fetch) could clear loading while `session` was still
null, briefly flashing a wrong IN PROGRESS / empty state.

Track sessionFetchDone + analysisFetchDone and only end loading once both
have settled (success or failure). Both flags are reset on session
navigation. As a bonus this also clears the spinner on a failed session
fetch in the non-GA path instead of waiting indefinitely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:42:14 +02:00
Oliver Kieselbach b7f80753de updated page last modified info 2026-06-08 13:28:43 +02:00
Oliver Kieselbach cd99210b14 perf(web): kill session-list waterfall on diagnosis page
The diagnosis page fetched the ENTIRE session list (api.sessions.list /
globalSessions.list) just to .find() one session client-side, then gated
the events + analysis fetches on the resolved tenant — a 2-stage waterfall
on the critical render path, plus a large over-fetch.

Switch to the direct single-session endpoint (api.sessions.get); the
backend resolves the tenant via FindSessionTenantIdAsync for global admins
when tenantId is omitted. Eager-hydrate sessionTenantId from TenantContext
so fetchEvents/fetchAnalysisResults fire in parallel with the session
fetch instead of after it. Mirrors the proven pattern in
app/sessions/[sessionId]/hooks/useSessionDetail.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:26:07 +02:00
Oliver Kieselbach 0b79632ad8 perf(web): code-split heavy libs + add route loading skeletons
Defer the largest client-only libraries so they no longer ship in the
initial route chunk, and give navigations an instant Suspense fallback.

Code-splitting (measured via `ANALYZE=1 npm run build`, First Load JS):
- /sla: 351 kB -> 249 kB  (recharts now dynamic, ssr:false)
- /sessions/[sessionId]/inspector: 269 kB -> 184 kB  (@xyflow/react +
  dagre lazy-loaded; only the "graph" tab needs them)
- fflate moved to on-demand `await import()` at the zip call sites
  (ReportSessionModal, SectionSubmitLogs): -5 kB on /sessions/[sessionId]
  and /settings/tenant/[section]

next.config: experimental.optimizePackageImports for recharts,
@xyflow/react, @microsoft/signalr, @azure/msal-react (better tree-shaking).

Perceived performance: add a shared PageSkeleton and route-level
loading.tsx for dashboard, fleet-health, sessions/[sessionId],
diagnosis/[sessionId] and apps/[appName] so App Router streams a skeleton
during navigation instead of a dead frame. Purely additive, no change to
existing client logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:25:01 +02:00
Oliver Kieselbach 233bdb7be0 chore(web): add @next/bundle-analyzer for bundle-size measurement
Wire @next/bundle-analyzer into next.config.ts behind an ANALYZE=1 env
gate so `ANALYZE=1 npm run build` emits client/server/edge treemaps into
.next/analyze/. Measurement infrastructure only -- no runtime behaviour
change. Establishes a baseline before bundle-splitting optimizations.

Baseline First Load JS (next build): shared 103 kB; heaviest routes
/sla 351 kB, /sessions/[sessionId] 316 kB, /dashboard 275 kB,
/sessions/[sessionId]/inspector 269 kB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:19:48 +02:00
Oliver Kieselbach 6344a8b061 feat(sessions): per-session RebootCount, stored + MCP-queryable
Surface "how many reboots did this enrollment have" as a stored,
filterable per-session metric so admins can ask e.g. "machines with
many reboots" via MCP search_sessions.

No agent change: V2 already emits one system_reboot_detected per reboot
(EventLog 6009 boot-time vs session start). The backend counts and stores
it; V1 stays 0 (never emits it).

Hybrid counting:
- Incremental per ingest batch in ClassifyEvents (V2 + legacy V1, shared
  EventClassification) -> rebootIncrement on IncrementSessionEventCountAsync,
  for a live in-flight value (passed 0 on terminal batches).
- Authoritative reconcile (ReconcileSessionRebootCountAsync): distinct count
  of system_reboot_detected from the Events table, idempotent + fail-soft.
  Runs centrally in TableSessionRepository.UpdateSessionStatusAsync after any
  successful terminal transition (ingest, admin mark, maintenance timeout,
  rule-engine fail), and again post-increment in the ingest path to cover
  already-terminal batch replays. Self-corrects at-least-once batch double-counts.

Storage/correctness:
- RebootCount preserved across re-registration in StoreSessionAsync (a reboot
  IS the restart trigger) and projected in BuildSessionIndexEntity (superset rule).

Search/MCP:
- rebootCountMin/Max pushed to OData in BuildSearchScanFilter (sparse-subset),
  decoded in SearchSessionsFunction + QueryRawSessionsFunction, added to the
  continuation fingerprint and the fields= projection. Client-side
  MatchesRebootCountBounds backstops the device-snapshot + legacy scan paths.
- search_sessions MCP tool gets rebootCountMin/Max + rebootCount projection.

Tests: SessionIndexProjection (superset), StoreSessionReregistrationPreserve,
RebootCountFilterBounds. Suite 2228/2228 green; MCP 220 green.

Inert until backend redeploy; legacy index rows lack the column until next
upsert (excluded by min-filter, intended). Web UI badge deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:11:52 +02:00