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>
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>
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>
- 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>
/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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
@
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>
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>
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>
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>
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>
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.
Audit findings #4 (SessionSummary mapper x3) and #3 dual-write Phase 1.
- Collapse the two ~99%-identical SessionSummary mappers into one core mapper
taking the resolved sessionId (index vs primary differ only in that source).
- BuildSessionIndexEntity is now the single SessionsIndex projection manifest and
a SUPERSET of every field the merge call sites write, so a StartedAt-shift full
upsert can no longer drop a merged field (the recurring drift class, cf ab90423b).
- Fix two real bugs surfaced while grounding the design: ImeAgentVersion was never
written to the index although search/MCP push an OData filter against it (filter
matched nothing); UpdateSessionImeAgentVersionAsync now mirrors it. AdminMarkedAction
(dashboard list badge) was merged but missing from the projection -> now included.
- SyncSessionIndexAsync funnels the upsert-vs-merge + IndexRowKey-resolution decision
that was duplicated across the 3 StartedAt-shift branch sites.
- Correct the DeletionState doc-comment (it is primary-only, not index-surfaced).
The SessionsIndex is a full denormalized mirror of Sessions (different RowKey sort)
serving list/search/stats/raw directly; lean-drop would break /api/raw/sessions.
Backend build 0 warnings; full xUnit suite 2172/2172 (incl. new index-projection +
ImeAgentVersion-mirror tests). BuildSessionIndexEntity made internal for the test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dedupe the copy-pasted Global-Admin scope apparatus across 8 pages (audit finding
#4). New shared primitives: useTenantList (the byte-identical tenant fetch+sort),
useGlobalAdminScope (override variant), useAggregatedAdminScope (aggregated 'All
tenants' variant with URL seed), plus GlobalAdminBanner + TenantScopeSelector.
Migrated: gather-rules, analyze-rules, sla, usage-metrics (override) and
fleet-health, geographic-performance, apps, apps/[appName] (aggregated, incl.
?global=1 deep-link). Behaviour preserved (endpoints, aggregated view, dynamic
banner) after reading every page in full to prove the variant taxonomy.
Review fixes: useAggregatedAdminScope re-defaults selection on GA-mode toggle via
render-time state adjustment (no committed render exposes a stale scope -> no
aggregated/own-tenant fetch race) and exposes a stable scopeKey the page fetch
effects depend on; TenantScopeSelector gains an opt-in 'themed' prop so the SLA
dark header keeps its dark-mode classes without affecting the light-only pages.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
UpdateSessionStatusAsync force-write fallback (after ETag retries are
exhausted) mirrored most fields but silently dropped FailureSource and
AdminMarkedAction. Under ETag contention at the terminal transition
(e.g. WhiteGlove drain racing an admin Mark Succeeded/Failed), this
neutralized the admin-driven AdminAction trigger sent to agents and
lost the failure attribution. Mirror both fields analogous to the
existing FailureSnapshotJson mirror.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Eliminates the copy-paste queue scaffolding flagged as finding D2: identical
Managed-Identity/connection-string QueueClient bootstrap hand-rolled across 13
sites, plus a poll-loop/poison/safe-delete skeleton duplicated in 5 workers.
- QueueClientFactory: resolves the MI/connstring model once; consumed by all 5
producers, the offboarding enqueuer, the backup producer and the poison probe.
- QueuePollingWorkerBase: shared bootstrap + poll loop + poison-move + safe-delete
+ queue-create (ExecuteAsync/ProcessOneAsync sealed to prevent drift).
- QueuePollingWorker<TEnvelope>: deserialize -> validate -> handler flow with an
opt-in in-flight heartbeat.
- 3 vanilla workers (Index/Analyze/Vuln) collapse to ctor + HandleAsync.
- SessionDeletion/Offboarding use BatchSize/UseHeartbeat/ShouldPauseAsync +
Before/AfterPoisonMoveAsync hooks (kill-switch, CAS state transition, OpsEvent
enrichment, Failed-state-before-poison all preserved bit-for-bit).
- Backup derives from the non-generic base and overrides ProcessOneAsync (its
lease + CAS state machine is intentionally bespoke).
Producers get the factory only -- no shared base, their business logic is
genuinely divergent. Test seams unchanged; the static SessionDeletionWorker.
MaxDequeueCount reference moves to QueuePollingWorkerBase.DefaultMaxDequeueCount.
2168/2168 backend tests green, 0 warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Intune health-script detections (scriptType=remediation, scriptPart=detection/
post-detection) that report complianceResult=True, exitCode=0 were emitted as
script_failed/Severity=Error whenever the detection PowerShell leaked a benign
probe line to stderr (e.g. Get-ChildItem path-not-found). On green sessions this
inflated errorCount (8 on a successful session) and ranked the rows #1 in
search_events, diluting usage.
Root cause: the hasStderr check short-circuited the failure decision before the
existing isHealthComplianceReport exemption. Exit-code was already exempt for
detection phases; stderr was not. The flawed rule was hand-duplicated in the
agent emit and the web reducer, and propagated into MCP errorCount + ranking.
Fix (verdict is authoritative for detection/post-detection phases):
- Agent ImeLogTrackerAdapter.EmitScriptCompleted: fold hasStderr under
!isHealthComplianceReport. Platform scripts, the remediation phase itself, and
explicit result="Failed" still route to script_failed/Error.
- Web reducer scriptExecutions.ts: same exemption (kept in sync). Non-compliant
detections render amber via isNonCompliantReport, not red.
- MCP read-side guard isBenignHealthDetectionReport() in tools/shared.ts, applied
in search.ts severityIntentFactor (damp 0.6, no failure boost) and sessions.ts
errorCount/keyEvents. Non-destructive; de-pollutes already-stored rows before
the agent build rolls out per-enrollment.
Tests: Agent 70 pass (retargeted enrichment test + new compliant-stderr
regression), MCP search 61 pass (helper + ranker damp), Web reducer 66 pass
(flipped detection-stderr case). MCP + Web tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The maintenance sweep graduated a stalled session to terminal Failed via the repo
(MaintenanceService Stage 2, SessionTimeoutHours=5h default) but wrote no event into
the session stream and never enqueued analyze. The analyze pipeline fires on stream
events, so a server-timed-out session analyzed to totalIssues:0 — unlike the agent
max-lifetime path, which emits a real enrollment_failed and is correctly analyzed.
- New server-authored event type Constants.EventTypes.SessionTimeout = "session_timeout"
(+ MCP termination_events catalog; drift test green).
- MaintenanceService Stage 2 now captures the UpdateSessionStatusAsync result and, only
on the real first transition (idempotent — the sweep never re-collects a Failed
session), emits a session_timeout event (new pure static BuildSessionTimeoutEvent:
Sequence = max(session events)+1 so it sorts last, Phase=Unknown, Source=System.
Maintenance, Severity=Error) and enqueues auto-analyze with ReasonEnrollmentFailed.
Best-effort try/catch — never blocks the timeout transition. Producer injected.
- RuleEngine: EvaluatePrecondition gains a pure event-type-presence gate (no-dataField
exists/not_exists) — additive (no rule used dataField-less preconditions), enables a
session-level suppression with no shared join field.
- New rule ANALYZE-ENRL-002 "Session Timed Out": fires on session_timeout, suppressed
when enrollment_complete OR enrollment_failed exists (5h<6h late-completion race +
no double-report with ANALYZE-ENRL-001). dist regenerated via combine.js.
- Web: session_timeout treated as terminal for stale-status refetch (the sweep sends
no SignalR delta).
Tests: +3 RuleEngine pure-type precondition, SessionTimeoutRuleTests (real shipped rule
fire/suppress), MaintenanceServiceSessionTimeoutEventTests (shape/sequence). Backend
2168 pass, web 434 pass, MCP 177 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
search_knowledge missed rules naming an HRESULT/Win32 code (e.g. 0x87D1041C)
because opaque hex tokens embed poorly — the semantic cosine (TPM rule scored
0.259) fell below the default minScore and the rule was dropped even though it
named the code verbatim.
- Add extractErrorCodeNeedles(): pull 0x-prefixed and bare HRESULT-shaped hex
out of the query (bare hex requires an a-f letter, so plain decimals never
trigger it); needles are the lowercased hex core so a scan matches whether the
query or the doc wrote the 0x prefix.
- Add SearchProvider.lexicalMatch + shared scanLexical(): literal case-insensitive
substring scan over indexed docs, scored 1.0; implemented by both providers.
- search_knowledge folds literal hits in (exempt from minScore, flagged
matchType:"error-code") and reports errorCodeFallback in the envelope.
- Lower default minScore 0.3 -> 0.25 so a relevant-but-marginal NL hit survives.
Tests: +extractErrorCodeNeedles + scanLexical units (MCP 177 pass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MCP server health probe previously sat inside PerformAllChecksAsync's
Task.WhenAll, so /health/detailed blocked on the MCP timeout before returning
any card, and a scaled-to-zero cold start was reported as an immediate warning.
Backend:
- Move the MCP probe out of the blocking all-checks batch onto its own
GET /api/health/mcp endpoint (AuthenticatedUser; visible to all, not GA-only).
- CheckMcpServerAsync now uses a wake-capable timeout (McpServerHealthTimeoutSeconds,
default 30s). Azure Container Apps holds the request during cold-start activation,
so the probe doubles as the wake signal: a successful wake -> healthy ("woke from
idle" when >2.5s), only a failed wake -> warning, a reachable-but-erroring server
(non-2xx) -> unhealthy.
- Register health/mcp in EndpointAccessPolicyCatalog (fail-closed).
Frontend:
- Fetch the MCP check on its own track, concurrently with and independent of the
fast checks. The card renders a blue "checking..." state immediately and updates
in place when the probe resolves; the other cards never wait on it.
- The MCP result folds into the overall banner/counts only once resolved, so a slow
cold start no longer flips the page to "Warnings detected".
Test: warning-path assertion updated for the new wording (10 HealthCheckService
tests green; 61 policy/endpoint tests green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cross-session index walk fetches /api/raw/events, which returns table
rows verbatim in PascalCase (EventType/Message/Source, Severity & Phase as
ints, DataJson as a JSON string, SessionId + PartitionKey). The scorer and
session extraction in search.ts read enriched camelCase fields, so every
fetched event scored 0 (eventsMatched:0) and yielded no _sessionId
(sessionsSearchedCount:0) despite thousands eventsFetched. Single-session
worked because it hits the enriched /api/sessions/{id}/events endpoint.
Add a defensive normalizeRawEvent boundary mapper (PascalCase-first,
camelCase fallback) applied in fetchEventsViaIndex: Severity int -> enum
name, DataJson string -> object, sessionId from SessionId or PartitionKey.
Widen EventEntry.phase to string|number; type raw rows as RawEventRow.
The former camelCase-only fixtures masked the defect; add 9 regression
tests exercising the real PascalCase shape end-to-end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "MCP Server" check to the System Health dashboard, visible to all
authenticated users (not GA-only). Probes the MCP server's public /health
endpoint (in front of the /mcp access guard, so auth-free), reports
reachability + latency, and surfaces the deployed MCP build version.
- MCP /health now returns { status, version } (SERVER_VERSION from package.json;
same value already in the MCP handshake, leaks nothing new).
- New Constants.McpServerBaseUrl (prod singleton, like ApiBaseUrl/AgentBlobBaseUrl),
overridable via McpServerUrl app setting.
- Cold-start handling: the Container App scales to zero and only serves /health
after boot, so timeout/connection failure is classified as a warning (with a
re-check hint) rather than a false "unhealthy" outage; the probe itself wakes
the scaler. A real non-2xx status is unhealthy.
- Server URL is never exposed in the card message or details.
- 4 service-level tests (healthy+version, healthy-no-version, non-2xx, timeout).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get_platform_metrics: expose maxSessions (->&limit=), echo sessionLimit +
truncated. Backend already honored days+limit; the tool only sent days, so
results were silently capped at the newest 100 sessions.
get_geographic_sessions: backend (tenant+global) now matches structured
country/region/city against actual Geo* fields via FilterSessionsByFields
(case-insensitive, optional narrowing); 400 relaxed to require locationKey OR
country. MCP forwards structured filters and infers groupBy on the raw
locationKey path so country/region keys resolve (previously matched nothing).
locationKey path retained for the web UI.
get_geographic_metrics: blank Region/City for coarser groupings so a country
row is not mislabeled with an arbitrary sample city; keep Loc for map pins.
Tests: +GeographicDrilldownFilterTests (backend), +geo-platform-helpers (MCP).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GetAllBlockedDevices never parsed the query string and always called the
cross-tenant GetAllBlockedDevicesAsync(), so a supplied tenantId was
silently ignored (cross-tenant widening) and a malformed tenantId was
not rejected. Parse tenantId, scope to GetBlockedDevicesAsync() when a
valid GUID is given, and return 400 for a non-GUID value. Classification
extracted to a testable ParseTenantFilter helper with unit coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "raw" tools were oversold: query_raw_sessions returned curated
SessionSummary DTOs and its fields= ran through a hard-coded whitelist
(ProjectFields) that silently dropped ~20 real SessionsIndex columns
(OsEdition, ImeAgentVersion, GeoRegion/City/Loc, FailureSource,
CurrentPhaseDetail, LastEventAt, ResumedAt, StalledAt, DeletionState, ...),
and it accepted fewer filters than search_sessions -> a strict subset.
The raw endpoints are the deliberate "fall back to the real stored bytes"
escape hatch; the enriched views already live on search_sessions/get_session
and search_events/get_session_events. So both raw tools now return the
LITERAL stored rows: every column verbatim, PascalCase, incl.
PartitionKey/RowKey/Timestamp, with fields= a pure pass-through (narrows,
never silently drops a real column; always keeps PartitionKey/RowKey).
- New RawEntityProjection helper: ToDictionary(TableEntity) + Project(rows, fields)
- 3 new ISessionRepository methods returning IReadOnlyDictionary<string,object?>
(no Azure types leak into Shared); reuse BuildSearchScanFilter +
MatchesScanClientFilters for the predicate, emit the raw entity
- query_raw_sessions: delete ProjectFields; add filter parity (manufacturer,
model, enrollmentType, deviceName, osBuild, geoCountry, isPreProvisioned,
isHybridJoin) -- already covered by the continuation fingerprint
- query_raw_events: literal rows, drop ErrorCodeEnricher + EventFieldProjection
(DataJson raw string, Severity/Phase raw ints); severity/source/date filters
reimplemented on raw dicts preserving prior match semantics
- EventFieldProjection kept for the enriched get_session_events; doc-comment fixed
- MCP admin.ts: rewrite both tool descriptions, add the new query_raw_sessions
inputs, PascalCase pass-through fields= docs; prompts.ts hint updated
Note: contract change for these two tools (camelCase curated keys -> PascalCase
literal columns). deviceProperties join stays out of raw (owned by
search_sessions); query_table remains the generic literal escape hatch.
Tests: new RawEntityProjectionTests (8); full backend suite 2134 passed;
MCP tsc clean + vitest 149 passed/41 skipped; tools-raw.test.ts updated to
the PascalCase contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MCP clients render tools in the order the server lists them, and the SDK
lists them in registration order. Tools are registered across three
thematic modules (sessions/search/admin), so the raw catalog interleaves
get_* / list_* / query_* / search_* and looks random to the user.
Add sortToolCatalog() at the end of registerTools(): it re-keys the SDK
registry alphabetically once, after all modules have registered. A plain
name sort groups the catalog by verb prefix, and any tool added to any
module in the future is sorted in automatically. Sorting only reorders
the keys; handler lookup is by name, so behaviour is unchanged.
Add a backend-free unit test locking in the order for both roles, the
prefix grouping, and that GA-only tools stay hidden from tenant users.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two contract gaps in the raw-event MCP readers, surfaced by an MCP-server review:
1. No fields= projection. query_raw_events/get_session_events returned full
EnrollmentEvent rows including the heavy Data dict (a single app_install_failed
event can be tens of KB), so lean counting/aggregation was impossible. Add a
shared EventFieldProjection helper (mirrors query_raw_sessions fields=) wired
into both endpoints; Data is dropped unless explicitly requested, and
ErrorCodeEnricher is skipped when Data will be projected away.
2. tenantId contract inconsistency. query_raw_events hard-rejected a sessionId
query without tenantId, while get_session_events auto-resolves it for GA. Now
query_raw_events resolves tenantId via FindSessionTenantIdAsync on the global
scope only (tenant scope keeps its JWT-bound tenantId). The resolved tenantId
is NOT echoed into nextLink, keeping the filterTenantId=null continuation
fingerprint stable across pages.
Adds EventFieldProjectionTests; updates MCP tool schemas/descriptions and the
docs row. Backend 2127/2127, MCP 145 pass, web tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live validation showed "app install timeout" still returned only benign
app_install_started, even at depth=deep. Root cause: the per-type fan-out is
sequential and budget-bounded, so a high-volume benign type (app_install_started
— every app logs "Installing") is walked first and exhausts the wall-clock
budget before the rarer app_install_failed is fetched. The real signal lives in
the failure event's MESSAGE ("[Win32App] Installation is timeout"), not its type
name, so the discriminating word never lifts the failure type's walk order.
Proof: "installation timeout failed" (where "failed" matches the type name, so
app_install_failed ranks #1 and is walked first) surfaces those exact events @1.0.
- prioritizeFailureTypes: when queryHasProblemIntent, stable-partition candidate
event types so failure-signal names (fail/error/timeout/denied/crash/…) are
walked first — they survive the per-type cap AND are fetched before benign
high-volume types drain the budget. (Pattern excludes stall/stuck: "stall" is a
substring of "in[stall]" — same mid-word trap, caught by a unit test.)
- Bigger budgets per the "wait a bit longer for good results, don't abort early"
goal: fast 3->5 pages / 12s->20s, deep 20->30 pages / 40s->50s (kept under the
MCP client's ~60s tool-call timeout).
Tests: prioritizeFailureTypes unit tests (reorder + no-op); full MCP suite 145
passed. Requires rebuild+redeploy of the MCP Container App.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Several MCP search tools could return count:0 together with a nextLink on the
first page while real matches sat on later pages (in-memory post-filters run
after a single Azure-table page fetch). Callers stopped at the empty page and
wrongly concluded "no results". Invalid eventType / deviceProperties keys
produced the same count:0, so a bad filter was indistinguishable from a miss.
- Add scanUntilMatch (client.ts): scans forward while a page is empty until it
finds matches, drains, or hits a bounded budget (maxPages/wallClockMs). Emits
an honest moreToScan + recallNote at the budget boundary and always adds
scannedPages. The three end states are now distinguishable: matches found
(nextLink = more pages), truly empty (count:0, no nextLink), budget-bounded
(count:0 + nextLink + moreToScan). Pages map 1:1 to backend boundaries.
- Wire into search_sessions, get_session_events, query_raw_events. Leave
search_sessions_by_event / query_raw_sessions on single-fetch (server-side
OData filters can't produce empty-but-continuable pages).
- Add catalog validators (resource-catalog.ts): assertKnownEventType against the
drift-tested ALL_EVENT_TYPES, and assertKnownDevicePropertyKeys (prefix-only,
so valid-but-uncatalogued sub-properties aren't blocked).
- Update tool descriptions; drop the now-wrong "keep scanning yourself" comment.
- Tests: auto-exhaust.test.ts (13 cases) covering scan/drain/budget paths and
all validators.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live verification of "app install timeout" exposed a false positive in the
synonym layer: the timeout-group synonym "stall" is a substring of "in[stall]"
(and "hang" of "c[hang]e"), so prefixAwareMatch's substring check made a
"timeout" query spuriously match every app_install_* event — reproducing the
exact symptom the original finding reported (benign "Installing" infos on top).
- Synonyms now match at word granularity (whole word or a >= MIN_PREFIX_LEN
shared word-prefix) via synonymMatchesWord, never as a mid-word substring.
The literal keyword keeps its existing loose prefix-aware matching.
- Useful equivalences (reboot<->restart, stuck<->timeout) are unaffected;
verified live: "machine restarted unexpectedly" -> system_reboot_detected and
"hello provisioning failed" -> hello_provisioning_failed both still resolve.
Added regression test: "timeout" must NOT match app_install_started or
network_state_change. tools-search.test.ts 34/34; full MCP suite 130 passed.
Requires rebuild+redeploy of the MCP Container App.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "hybrid semantic" event search selected candidate event types via vector
embeddings, but the per-event scorer (scoreEvent) was a lexical AND-gate that
returned null for any event with zero literal keyword overlap — silently
discarding the very events the semantic layer just fetched. So intent-only
queries ("machine restarted unexpectedly" -> system_reboot_detected) returned
0 results despite the right types being scanned.
- scoreEvent: add a cosine-scaled semantic floor for events whose type was
selected semantically, so they survive and rank even with no keyword match.
Pure-keyword behavior is unchanged when no semantic-score map is passed
(single-session / legacy paths).
- selectEventTypeCandidates returns { candidates, semanticTypeScores }; the
map is threaded through fetchSessionEvents into scoreEvent.
- Synonym-aware matching (reboot/restart, timeout/stall/stuck, hello/whfb,
download/transfer, install/setup) counted as one keyword (no normalization
dilution).
- Merge search_events_semantic + deep_search_events into one search_events
tool with depth=fast|deep (removed the byte-identical-twins problem).
- Honest descriptions + envelope: searchBackend="hybrid-keyword-semantic",
depth, semanticOnlyCount, per-result semanticOnly flag. Updated index.ts
instructions, prompts.ts, admin.ts, sessions.ts, and the web docs.
Tests: tools-search.test.ts 33/33 (semantic-floor regression, synonyms,
cosine scaling, lexical>semantic-only). Full MCP suite 129 passed/39 skipped.
Requires rebuild+redeploy of the MCP Container App to take effect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Event search was keyword-only despite the "semantic" name. Make cross-session candidate
selection genuinely semantic by reusing the embedder already loaded for search_knowledge:
index the 130-entry event-type catalog into a second SearchProvider at startup (shares the
model singleton → ~no extra memory), then blend lexical + semantic candidates per query.
selectEventTypeCandidates: keyword/prefix matches first (precision), then the vector
catalog's closest types (recall) — so "app stuck downloading" reaches download_progress and
"disk encryption" reaches bitlocker_status with zero word overlap. Degrades cleanly to
keyword-only on the fuse backend, an unindexed provider, or any embed error.
Wired through index.ts → tools.ts → registerSearchTools → fetchSessionEvents (raw query +
eventTypeIndex). Tool descriptions updated to "hybrid" and the event-TYPE-driven caveat
softened (semantic mapping helps, but a concept with no matching type still needs a
sessionId). The deep content-text limit (Tier 2 / Azure AI Search) remains out of scope.
MCP 122/122, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>