Commit Graph

1668 Commits

Author SHA1 Message Date
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
Oliver Kieselbach c0552b7f9d reduced AppInsights dependency telemetry 2026-06-08 11:03:01 +02:00
Oliver Kieselbach fbef5c0467 changed tooltip visual 2026-06-08 09:41:07 +02:00
Oliver Kieselbach 9ad5225ec6 added tooltip last contact to sesson info 2026-06-08 09:29:53 +02:00
Oliver Kieselbach ba638cad2f re-ordering sidebar monitoring entries 2026-06-07 18:26:23 +02:00
Oliver Kieselbach dce5d7b14e feat(apps): opt-in pagination on apps/list + client-side paging in Installs tab
The apps/list endpoints returned the full per-app list in one shot, so there was
no way to page a large fleet's apps (e.g. for a future MCP consumer).

Backend (apps/list + global/apps/list):
- AppsAnalyticsHelper.BuildAppsListResponse gains opt-in pagination: no pageSize
  keeps the legacy full-array body (the web UI pages client-side); pageSize
  returns an offset-paginated envelope with count/offset/pageSize/nextLink.
- The list is aggregated in-memory from a deterministic sort, so a plain integer
  offset is a stable cursor — no HMAC continuation token needed (unlike the
  Azure-Table-backed config/all surface). Added an appName tiebreaker so the sort
  is fully deterministic across pages.
- New ParseAppsPaging validates pageSize (1..1000) / skip; both functions 400 on
  bad input and build a route-specific nextLink (global echoes tenantId).

Web (Installs tab): client-side pagination (pageSize 20) over the filtered/sorted
apps, matching the Inventory tab — sort/search stay across the full set.

Tests: AppsAnalyticsHelperPagingTests (legacy vs paginated, deterministic order,
last-page nextLink null). Backend 2206/2206, web 446, tsc clean, next build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:25:10 +02:00
Oliver Kieselbach 0dfef290e3 fix(metrics+mcp+web): address DO-rollup + GA-inventory review findings
- DO rollup no longer drops legacy telemetry. MetricsMath.BuildAppMetricsPayload
  now delegates to DoAggregator.Compute (the existing single source), which falls
  back to peers + http when DoTotalBytesDownloaded is 0 and filters to rows that
  actually carry DO telemetry (DoDownloadMode >= 0). Removes the duplicate
  summation that used DoTotalBytesDownloaded directly as the denominator.
- InstallsTab DO card now renders whenever any DO source bytes exist (effective
  total = max(total, peers+MCC+http)) instead of hiding on totalBytesDownloaded <= 0.
- get_software_inventory: guard the GA inventory path — a Global Admin call with
  scope="inventory" and no tenantId now returns actionable guidance instead of a
  raw backend 400.

Tests: new MetricsMath fallback test; DO test rows set DoDownloadMode so they pass
the telemetry filter. Backend 2203/2203, MCP 220, web 446, all tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:11:17 +02:00
Oliver Kieselbach e4c41e03ee feat(web): merge App Health into a tabbed "Software" hub
/apps becomes a Software hub with three lens tabs synced to ?tab=
(installs | inventory | vulnerabilities) — client-side tabs to avoid a
route collision with the existing /apps/[appName] detail page; drill-down
preserved. Nav label "App Health" -> "Software".

- Installs tab: existing app-health list + a Delivery Optimization card that
  renders the deliveryOptimization rollup from /api/metrics/app (peer/MCC
  offload %, bytes saved, Peers/MCC/CDN bar).
- Inventory tab: tenant's installed-software inventory (GA picks a tenant via
  the scope selector; the inventory endpoint is per-tenant).
- Vulnerabilities tab: tenant-facing CVE/KEV exposure (first time customers
  can see their own exposure; was GA-only).

The three lenses key differently (IME app name / Registry identity / CPE-CVE)
so they are merged at the navigation level, not row-joined.

Shared groundwork (de-dupes existing copies):
- lib/formatting.ts (canonical formatBytes/Duration/Throughput/Percent),
  lib/vulnerabilitySeverity.ts (severity chips + KEV).
- components/DoBreakdownBar.tsx — DownloadProgress refactored onto it
  (behaviour-preserving via the total override).
- components/VulnerabilityExposurePanel.tsx — the GA VulnerabilityDataSection
  refactored onto it (removes the inline exposure JSX + local severity map).
- api.ts: metrics.vulnerability, metrics.softwareInventory,
  vulnerability.softwareInventory.

Verified: tsc clean, 446 web tests, next build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:51:59 +02:00
Oliver Kieselbach fed162946e feat(metrics+mcp): app-install & software-inventory MCP tools + DO rollup
Backend:
- Extract MetricsMath.BuildAppMetricsPayload as the single source for the
  app-metrics aggregation (was duplicated verbatim across the tenant and
  global functions) and add a Delivery Optimization rollup: per-app peer/MCC
  offload bytes + overall deliveryOptimization summary (peers and MCC counted
  separately, peerOffloadPercent credits both as not-from-CDN).
- New tenant-scoped GetTenantSoftwareInventoryFunction (GET metrics/software-
  inventory, MemberRead, JWT tenant) + fail-closed policy-catalog entry.

MCP server:
- get_app_install_metrics: role-aware routing (global/metrics/app vs
  metrics/app), surfaces the DO rollup.
- get_software_inventory: one role-aware tool; non-GA sees only its own tenant
  (no tenantId/scope in schema), GA additionally gets tenantId + scope=unmatched.
  New exported paginateInventory client-side pager.

Tests: MetricsMathAppPayloadTests (DO rollup/offload/sample-floor) + vitest
paginateInventory. Backend 2202/2202, MCP 220 passed, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:51:36 +02:00
Oliver Kieselbach a9c8ae42f3 update agent changelog 2026-06-07 15:45:18 +02:00
Oliver Kieselbach 425502c419 feat(ppkg): array-aware analyze rules + allow-list + size-chunked scan event
Reworks PPKG detection to one aggregate event the rules parse as an array,
adds a default + customizable allow-list, and makes the event storage-safe.

Rule engine:
- New condition source event_data_array + ItemField: iterates an array field
  (any-element match), with dot-path dataField support. (RuleEngine.ConditionEvaluators)

Agent:
- Drop per-package provisioning_package_detected events (noise: a clean Win11 VM
  ships ~22 OS-inbox .ppkg). One provisioning_package_scan carries an artifacts[]
  array; anyPpkgFound derived from it (consistent, incl. recovery residue).
- Size-based chunking: split artifacts across multiple scan events so each event's
  Newtonsoft DataJson stays < 24k (well under the 30k Table-Storage truncation that
  would blind the rule); never drops a tail artifact. Per-artifact string fields
  (identity/dir/rank/installTime) hard-capped at 256; scanErrors capped.

Rules:
- ANALYZE-SEC-005 (default on) + SEC-006 (template) fire on artifacts.identity
  not matching an anchored allow-list (^(?:...)\b) that covers OS-inbox families.
- ANALYZE-SEC-007 alarms when a per-source collection cap was hit (scanTruncated).

Web/MCP/docs: event_data_array in the rule form (types + fields), analyze-rules
docs section + example, event-type catalogs.

Tests: rule-engine array/anchor/dot-path/SEC-007; agent artifacts/chunking/field-cap
/worst-case-size. Build green; V2.Core 1236; backend rule + web tsc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:14:51 +02:00
Oliver Kieselbach 01fac29bcb feat(agent): AutoLogon detector + analyze rules (V2)
V2-only AutoLogonAnalyzer that reads the Winlogon AutoLogon configuration and
emits an autologon_analysis event with raw facts at Info severity. AutoLogon can
be a legitimate kiosk/shared-device setup or a way to automate enrollment/OOBE;
the agent reports facts only and never reads the DefaultPassword value (presence
detected via value-name enumeration). Backend analyze-rules grade the facts.

- AutoLogonAnalyzer (always-on, no tenant toggle): startup no-op, runs at
  DeviceSetup-phase completion (new AutoLogonDeviceSetupTrigger on
  EspAndHelloHost.DeviceSetupProvisioningComplete) and at final shutdown.
- Sysinternals Autologon heuristic (AutoLogon enabled + default user + no
  registry password = LSA-secret credential); never reads the LSA secret.
- Analyze rules: ANALYZE-SEC-002 (AutoLogon enabled -> warning, default-on),
  ANALYZE-SEC-003 (plaintext DefaultPassword -> high), ANALYZE-SEC-004 (kiosk
  allow-list template). Sysinternals graded via a real confidence condition
  inside SEC-002/004 so the allow-list still suppresses approved kiosks.
- autologon_analysis added to all event-type catalogs; rule-builder picker also
  gains the previously-missing integrity_bypass_analysis + local_admin_analysis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:09:52 +02:00
Oliver Kieselbach 2900b35256 feat(agent): provisioning-package (PPKG) detector + analyze rules (V2)
V2 PPKG scanner that runs once at DeviceSetup-phase start and reports raw facts
only — .ppkg file presence, Provisioning\Packages registry metadata,
Recovery\Customizations residue, and best-effort per-package content indicators.
PPKGs can come from legitimate bulk enrollment or be a pre/in-enrollment
manipulation vector; intent is judged by backend analyze-rules, not the agent.

- ProvisioningPackageCollector + ProvisioningPackageHost, wired as a kernel host
  in DefaultComponentFactory (one-shot at DeviceSetup start, not at agent start).
- Events: provisioning_package_scan (aggregate) + provisioning_package_detected
  (one scalar event per artifact so analyze rules can match per package).
- Analyze rules ANALYZE-SEC-005/006 incl. a per-tenant allow-list template.
- Event-type catalogs kept in sync (Constants.EventTypes, MCP resource-catalog,
  rule-builder eventTypes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:06:08 +02:00
Oliver Kieselbach 6daee92809 fix(sessions): preserve cumulative script counters across re-registration
StoreSessionAsync rewrites the Sessions row with UpsertEntity (Replace mode)
on every agent restart (same session id re-registration). The preserve-list
carried EventCount/CurrentPhase/durationSeconds/geo but had forgotten
PlatformScriptCount and RemediationScriptCount (added later), so Replace
zeroed them on each restart. Combined with the subsequent full index upsert
and the increment-merge only writing a counter when its increment > 0, this
undercounted scripts and drifted Sessions vs. SessionsIndex (e.g. get_session
platformScriptCount=0 while the index-backed list showed 3).

Preserve both counters from the existing row, mirroring the EventCount path.
Add StoreSessionReregistrationPreserveTests covering preservation on
re-registration and zero-default on first registration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:35:05 +02:00
Oliver Kieselbach 6283936805 changed default pageSize for get_api_usage 2026-06-06 16:51:37 +02:00
Oliver Kieselbach 5ddbf4f4aa fix(mcp): surface named entities in search_events (fetch starvation + scoring)
Cross-session search_events buried the named entity behind generic failure
keywords: "BitLocker encryption failed" returned only enrollment_failed,
"app download stuck timeout" only hello_wait_timeout.

Root cause (verified live): candidate-walk starvation. prioritizeFailureTypes
walked high-volume generic-failure types first, draining the page/wall-clock
budget before the specific type selected by the rare keyword (bitlocker_status,
app_download_started) was ever fetched. Scoring then buried it even once fetched.

Fix threads one bounded keyword-specificity signal through fetch + scoring:
- computeKeywordSpecificity + orderBySelectivity: walk the rarest-keyword type
  first so the named entity is not starved; failure-priority kept as the
  within-selectivity tie-break.
- scoreEvent: de-dup intra-query synonyms (stuck=timeout count once) and add a
  specificity-gated entity boost (>=1.5 failure boost, gated to rare non-problem
  keywords so high-volume benign types cannot flood problem queries). New
  keywordSpecificity param is optional -> existing callers/tests unaffected.
- entityRecallHint: name the entity event type in the truncated recallNote.

MCP-server-only; no backend/API/ConfigVersion/Table change. 215 backend-mcp
tests green (74 in tools-search incl. a fake-PageFetcher starvation repro).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:41:44 +02:00
Oliver Kieselbach bdab377a07 fix(mcp): paginate get_api_usage global per-record breakdown
The global per-record mode (no userId, no daily) fetched the full
mcp-usage record set with no server-side cursor — thousands of rows /
~182KB — overflowing the response cap so output spilled to a file. It
was the only mode of the tool without a way to bound the response.

Mirror the get_geographic_sessions client-side pager (usage-offset:N
cursor) instead of imposing a silent default window, preserving the
server's "results are never silently truncated" contract: the caller
follows nextLink and every row stays reachable. New paginateUsage helper
slices whichever array the payload carries (records / summaries) and
echoes all other top-level fields. Adds pageSize (default 200) +
continuation params, bumps the cap to adminStream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:37:11 +02:00
Oliver Kieselbach 9855860f1c feat(mcp): interpolate {{token}} rule-template placeholders in analysis output
MCP-side mirror of the web interpolateRuleTemplate: resolve
{{reason}}/{{appName}}/{{errorCode}}/... from each rule result's
matchedConditions before get_session_details and get_session_summary
ship the explanation + remediation text, so MCP tool output never leaks
raw {{...}} placeholders. Resolution order is kept in lock-step with the
web copy (byField -> whitelisted auto-field -> signal name).

Also rewords the ANALYZE-ENRL-001 footnote to explain that a remaining
literal {{...}} means the field simply was not recorded for this session
(version 1.1.0 -> 1.1.1; compiled dist and the rule-version assertion in
RuleEngineDetectionFailureTests updated to match).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:40:09 +02:00
Oliver Kieselbach 908fb8e7ac fix(metrics): clamp duration aggregates, reconcile status buckets, guard slowest-apps sample size
Three data-hygiene fixes for the MCP metric tools, all driven by
incomplete/abandoned sessions distorting aggregates:

- get_usage_metrics: clamp each session duration to the shared 7-day
  ceiling before avg/median/p95/p99, so stuck non-terminal sessions
  (unclamped now-StartedAt wall-clock) no longer push p99 into weeks.
  Surface SampleCount + ClampedSessionCount so the skew stays visible.
- get_metrics: tally Pending/Stalled into their own buckets and route
  any other status into `other`, so the per-tenant buckets reconcile to
  total by construction (closes the silent 214/134 gap). Extracted the
  fold into SessionStatusBuckets.
- slowestApps: require >=3 successful installs via the shared
  MetricsMath.SelectSlowestApps helper, so a single N=1 (often
  unfinished, legacy pre-clamp) install cannot dominate the ranking;
  also de-dupes the copy-pasted block across the tenant + global
  app-metrics functions.

Adds xUnit regression cover for all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:39:49 +02:00
Oliver Kieselbach 841f1ae04c feat(mcp): add action/performedBy/entityType/entityId filters to get_audit_logs
The get_audit_logs MCP tool could only narrow by tenant + date window, even
though the audit entity already stores Action/PerformedBy/EntityType/EntityId.
Add exact-match server-side filtering across all three layers so a filtered
query never falls back to in-memory scanning.

- Shared: AuditLogQueryFilters carrier + extended IMaintenanceRepository
- Storage: shared AppendAuditFieldFilters (OData-escaped eq clauses) in both
  the tenant and global fan-out filter builders; threaded through page +
  legacy methods
- Functions: new shared AuditLogFilterRequest parser folds filters into the
  pagination fingerprint AND echoes them on nextLink (cross-filter token
  replay rejected, follow-up pages round-trip), identical for tenant + global
- MCP: four optional params on get_audit_logs + pass-through
- Tests: AuditLogFieldFilterTests (eq clauses, escaping, coexistence with
  performer suppression + deletion exclusion, extras ordering)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:36:39 +02:00
Oliver Kieselbach b332631d53 @
refactor(backend): extract shared MetricsMath.Percentile helper

CalculatePercentile was duplicated verbatim across four services
(UsageMetricsService, SlaMetricsService, SlaBreachEvaluationService,
MaintenanceService.Aggregation) with only cosmetic differences. Hoist
the nearest-rank percentile into Helpers/MetricsMath and route all call
sites through it. No behavior change; callers already pre-sort.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-06 10:48:57 +02:00
Oliver Kieselbach 1e0b61bb8a fix(sla): unify consecutive-failure threshold floor across raise/resolve paths
The effective consecutive-failure threshold was derived two different ways:
the timer path used Math.Max(cfg, 2) while the inline path used (< 2 ? 5).
For a sub-minimum config value (0/1) this desynced the raise window (inline,
5) from the resolve window (timer, 2), so a breach requiring 5-in-a-row could
be resolved as soon as the top 2 weren't both failed.

Introduce a single canonical EffectiveConsecutiveFailureThreshold helper with
(< 2 ? 5) semantics (5 = the default used by model, DB read, and web UI) and
use it at all three sites. Extract BuildInlineService/FailedPage test helpers
and add a sub-minimum-threshold fallback test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:34:18 +02:00
Oliver Kieselbach 7626c06303 fix(agent-v2): enforce real hard timeout in StallProbeCollector source scans
RunWithTimeout ran each source synchronously on the long-lived
IdleCheckCallback worker and only measured elapsed time afterward, so a
wedged EventLogReader/registry/file read would block that worker forever
- despite the XML docs, the per-source timeout constants, and the inline
comment all promising a hard cap.

Each source now runs on a dedicated IsBackground thread joined with its
timeout. On timeout the worker is abandoned (read-only, dies with the
process) and the source marked failed; remaining sources still run. To
avoid an orphan racing the main thread's list reads, scans write into an
isolated scratch ProbeResult, merged into the live result only on
in-time, successful completion. Cross-source caps (MaxRawSamples,
MaxActiveInstalls) extracted into constants and enforced during merge.

V2.Core suite 1189/1189 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:16:52 +02:00
Oliver Kieselbach e4d9586bda fix(vuln): flush negative auto-resolve cache on mappings reload
ResetAutoResolveFailedCache() was documented as "called when seed/community
mappings are reloaded" but had no callers, so the 7-day NVD-miss cache
survived every reseed. Software that previously missed stayed blacklisted
against the warm Function instance and never got a fresh resolution attempt
against the reloaded mappings.

Fold the negative-cache flush into ResetMappingsCache() so all 7 reload
sites (reseed seed/community, save/delete/bulk-auto-resolve custom mappings)
clear it — no call site can forget it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:16:14 +02:00
Oliver Kieselbach 9e3a2e3708 fix(backend): bound SignalR sends + skip degenerate Azure Monitor range
Two hardening fixes surfaced by 48h backend log review:

- AzureMonitorMetricsReader.GetTotalAsync: skip the round-trip when the query range is shorter than 1 minute. The SignalR-quota watcher and the health check both anchor the start of the range to start-of-day, so a call landing on the 00:00 UTC boundary sent from==to and absorbed a daily 400 BadRequest.

- SignalRNotificationService: cap every imperative send (hub-context resolution + SendCoreAsync) at 5s via CancellationTokenSource. Without it a slow/unreachable SignalR endpoint blocks for the SDK HttpClient default of 100s, pinning the calling queue-worker/Task.Run thread. These pushes are observability nudges, never correctness, so fail fast and swallow as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:57:18 +02:00
Oliver Kieselbach 55f5fb883a fix(audit): make "excl. deletions" a server-side filter that fills the page
The "All (excl. deletions)" audit view filtered deletions client-side AFTER a
fixed 20-row backend page. During/after a cleanup sweep the table fills with
deletion_started/deletion_completed rows (written with the operator UPN, so the
existing System.Maintenance exclusion misses them); a 20-row page came back
all-deletions and the client stripped it to an empty list while real entries
sat on later pages.

Push the exclusion into the OData query (mirroring the System.Maintenance
clause) and back-fill the page: a server-side `Action ne ...` exclusion can make
a single Azure page return far fewer than pageSize matches, so follow the opaque
continuation until pageSize matches or the window is exhausted (accumulate whole
Azure pages to keep the token gap-free; cap at 20 round-trips). The flag is
bound into the continuation fingerprint scope (audit:tenant:nodel) so a token
cannot be replayed across views. Threaded through interface -> repo -> storage
with a defaulted param; global GA fan-out path covered too.

Frontend sends excludeDeletions=true when the filter is DEFAULT and refetches
page 1 on toggle.

Tests: 2172/2172 backend green + 4 new AuditLogDeletionExclusionTests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:33:34 +02:00
Oliver Kieselbach 39ffa93ee7 fix(agent-v2): emit non-failure subcategory_state_change transitions
DetectSubcategoryTransitions silently dropped every non-failure, non-completion,
non-noise subcategory transition (e.g. succeeded -> in_progress retry, failed ->
in_progress recovery, in_progress -> notStarted reset) due to a dead inner
'if (isFailure)' guard that is always false after the outer failure/completion
branches. The esp_provisioning_status event downgraded to a plain progress update
without a subcategory_state_change changeType. Record those as non-failure
transitions so they surface; add regression coverage.
2026-06-05 20:54:54 +02:00