mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21b8e054df | |||
| 249c20e15a | |||
| 4ebd60fb1e | |||
| 22093d0824 | |||
| dff1532be5 | |||
| 8608ee6272 | |||
| f762b932a2 | |||
| 173bfc40a9 | |||
| 609d9c47af | |||
| 5496d575c1 | |||
| 1c3d29f30b | |||
| bb4accfe96 | |||
| 0ec481b479 | |||
| 3ec716a3ec | |||
| b0bb7233e4 | |||
| 6758dc59e4 | |||
| ffd70e0383 | |||
| c18154d76d | |||
| f524473305 | |||
| 8fe420b902 | |||
| 2e8c2cdb2f | |||
| e04b75dd53 | |||
| d55b0ff864 | |||
| 123ba0e55e | |||
| c8175ff7b1 | |||
| 0efeb0f734 | |||
| 7b975d46b6 | |||
| 7680bdff7b | |||
| 93094653a2 | |||
| ba7d14b070 | |||
| 7f7330e9e5 | |||
| 82934a3719 | |||
| 372f0c7c98 | |||
| 8057b44e48 | |||
| 7842d121a0 | |||
| 4bc2435ec5 | |||
| 1bf37f6761 | |||
| 88da317bf4 | |||
| 1e6de47433 | |||
| 6de21e821a | |||
| 55c898bb9c | |||
| e637317bdb | |||
| 4eac7d042c | |||
| c7ed3ec27d | |||
| f311e8746b | |||
| e0b6ce83cf | |||
| 7f7b590db4 | |||
| 4c2c447a2e | |||
| 8a794a1b52 | |||
| 6c53dc33e7 | |||
| 801891d57a | |||
| 1f6efca95b | |||
| 9ec8644be2 | |||
| 9acd10e1dc | |||
| b382e5678e | |||
| 2fee50276e | |||
| 8bb02acc4a | |||
| fd7d3973e7 | |||
| fd2b5c2da2 | |||
| a44e37a7c2 | |||
| d8a252562d | |||
| a5d12ba1d0 | |||
| 530c978bf8 | |||
| 09898d7ba0 | |||
| 71eda93e8b | |||
| 1cfc32c9aa | |||
| 11339c9868 | |||
| 540f543834 | |||
| 088c3b0479 | |||
| 3224774752 | |||
| e7ea431ccf | |||
| 9e24b92fbf | |||
| 45d7c03ccf | |||
| 90012d9a66 | |||
| e8ef4f53ae |
+1
-1
Submodule .claude/commands updated: af1b8468c7...997df4b5ed
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: src/Web/autopilot-monitor-web/package-lock.json
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: src/McpServer/autopilot-monitor-mcp/package-lock.json
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
|
||||
- name: Regenerate
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
|
||||
- name: Install ajv-cli
|
||||
run: npm install -g ajv-cli ajv-formats
|
||||
|
||||
@@ -21,6 +21,11 @@ jobs:
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
# Dependabot PRs run without repo secrets, so the deploy token is absent.
|
||||
# Surface it as an env var so the deploy step can gate on it — secrets
|
||||
# cannot be referenced directly in an `if:` expression.
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -28,6 +33,9 @@ jobs:
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
- name: Build And Deploy
|
||||
id: builddeploy
|
||||
# Skip (green) instead of failing when the token is missing (Dependabot
|
||||
# PRs); real deploys on main and internal PRs still have it.
|
||||
if: env.DEPLOY_TOKEN != ''
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
env:
|
||||
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
|
||||
@@ -39,9 +47,6 @@ jobs:
|
||||
with:
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
|
||||
# Dependabot PRs run without repo secrets, so the deploy token is absent.
|
||||
# Skip (green) instead of failing the check; real deploys on main still have the token.
|
||||
skip_deploy_on_missing_secrets: true
|
||||
action: "upload"
|
||||
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
|
||||
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
|
||||
@@ -54,9 +59,12 @@ jobs:
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
name: Close Pull Request Job
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
steps:
|
||||
- name: Close Pull Request
|
||||
id: closepullrequest
|
||||
if: env.DEPLOY_TOKEN != ''
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
with:
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
|
||||
@@ -119,6 +119,19 @@ normal IME deployment flow. Spam containment, in order:
|
||||
payload; the event's own timestamp is the emission time, so the session's `StartedAt`
|
||||
(which the backend aligns to the earliest event) is not dragged backwards.
|
||||
|
||||
Presentation (2026-07-28, customer feedback — an unannounced wall of pre-agent Error
|
||||
events read like a live failure):
|
||||
|
||||
- The scan buffers the (capped) record set, first emits an **Info summary event**
|
||||
(`modern_deployment_log`, payload `backfillSummary: true` + `replayedCount` /
|
||||
`droppedOverCap` / `lookbackMinutes`) announcing the replay block, then replays the
|
||||
records **oldest-first** so the timeline sequence follows event-log chronology.
|
||||
- The web timeline (`EventTimeline.tsx`) badges `backfilled: true` events („⟲ Backfilled",
|
||||
slate, explanatory tooltip) and renders the original `timeCreated` as the row time with a
|
||||
"(reported …)" suffix; the expanded metadata block gains a `Recorded` line. Severity is
|
||||
deliberately **not** downgraded — unresolved pre-agent errors must still surface in the
|
||||
Error filter.
|
||||
|
||||
# Schema — Error codes (backend known-issue map)
|
||||
|
||||
Codes documented in the known-issues / troubleshooting-FAQ pages that appear in ESP/enrollment
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
type: Concept
|
||||
title: ESP Policy-Provider Stall Detection — the no-timeout Setup/Apps wait and the co-management Sidecar gap
|
||||
description: Why the ESP's Apps wait is keyed to the Intune-registered "Sidecar" provider by name — a foreign provider (ConfigMgr) with TrackingPoliciesCreated=1 does not satisfy it — plus the classic never-completes stall, and how the always-on two-arm esp_policy_provider_stalled tripwire (reason=provider_incomplete | sidecar_provider_missing, 15-min wall-clock dwell, StartupEventGate cross-restart dedup) surfaces both.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspPolicyProviderProbe.cs
|
||||
tags:
|
||||
- agent
|
||||
- esp
|
||||
- enrollment-status-tracking
|
||||
- co-management
|
||||
- stall-detection
|
||||
timestamp: 2026-07-24T00:00:00+02:00
|
||||
---
|
||||
|
||||
# ESP Policy-Provider Stall Detection
|
||||
|
||||
## Schema
|
||||
|
||||
The Enrollment Status Page decides when its "Apps" category may progress from
|
||||
"Identifying" using the **EnrollmentStatusTracking CSP**
|
||||
(<https://learn.microsoft.com/en-us/windows/client-management/mdm/enrollmentstatustracking-csp>),
|
||||
mirrored in the registry under
|
||||
`HKLM\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\{Device|<UserSID>}\…`.
|
||||
Two contracts matter:
|
||||
|
||||
1. **`Setup/Apps/PolicyProviders/<Name>`** — per the CSP doc: *"Existence of this node
|
||||
indicates to the ESP that it shouldn't show the tracking status message until the
|
||||
TrackingPoliciesCreated node has been set to true."* This wait has **no timeout**. A
|
||||
registered provider that never sets `TrackingPoliciesCreated=1` parks the ESP at
|
||||
"Apps (Identifying)" indefinitely — observed for days in the field. Exists in device
|
||||
scope (`Device`) and user scope (`S-<SID>`, appears at sign-in).
|
||||
2. **`DevicePreparation/PolicyProviders/<Name>/InstallationState`** — must reach 2
|
||||
(NotRequired) or 3 (Completed); 1=NotInstalled, 4=Error. Device scope only. The ESP
|
||||
applies a default 15-minute timeout here, so these stalls self-surface as ESP errors —
|
||||
the detector still records them for the diagnostic trail.
|
||||
|
||||
Known provider names: **`Sidecar` is the Intune Management Extension** (the CSP doc names
|
||||
"Intune's agents, such as SideCar"); **`ConfigMgr`** is the Configuration Manager client,
|
||||
registered during Autopilot-into-co-management — the troubleshooting section of
|
||||
<https://learn.microsoft.com/en-us/intune/configmgr/comanage/autopilot-enrollment> queries
|
||||
exactly these registry paths. The same document states that ESP policy providers **are not
|
||||
aware of each other** and that **pre-provisioning is unsupported** with Autopilot into
|
||||
co-management.
|
||||
|
||||
## The field case (issue #106)
|
||||
|
||||
A tenant pre-provisions devices and installs the ConfigMgr client as a blocking app.
|
||||
Occasionally the user-phase ESP reaches "Apps (Identifying)" and never leaves it — for
|
||||
hours to days. On those devices `Device\Setup\Apps\PolicyProviders` contains **only a
|
||||
`ConfigMgr` key (no `Sidecar`)** — and, per the customer's follow-up, **`TrackingPoliciesCreated=1`
|
||||
IS set under it**. The provider is "complete" by the CSP value contract, yet the ESP
|
||||
still waits. Manually renaming the key to `Sidecar` — which creates no tracking entries,
|
||||
only satisfies the name — unblocks the ESP instantly.
|
||||
|
||||
The naive CSP-doc reading ("any provider with TrackingPoliciesCreated=1 satisfies the
|
||||
wait") is therefore wrong for this case. What the evidence supports instead: provider
|
||||
nodes are registered **by the Intune service at enrollment**, and its ESP-policy WAP
|
||||
payload always registers `Sidecar` (ConfigMgr joins only via a co-management settings
|
||||
policy — see the payload documented by Niehaus). The ESP waits on the provider it
|
||||
expects **by name**; a foreign provider occupying the slot does not satisfy it,
|
||||
regardless of its completion values. On the affected devices the CM client (installed
|
||||
as a plain Win32 app, no co-management settings policy) evidently displaced the
|
||||
Sidecar node — consistent with Microsoft's warning that providers "aren't currently
|
||||
aware of others" and that pre-provisioning is unsupported with co-management. The exact
|
||||
CloudExperienceHost mechanism is not publicly documented; the registry signature —
|
||||
setupApps providers present, `Sidecar` absent — is deterministic either way.
|
||||
|
||||
## Detection
|
||||
|
||||
`EspPolicyProviderProbe` (fail-soft, static, `ScopedOverride` test seam — structural clone
|
||||
of `EspTrackingInfoProbe`) enumerates all providers in both kinds and scopes.
|
||||
`EspPolicyProviderStallDetector` evaluates a pure transition function on a 60-second tick
|
||||
from the always-on `EspPolicyProviderStallHost` — **two arms**, same 15-minute wall-clock
|
||||
dwell, one shared event type discriminated by `reason`:
|
||||
|
||||
* **Arm 1, `reason=provider_incomplete`** — a provider **continuously incomplete for
|
||||
≥ 15 minutes**. The dwell clock starts at first incomplete observation and resets on
|
||||
completion or disappearance. Missing values parse as incomplete
|
||||
(fail-toward-observation). Catches e.g. Sidecar itself never reporting.
|
||||
* **Arm 2, `reason=sidecar_provider_missing`** — **≥ 1 `Setup\Apps` provider registered
|
||||
but none named `Sidecar`**, continuously for ≥ 15 minutes. Deliberately ignores
|
||||
provider completeness (the field case had `TrackingPoliciesCreated=1`). Sidecar
|
||||
registering later — the legitimate startup ordering — clears the condition and resets
|
||||
the clock. DevicePreparation-only snapshots don't activate it.
|
||||
* When both arms cross in the same round, ONE event is emitted and
|
||||
`sidecar_provider_missing` wins as `reason` (it explains the hang regardless of the
|
||||
foreign provider's state).
|
||||
* Missing root key / empty provider list = normal early enrollment: clears all dwell
|
||||
state (both arms), never fires.
|
||||
* One `esp_policy_provider_stalled` **Warning** per newly stalled condition set
|
||||
(`ImmediateUpload`), one-shot per condition in-process, cross-restart deduped via a
|
||||
`StartupEventGate` fingerprint over the sorted currently-active fired conditions
|
||||
(provider keys + a `sidecarMissing` sentinel) — a restart into the same stall stays
|
||||
silent, a new condition joining re-reports.
|
||||
* Payload: `reason`, `providers[]` (full table: name, scope, kind, raw values, complete
|
||||
flag), `stalledProviders[]` with `stalledForMinutes`, `sidecarMissingForMinutes`
|
||||
(arm 2 only), `sidecarRegistered`, `dwellMinutes`.
|
||||
* **Purely observational**: emitted via `InformationalEventPost` (dispatch-guard exempt
|
||||
pass-through) — never touches decision state. No config toggle, no ConfigVersion bump
|
||||
(precedent: `disk_space_low`).
|
||||
|
||||
Deliberately **not** built on `StallProbeCollector` (its idle clock resets on any session
|
||||
activity — a provider stall coexists with a chatty session) and not on the periodic
|
||||
collectors (idle-stopped after 15 minutes — exactly the dormant window the dwell must
|
||||
observe).
|
||||
|
||||
## Guardrail companion
|
||||
|
||||
`rules/guardrails.json` allow-lists `SOFTWARE\Microsoft\Windows\Autopilot` (category
|
||||
"Autopilot / OOBE / Setup") so tenants can build their own gather rules on the subtree —
|
||||
e.g. a `not_exists` registry rule on the `Sidecar` provider key. The subtree carries
|
||||
deployment state only (provider registrations, tracking lists, app IDs, per-user SIDs);
|
||||
sensitivity is at or below existing entries. Enforcement is agent-side (embedded
|
||||
resource), so both the detector and the guardrail change ship with the agent release.
|
||||
|
||||
## Examples
|
||||
|
||||
Customer-case event (abbreviated) — note the provider is "complete" by CSP values, the
|
||||
hang is carried entirely by `reason=sidecar_provider_missing`:
|
||||
|
||||
```json
|
||||
{
|
||||
"eventType": "esp_policy_provider_stalled",
|
||||
"severity": "Warning",
|
||||
"data": {
|
||||
"reason": "sidecar_provider_missing",
|
||||
"dwellMinutes": 15,
|
||||
"sidecarRegistered": false,
|
||||
"sidecarMissingForMinutes": 15.0,
|
||||
"providers": [
|
||||
{ "name": "ConfigMgr", "scope": "device", "kind": "setupApps",
|
||||
"trackingPoliciesCreated": 1, "installationState": null, "complete": true }
|
||||
],
|
||||
"stalledProviders": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`reason=sidecar_provider_missing` is the co-management signature;
|
||||
`reason=provider_incomplete` with `sidecarRegistered=true` covers a stalled Sidecar or
|
||||
third-party provider — the generalized contract violation.
|
||||
|
||||
# Citations
|
||||
|
||||
* EnrollmentStatusTracking CSP — <https://learn.microsoft.com/en-us/windows/client-management/mdm/enrollmentstatustracking-csp>
|
||||
* Windows Autopilot into co-management (requirements, provider-conflict warning, troubleshooting registry paths) — <https://learn.microsoft.com/en-us/intune/configmgr/comanage/autopilot-enrollment>
|
||||
* Troubleshoot the ESP (models app tracking exclusively via the `Sidecar` subkey) — <https://learn.microsoft.com/en-us/troubleshoot/mem/intune/device-enrollment/understand-troubleshoot-esp>
|
||||
* Niehaus: the Intune ESP-policy WAP payload registering the provider nodes (`Sidecar` always, `ConfigMgr` only with a co-management settings policy) — <https://oofhours.com/2023/08/25/run-an-sccm-task-sequence-during-autopilot/>
|
||||
* Niehaus: ESP tracks arbitrarily named providers when they are properly registered — <https://oofhours.com/2023/09/18/track-anything-using-esp/>
|
||||
* Agent code: `EspPolicyProviderProbe.cs`, `EspPolicyProviderStallDetector.cs`, `EspPolicyProviderStallHost.cs` (SystemSignals / Orchestration.Hosts)
|
||||
* Related: [MDM Reboot Coalescing](mdm-reboot-coalescing.md), [Gather-Rule Guardrails](../rules/gather-rule-guardrails.md)
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Gather Rule Debug Log — the per-tenant evaluation trace for rules that produce nothing
|
||||
description: EnableGatherRuleDebugLog (ConfigVersion 36) makes the agent write a local trace file explaining every gather-rule evaluation decision — scope skips, missing intervalSeconds, on_change suppression, empty collector results, logparser position/parse-mode/no-match outcomes — so customers can self-diagnose rules that never show up in the timeline.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs
|
||||
tags:
|
||||
- agent
|
||||
- gather-rules
|
||||
- diagnostics
|
||||
- tenant-configuration
|
||||
timestamp: 2026-07-28T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Gather Rule Debug Log
|
||||
|
||||
## Schema
|
||||
|
||||
### The problem
|
||||
|
||||
A gather rule that produces no timeline events is indistinguishable from a rule that
|
||||
never ran. The pipeline has many *silent-nothing* exits — before this feature, none of
|
||||
them were visible to the customer:
|
||||
|
||||
| # | Decision point | Where |
|
||||
|---|---|---|
|
||||
| 1 | Rule delivered but `Enabled=false` | `GatherRuleExecutor.UpdateRules` |
|
||||
| 2 | `trigger=interval` without `intervalSeconds` — never runs | `UpdateRules` timer filter |
|
||||
| 3 | Interval timer's first tick comes only after one **full** interval | `UpdateRules` timer creation |
|
||||
| 4 | Out of phase scope (`activePhases` / `activeFromPhase` not latched); incl. `currentPhase=Unknown` before the first phase signal | `IsRuleInScopeLocked` |
|
||||
| 5 | phase_change / phase_exit dedup — once per (rule, phase) | `OnPhaseChanged` |
|
||||
| 6 | Collector returned an **empty** result (e.g. `emitOnlyIfExists=true` and target absent) | `ExecuteRule` |
|
||||
| 7 | `emit_mode=on_change` — result hash unchanged, emit suppressed | `ShouldEmitOnChange` |
|
||||
| 8 | Guard blocked the target (emits `security_warning`, but the rule yields no data) | `GatherRuleContext.EmitSecurityWarning` |
|
||||
| 9 | logparser: no files matched, position tracker says nothing new, regex no-match, plain-text file parsed in default `cmtrace` mode, `maxLines` cap, wildcard capped at 20 files | `LogParserCollector` |
|
||||
| 10 | Rule execution threw (agent log had only the message, no stack) | `ExecuteRule` catch |
|
||||
|
||||
### The setting
|
||||
|
||||
`TenantConfiguration.EnableGatherRuleDebugLog` (`bool?`, default null→false), served to the
|
||||
agent as `AgentConfigResponse.EnableGatherRuleDebugLog` — **ConfigVersion 36**. Portal:
|
||||
Settings → Agent → Settings, toggle "Gather Rule Debug Log", stored via the usual
|
||||
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
|
||||
tenant-config report.
|
||||
|
||||
`RemoteConfigMerger` projects the bool onto a runtime path (same pattern as
|
||||
`EnableImeMatchLog`): `true` → `Constants.GatherRuleDebugLogPath` =
|
||||
`%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`; `false` → null, unless a
|
||||
`--gather-debug-log <path>` CLI override supplied a non-default path. The field is not
|
||||
security-sensitive and deliberately **survives the offline config cache**.
|
||||
|
||||
### The trace file
|
||||
|
||||
`GatherRuleDebugLog` is an append-only writer held by `GatherRuleExecutor` and exposed
|
||||
to collectors via `GatherRuleContext.DebugLog(ruleId, stage, message)` (null-safe no-op
|
||||
when disabled — zero cost on the normal path). Line format:
|
||||
|
||||
```
|
||||
2026-07-28T09:15:02.123Z | RULE-ID | stage | message
|
||||
```
|
||||
|
||||
Stages: `config` (registration summary per rule, header, zero-rules note), `trigger`
|
||||
(timer scheduled, phase/event fired, dedup), `scope` (skips with reason incl. current
|
||||
phase), `exec` (execution start), `collector` (null/empty results with likely cause),
|
||||
`guard` (allowlist block), `emit` (event emitted, hash change), `suppress` (on_change
|
||||
suppression with hash prefix + streak), `logparser` (per-file outcome: lines read,
|
||||
position range, match count, parse failures, regex timeouts, mode; plus the
|
||||
`format=text` hint when every line fails CMTrace parsing), `error` (full exception
|
||||
**with stack trace** — the agent log keeps only the message).
|
||||
|
||||
Volume control: 10 MB cap with a single `.old` rotation generation (~20 MB worst case).
|
||||
Every write failure is swallowed — tracing must never break gathering. The trace never
|
||||
leaves the device on its own — but because it lives in the agent log folder
|
||||
(`Constants.LogDirectory`), the diagnostics package (`DiagnosticsPackageService`,
|
||||
`*.log` pattern, `AgentLogs/` section) picks it up automatically, so a customer-initiated
|
||||
diagnostics upload hands the trace to support without manual copying.
|
||||
|
||||
### Semantics
|
||||
|
||||
* **No hot-reload** — the flag is consumed once when `DefaultComponentFactory` builds
|
||||
the `GatherRuleExecutorHost` at session start (per-enrollment lifecycle, same as
|
||||
`EnableImeMatchLog`). Enable it in the tenant, then the *next* enrollment writes the
|
||||
trace.
|
||||
* **Zero-rules case is traced too** — flag on + backend delivered no gather rules →
|
||||
a one-shot standalone `config` line, so the file is never just absent.
|
||||
* **`--run-gather-rules` always traces** (operator diagnostic mode; CLI override or
|
||||
default path) and mirrors every line to the console under `--console`.
|
||||
|
||||
## Examples
|
||||
|
||||
A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
|
||||
|
||||
```
|
||||
... | LP-01 | config | registered: trigger=interval, collector=logparser, target=C:\...\app.log, unscoped, emitMode=on_change, interval=300s
|
||||
... | LP-01 | trigger | interval timer scheduled every 300s (first run after one full interval — no immediate execution)
|
||||
... | LP-01 | exec | executing: collector=logparser, target=C:\...\app.log
|
||||
... | LP-01 | logparser | app.log: read 1000 lines from position 0->81234, matched 0, parseFailures=1000, regexTimeouts=0, mode=cmtrace
|
||||
... | LP-01 | logparser | every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text
|
||||
```
|
||||
|
||||
— the answer ("plain-text log parsed in CMTrace mode") is in the file.
|
||||
|
||||
## Citations
|
||||
|
||||
* Executor + trace points: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs`
|
||||
* Writer: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs`
|
||||
* Config projection: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs`
|
||||
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
|
||||
* Phase scoping / emit mode background: `../rules/gather-rule-phase-scoping.md`
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Secure Boot CA-2023 Detection — firmware evidence vs. Windows servicing status
|
||||
description: Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly (GetFirmwareEnvironmentVariableExW + SeSystemEnvironmentPrivilege) instead of trusting the SecureBoot\Servicing registry key, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant rule suppression across old and new agents.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/UefiSecureBootCertReader.cs
|
||||
tags:
|
||||
- agent
|
||||
- secureboot
|
||||
- ca2023
|
||||
- analyze-rules
|
||||
- backward-compatibility
|
||||
timestamp: 2026-07-23T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Secure Boot CA-2023 Detection
|
||||
|
||||
## Schema
|
||||
|
||||
Two independent evidence sources describe the Windows UEFI CA 2023 rollout state:
|
||||
|
||||
1. **Servicing registry** (`HKLM\SYSTEM\CurrentControlSet\Control\SecureBoot\Servicing\UEFICA2023Status`)
|
||||
— reflects whether the *Windows Update* rollout ran and self-verified. Lags reality:
|
||||
devices whose firmware received the certificate another way (OEM factory image, WinCS
|
||||
CLI, manual deployment) or where WU simply has not run yet show `unknown`/`notfound`.
|
||||
2. **Firmware itself** — the UEFI variables `db` (signature database, GUID
|
||||
`{d719b2cb-3d3a-4596-a3bc-dad00e67656f}`) and `KEK` (GUID
|
||||
`{8be4df61-93ca-11d2-aa0d-00e098032b8c}`). Authoritative. Certificate subject CNs are
|
||||
ASCII bytes inside the DER signature lists, so a byte-substring search for
|
||||
`Windows UEFI CA 2023` / `Microsoft Corporation KEK 2K CA 2023` decides presence —
|
||||
the same check `Get-SecureBootUEFI` enables from PowerShell.
|
||||
|
||||
`UefiSecureBootCertReader` (agent, `Monitoring/Interop`) reads both variables natively via
|
||||
`GetFirmwareEnvironmentVariableExW` — no PowerShell runspace during OOBE. The call needs
|
||||
`SeSystemEnvironmentPrivilege`: SYSTEM holds it default-disabled, so the reader enables it
|
||||
on the process token (`AdjustTokenPrivileges`) and restores the previous state afterwards.
|
||||
Fail-soft contract like `OobeStateReader`: never throws; failures map to status strings
|
||||
(`not_uefi` = legacy BIOS via `ERROR_INVALID_FUNCTION`, `variable_not_found`,
|
||||
`privilege_denied`, `error_<code>`).
|
||||
|
||||
This is the repo's only privilege-adjustment code, so it also carries the ownership rules
|
||||
any future one should copy:
|
||||
|
||||
* The process token lives in a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR
|
||||
owns it from the moment `OpenProcessToken` returns — the critical finalizer releases it
|
||||
even if `Dispose` never runs (async exception before the instance reaches its `using`).
|
||||
* The restore obligation is recorded immediately after the successful `AdjustTokenPrivileges`,
|
||||
and only when the privilege was not *already* enabled; nothing that can throw sits between
|
||||
the adjustment and that assignment. `Dispose` is idempotent, swallows a failed restore, and
|
||||
releases the handle in a `finally`.
|
||||
* The privilege is adjusted on the **process** token, so enable → read → restore is a
|
||||
process-wide side effect and runs under a static gate. Without it a concurrent reader would
|
||||
see "already enabled", correctly skip its own restore, and then lose the privilege mid-read
|
||||
when the first reader disposes — a spurious `privilege_denied` on a machine that holds it.
|
||||
|
||||
Fields land on the existing `secureboot_status` event (once per agent start,
|
||||
restart-deduped by the StartupEventGate): `uefiFirmwareReadStatus`, per-cert booleans
|
||||
(`uefiDbHasWindowsUefiCa2023`, `uefiDbHasWindowsProductionPca2011`,
|
||||
`uefiKekHasMicrosoftKek2kCa2023`, …) — emitted only when the respective read succeeded —
|
||||
plus the marker below.
|
||||
|
||||
## The one-sided marker (absence-tolerant suppression)
|
||||
|
||||
Rule-engine constraints (pinned by `RuleEnginePreconditionTests`):
|
||||
|
||||
* **Conditions** never match on an absent field (null guard) — new fields must stay
|
||||
`required: false` or the rule dies for every old agent.
|
||||
* **Preconditions**: `not_exists` passes while the field is absent/empty and fails as soon
|
||||
as the field is present with *any* value; comparison operators fail closed on absence.
|
||||
|
||||
A two-sided boolean therefore cannot drive suppression: `not_exists` on
|
||||
`uefiDbHasWindowsUefiCa2023` would also suppress the rule when the field is present with
|
||||
`false`. Hence the **one-sided marker** `uefiCa2023FirmwareConfirmed = true`, emitted
|
||||
ONLY when the db read succeeded AND the 2023 certificate was found — never as `false`.
|
||||
ANALYZE-SEC-001 v3 adds a `not_exists` precondition on it:
|
||||
|
||||
| Device state | Marker | Rule |
|
||||
|---|---|---|
|
||||
| Old agent (no firmware fields) | absent | evaluates exactly like v2 |
|
||||
| New agent, cert missing or firmware unreadable | absent | evaluates; optional conditions `uefiDbHasWindowsUefiCa2023=false` (+20) / `uefiKekHasMicrosoftKek2kCa2023=false` (+10) raise confidence |
|
||||
| New agent, firmware confirms cert | present | silently suppressed (the customer-reported false positive) |
|
||||
|
||||
Because the marker exists nowhere until the new agent ships, the v3 rule is safe to seed
|
||||
**before** the agent rollout.
|
||||
|
||||
## Examples
|
||||
|
||||
Customer-equivalent verification from PowerShell (authoritative, matches the reader):
|
||||
|
||||
```powershell
|
||||
[Text.Encoding]::ASCII.GetString((Get-SecureBootUEFI -Name db).Bytes).Contains('Windows UEFI CA 2023')
|
||||
[Text.Encoding]::ASCII.GetString((Get-SecureBootUEFI -Name KEK).Bytes).Contains('Microsoft Corporation KEK 2K CA 2023')
|
||||
```
|
||||
|
||||
## Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/UefiSecureBootCertReader.cs` — native reader
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs` — `CollectSecureBootStatus` / `AppendFirmwareCertFields`
|
||||
* `rules/analyze/ANALYZE-SEC-001.json` — v3.0.0 rule definition
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/RuleEnginePreconditionTests.cs` — pinned precondition semantics + v3 mirror tests
|
||||
* Microsoft: [Act now — Secure Boot certificates expire June 2026](https://techcommunity.microsoft.com/blog/windows-itpro-blog/act-now-secure-boot-certificates-expire-in-june-2026/4426856)
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Device Journeys & First-Time-Right (F2)
|
||||
description: How a device's terminal enrollment attempts are grouped into journeys (device key, junk-serial exclusion, first-success end, 30-day gap), where the DeviceHistories chain and the daily FTR aggregates are computed, stored and pruned, and which surfaces serve them.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs
|
||||
tags:
|
||||
- backend
|
||||
- metrics
|
||||
- device-history
|
||||
- first-time-right
|
||||
- insights
|
||||
timestamp: 2026-07-27T12:00:00+02:00
|
||||
---
|
||||
|
||||
# Device Journeys & First-Time-Right (F2)
|
||||
|
||||
Answers "how many devices enrolled right on the first try?" and "show me every attempt
|
||||
this device made". Session success rate hides wipe-and-retry loops — every retry that
|
||||
eventually succeeds counts as a success; First-Time-Right (FTR) exposes the real pain.
|
||||
All grouping semantics live server-side in `DeviceJourneyCalculator` (JourneyVersion 1);
|
||||
no consumer re-derives them.
|
||||
|
||||
# Schema
|
||||
|
||||
## Device key
|
||||
|
||||
`(TenantId, normalized serial)` — normalization is trim + lower-case. Junk/placeholder
|
||||
serials are excluded entirely (never a chain, never in FTR, disclosed as a per-day
|
||||
exclusion count): `System Serial Number`, `To Be Filled By O.E.M.`, `Default string`,
|
||||
`0`, `None`, `INVALID`, `Unknown` (the agent's WMI-failure sentinel), empty, or shorter
|
||||
than 4 characters. A motherboard swap changes the serial and therefore the device key;
|
||||
re-enrollment under a different tenant is a separate key by design.
|
||||
|
||||
## Journey
|
||||
|
||||
Terminal sessions of a device key ordered by StartedAt. The terminal set is
|
||||
**Succeeded / Failed / Incomplete** — `Pending` (a sealed WhiteGlove part 1),
|
||||
`AwaitingUser`, `Stalled` and `InProgress` are OPEN sessions and never attempts.
|
||||
|
||||
* A journey **ends** with the first terminal success; the next session starts a new
|
||||
journey (redeployment).
|
||||
* A gap of **more than 30 days** since the previous attempt's terminal moment
|
||||
(`CompletedAt`, fallback StartedAt) starts a new journey even without a success
|
||||
(device shelved/repurposed). Constant, not a setting — production gaps: median 4.9 h,
|
||||
92 % < 7 d.
|
||||
* WhiteGlove part 1 + part 2 share ONE session row, so a completed WG enrollment is ONE
|
||||
attempt; a WG device still waiting for its user session leaves the journey open —
|
||||
never a failed attempt.
|
||||
* **Attempt count** = terminal sessions in the journey (`Failed` and `Incomplete` are
|
||||
non-successful attempts).
|
||||
* **FTR rate** = completed journeys with attempt count 1 ÷ all completed journeys.
|
||||
Open and gap-abandoned journeys never enter numerator or denominator.
|
||||
|
||||
Durations shown for attempts are the sessions' authoritative `DurationSeconds` verbatim
|
||||
(WhiteGlove pause excluded; `Incomplete` deliberately has none) — never
|
||||
`CompletedAt − StartedAt`, which is later in ~25 % of terminal sessions.
|
||||
|
||||
## Persistence & compute
|
||||
|
||||
* `DeviceHistories` (PK = TenantId, RK = percent-encoded normalized serial): the chain of
|
||||
the device's terminal session refs `{sessionId, startedAt, completedAt, status,
|
||||
enrollmentType, isPreProvisioned, durationSeconds, adminMarked}` capped at the 20 most
|
||||
recent, plus derived `CurrentJourneyAttempts` (the LAST journey's attempts — the
|
||||
banner's "Attempt N"), `JourneyCount` (chain-scoped) and `JourneyVersion`. Written
|
||||
inline at the session-terminal single-writer seam
|
||||
(`TableSessionRepository.UpdateSessionStatusAsync`, all three terminal statuses,
|
||||
DeletionState-guarded) so the session banner is fresh; healed by the maintenance sweep.
|
||||
Deliberately NOT part of the per-session deletion manifest — the row aggregates many
|
||||
sessions. Wiped on tenant offboarding.
|
||||
* Sweep (`SweepDeviceJourneysAsync`, 30-day rolling window, 2h tick): (1) drops chain
|
||||
refs of deleted sessions **tombstone-driven** — every session deletion (cascade and
|
||||
retention both run through the cascade worker) leaves a `SessionTombstones` marker for
|
||||
~7 days, far longer than the sweep cadence, and even an expired-but-unpruned marker is
|
||||
valid deletion evidence; a chain left empty deletes its row. (2) Merges the window's
|
||||
terminal sessions into their chains (backfill, inline-miss healing, re-terminal
|
||||
reclassifications). (3) Recomputes the daily FTR aggregates idempotently.
|
||||
* `DeviceJourneyAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}`): per day —
|
||||
completed journeys, first-time-right count, attempt histogram, junk-serial exclusion
|
||||
count, `JourneyVersion`. A journey buckets on the StartedAt date of its completing
|
||||
success session (the platform's StartedAt-date convention; late terminals converge via
|
||||
the sweep). All counts are **additive**, so a window rate is the ratio of summed daily
|
||||
rows — there is no rolling-window row (unlike the median-based time-attribution
|
||||
aggregates, which cannot merge). 180-day retention. Rows exist below the ≥20 UI gate
|
||||
on purpose (the UI needs the n for "insufficient data (n=…)").
|
||||
|
||||
# Surfaces
|
||||
|
||||
* `GET api/metrics/device-history?serialNumber=&sessionId=` (MemberRead + tenant query
|
||||
param — the same route serves the web banner and MCP; a Global Admin passes `tenantId`)
|
||||
— the chain plus, with `sessionId`, that session's server-computed attempt number
|
||||
(live sessions get their would-be position via a virtual non-successful attempt, so the
|
||||
redeploy and gap rules apply). `history: null` = no recorded history.
|
||||
* `GET api/metrics/device-journeys?days=` (MemberRead) /
|
||||
`GET api/global/metrics/device-journeys?days=&tenantId=` (GlobalReadOrAdmin) —
|
||||
`{ windowDays, totals, daily, repeatDevices }`. The days selector is honored because
|
||||
counts are additive. Without `tenantId` the global variant serves the cross-tenant
|
||||
aggregate rows and `repeatDevices: null` — a per-device list would require scanning
|
||||
every tenant's partition.
|
||||
* Repeat devices (violator list, capped 10): devices whose LAST journey took ≥2 attempts
|
||||
and whose newest terminal session falls inside the window, ordered by attempts then
|
||||
recency; the failure reason comes from the newest failed attempt (bounded point-reads).
|
||||
* Web: "Attempt N for this device · View history" banner on session detail (expandable
|
||||
chain); "First-time-right" section on Fleet Health (rate + weekly trend + attempt
|
||||
histogram + repeat devices). MCP: `get_device_history` (serial mode / fleet FTR mode).
|
||||
|
||||
# Examples
|
||||
|
||||
A device fails ESP twice and succeeds on the third try within one week: one journey,
|
||||
three attempts, completed — counts once in the FTR denominator, not in the numerator,
|
||||
and the histogram's bucket `3` gains one. The same device redeployed a month later and
|
||||
succeeding immediately starts journey 2 with attempt count 1 — first-time-right. A
|
||||
device with `serialNumber = "Unknown"` (WMI failure) never gets a chain; its terminal
|
||||
sessions surface only as that day's disclosed exclusion count.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs` — device key, junk list, grouping, attempt numbers (JourneyVersion 1).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.DeviceHistory.cs` — persistence + inline seam entry.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.DeviceJourneys.cs` — sweep, tombstone pruning, daily aggregation.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs` — read surfaces.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs` — table-driven grouping/aggregation/attempt-number tests.
|
||||
* `tasks/insights-expansion-spec.md` — F2 specification and source-data audit (§0.5).
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Rule Regression Radar (F3)
|
||||
description: How the radar detects analyze rules whose hit rate suddenly rose (7-day window vs 28-day baseline, Wilson-separated), which suppressions keep it quiet, how alert episodes live in the notification tracker, and which surfaces show them.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Helpers/RuleRegressionRadar.cs
|
||||
tags:
|
||||
- backend
|
||||
- rules
|
||||
- regression-radar
|
||||
- insights
|
||||
timestamp: 2026-07-27T16:00:00+02:00
|
||||
---
|
||||
|
||||
# Rule Regression Radar (F3)
|
||||
|
||||
Detects "this analyze rule suddenly fires much more often" before helpdesk volume does, and
|
||||
says what the spike correlates with — with honest statistics and honest wording. Analyze
|
||||
rules only in v1: their daily stats are audit-verified session-deduplicated
|
||||
(`FireCount` ≈ distinct sessions with a hit, `SessionsEvaluated` the same-source
|
||||
denominator). Gather rules are excluded until their per-batch stats dedup is fixed —
|
||||
never silently included with bad math.
|
||||
|
||||
# Schema
|
||||
|
||||
## Signal & detection
|
||||
|
||||
Per (tenant, ruleId), daily hit rate = `FireCount ÷ SessionsEvaluated` from the
|
||||
`RuleStats` rows — RATES, not counts (absolute counts spike with every rollout wave).
|
||||
A regression fires only when ALL hold for the trailing 7-day window vs. the prior
|
||||
28-day baseline:
|
||||
|
||||
1. window has ≥ 5 hit sessions AND ≥ 20 evaluated sessions;
|
||||
2. window rate ≥ 2× baseline rate;
|
||||
3. the Wilson 95 % intervals are disjoint in the increase direction
|
||||
(`MetricsMath.WilsonInterval` / `RateIncreaseSeparated` — deterministic, unit-pinned),
|
||||
so small-n noise never alerts.
|
||||
|
||||
## Suppression (false positives are trust damage)
|
||||
|
||||
* Rule entity missing (deleted) — never alerts.
|
||||
* Rule younger than baseline + window (`CreatedAt` within 35 days — grace period for new
|
||||
rules; the entity timestamp is the honest gate, a stats-row count would fail on
|
||||
low-volume tenants whose quiet days simply have no rows).
|
||||
* Rule edited inside the window (`UpdatedAt` ≥ window start) — edits legitimately change
|
||||
hit rates.
|
||||
* Empty baseline denominator (rule disabled throughout the baseline).
|
||||
* Platform kill switch: app setting `RuleRegressionRadarDisabled=true` skips the radar
|
||||
(fail-open — it only notifies, never mutates data).
|
||||
|
||||
## Alert episodes (tracker keyspace)
|
||||
|
||||
An episode lives as `ruleregression|{ruleId}` in the notification-tracker table
|
||||
(the `tpmpss|` pattern): the row IS the dedup (bell + ops event fire exactly once per
|
||||
episode), the badge state and the `regressions[]` payload — window/baseline counts,
|
||||
rates, lift (null for a zero-baseline "new signal" — no finite lift is invented), window
|
||||
dates, and the dimension concentration captured at first fire. Numbers refresh on every
|
||||
radar pass while the episode stays active; `FirstNotifiedAt` never moves. The episode
|
||||
closes (badge clears, dedup re-arms) when the window rate falls back under 1.5× baseline
|
||||
or fires stop entirely; the tracker's 30-day retention sweep also re-arms, so a
|
||||
month-old still-burning regression rings once more by design.
|
||||
|
||||
## Dimension correlation (on fire only)
|
||||
|
||||
Hit sessions (RuleResults scan: tenant partition range + RowKey = ruleId + DetectedAt in
|
||||
window, intersected with the window's sessions) are compared against all window sessions
|
||||
across `osBuild`, `model` (manufacturer + model), `agentVersion` and `imeVersion`. The
|
||||
top value by lift is reported with BOTH shares — wording contract: "X % of affected
|
||||
sessions are on … vs Y % of all sessions — correlated, not necessarily causal". No value
|
||||
with ≥ 5 hit sessions at ≥ 2× lift → "no clear dimension concentration"; a correlation
|
||||
failure also yields no claim. App-version correlation for `ANALYZE-APP-*` rules is a
|
||||
follow-up (needs rule→app matching semantics), tracked with the gather-rule fix.
|
||||
|
||||
## Compute & alert path
|
||||
|
||||
Runs in the daily maintenance pass right after the rule-stats aggregation
|
||||
(`RunRuleRegressionRadarAsync`), anchored on YESTERDAY — whole days only, a partial
|
||||
today would understate the window rate. One 35-day stats read covers all tenants;
|
||||
evaluation is the pure `RuleRegressionRadar` core. On a NEW episode:
|
||||
|
||||
1. `RuleFrequencyRegression` ops event (category Tenant, Warning) with the full numbers
|
||||
— dual-registered in the web `OPS_EVENT_TYPES` catalog for alert-rule routing.
|
||||
2. Tenant bell notification (type `rule_frequency_regression`, Admin audience) with the
|
||||
same numbers and a deep link to the rule's card (`/analyze-rules#rule-card-{ruleId}`).
|
||||
A sessions-list pre-filtered to rule hits needs a rule filter the session search does
|
||||
not have yet — follow-up alongside the gather-rule work.
|
||||
3. v1 channel: bell + ops event only. Provider fan-out (webhook/Telegram) follows once
|
||||
alert quality is proven — a noisy first version burns trust permanently.
|
||||
|
||||
# Surfaces
|
||||
|
||||
* `GET api/metrics/rule-stats` (and the global variant with `tenantId`) carries
|
||||
`regressions[]` — the active episodes. MCP `get_rule_stats` inherits the block.
|
||||
* Analyze-rules page: per-rule 30-day fire-count sparkline (densified from the `trend`
|
||||
rows the endpoint already served) and a red "↑ Regression" badge while an episode is
|
||||
active, with the full numbers in the tooltip.
|
||||
|
||||
# Examples
|
||||
|
||||
A rule firing on 2 % of sessions for a month jumps to 15 % over a week (15/100 vs
|
||||
10/500): lift 7.5×, Wilson-separated → one bell, one ops event, badge until the rate
|
||||
falls back under 3 %. The same jump on 5/20 sessions with a 12 % baseline stays silent —
|
||||
the intervals overlap, so the spike is not statistically real yet. A rule that never
|
||||
fired in the baseline and now hits 15 % alerts as a "new signal" with no lift figure.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/RuleRegressionRadar.cs` — gates, suppression, re-arm, dimension concentration.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs` — Wilson interval + separation primitive.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.RuleRegression.cs` — sweep, episode reconcile, bell/ops wiring.
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs` — episode keyspace.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/RuleRegressionRadarTests.cs` — hand-computed Wilson vectors + every suppression branch.
|
||||
* `tasks/insights-expansion-spec.md` — F3 specification and source-data audit (§0.5).
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
type: Concept
|
||||
title: ServerActions & On-Demand Diagnostics Collection
|
||||
description: The queue-on-session, deliver-on-ingest ServerAction channel (terminate_session / rotate_config / request_diagnostics), the operator-tier authorization split, the portal's Collect Logs flow including the admin quick-config path, and why the offboarding cascade sweeps the hosted diagnostics prefix.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs
|
||||
tags:
|
||||
- backend
|
||||
- agent
|
||||
- web
|
||||
- server-actions
|
||||
- diagnostics
|
||||
- authorization
|
||||
timestamp: 2026-07-24T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
**Channel.** A `ServerAction` (`Type`, `Reason`, `RuleId`, `Params`, `QueuedAt`) is queued onto
|
||||
`Sessions.PendingActionsJson` by server-side writers — the admin endpoint
|
||||
`POST /api/sessions/{sessionId}/actions`, the RuleEngine, maintenance — and delivered
|
||||
piggy-backed on the agent's next ingest response. No polling channel exists; delivery latency
|
||||
equals the agent's telemetry cadence (seconds to ~1 minute during enrollment). Delivery is
|
||||
at-least-once: the agent's `ServerActionDispatcher` dedups on `Type|RuleId|QueuedAt` and
|
||||
processes a batch sequentially in queue order. Unknown types are logged and skipped, so new
|
||||
types roll out server-first. Every action emits `server_action_received` and then
|
||||
`server_action_executed` or `server_action_failed` into the session timeline.
|
||||
|
||||
**Authorization (two layers).** The route sits on `EndpointPolicy.TenantAdminOrOperator`
|
||||
(tenant Admin, tenant Operator, or Global Admin — no Viewer, no Global Reader: it is a write
|
||||
tier). Because the route serves multiple action kinds, `QueueSessionActionFunction`
|
||||
re-gates per type via `IsTypeAllowedForCaller`: Operators may queue only
|
||||
`request_diagnostics`; `terminate_session` and `rotate_config` require
|
||||
`RequestContext.IsTenantAdmin || IsGlobalAdmin` (403 otherwise). Any route on this tier with
|
||||
a multi-kind body MUST follow this same in-function re-gating pattern.
|
||||
|
||||
**On-demand collection (`request_diagnostics`).** The dispatcher calls
|
||||
`DiagnosticsPackageService.CreateAndUploadAsync(enrollmentSucceeded: false, suffix:
|
||||
"server-requested")` — producing `AgentDiagnostics-{sessionId}-{timestamp}-server-requested.zip`.
|
||||
Passing `enrollmentSucceeded: false` is what makes on-demand collection work under
|
||||
`DiagnosticsUploadMode=OnFailure`; only `Off` (or an unconfigured destination) blocks it,
|
||||
surfacing as `server_action_failed`. The `diagnostics_uploaded` event stamps
|
||||
`DiagnosticsBlobName` + `Destination` onto the Sessions row (latest wins — an on-demand name
|
||||
is later replaced by the terminal package), and the SignalR `sessionUpdate` delta carries the
|
||||
field, so the portal's Download button flips live.
|
||||
|
||||
**Portal flow (Collect Logs, session detail header).** The button is always rendered; the
|
||||
disabled states carry tooltips (inactive session, cross-tenant view, missing role, diagnostics
|
||||
not configured) as a deliberate nudge. Members below Admin cannot read the tenant config, so
|
||||
the member-readable feature-flags endpoint exposes `diagnosticsUploadConfigured`
|
||||
(`mode != Off && (customer SAS present || destination Hosted)`). Progress tracking is
|
||||
sequence-based (backend-assigned, monotonic), never timestamp-based — agent clock skew and
|
||||
at-least-once redelivery of older actions cannot produce false matches
|
||||
(`collectLogsLogic.ts`). Cross-tenant views are gated off because `TenantHelper.GetTenantId`
|
||||
resolves the tenant strictly from the caller's JWT.
|
||||
|
||||
**Admin quick-config.** For an unconfigured tenant, a Tenant Admin's click opens a dialog
|
||||
that switches the tenant to `Destination=Hosted`, `Mode=OnFailure` (GET full config → patch
|
||||
exactly those two fields → PUT verbatim), then queues `rotate_config` **before**
|
||||
`request_diagnostics` — the dispatcher's sequential ordering guarantees the agent refetches
|
||||
the now-enabled config (the `RemoteConfigMerger` applies diagnostics fields live, remote wins)
|
||||
before building the package. This works against the already-deployed agent fleet; a
|
||||
"one-time override" variant would not, because the `mode=Off` check is client-side in
|
||||
`CreateAndUploadAsync` and the deployed handler reads no params.
|
||||
|
||||
**Offboarding cascade.** Latest-wins on `DiagnosticsBlobName` means a session can leave more
|
||||
hosted blobs behind than the deletion manifest references. The cascade's Hosted branch
|
||||
therefore sweeps the canonical prefix `{tenantId}/AgentDiagnostics-{sessionId}-`
|
||||
(`HostedDiagnosticsBlobService.DeleteBySessionPrefixAsync`) plus the manifest-named blob as
|
||||
belt-and-braces; a non-GUID legacy `SessionId` degrades to the single-name delete instead of
|
||||
poisoning the cascade. The CustomerSas branch is unchanged (delete only with the `d`
|
||||
permission; the customer's lifecycle rules stay authoritative).
|
||||
|
||||
# Examples
|
||||
|
||||
Queue an on-demand collection (Admin or Operator, own tenant):
|
||||
|
||||
```
|
||||
POST /api/sessions/{sessionId}/actions
|
||||
{ "type": "request_diagnostics", "reason": "On-demand log collection from portal" }
|
||||
→ 202 { "success": true, "queuedAt": "..." }
|
||||
```
|
||||
|
||||
Operator queueing an admin-only type:
|
||||
|
||||
```
|
||||
POST /api/sessions/{sessionId}/actions
|
||||
{ "type": "terminate_session" }
|
||||
→ 403 { "message": "Action type 'terminate_session' requires the Tenant Admin role" }
|
||||
```
|
||||
|
||||
Timeline for a successful round trip: `server_action_received` → `server_action_executed`
|
||||
(with `blobName`) → `diagnostics_uploaded` → Sessions row + SignalR delta carry the new
|
||||
`DiagnosticsBlobName`.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs` — allowed types, per-type caller gate.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs` — `TenantAdminOrOperator` tier definition.
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/ServerActionDispatcher.cs` — dedup, sequential dispatch, telemetry contract.
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — mode gates, archive build, suffix.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobCascadeDeleter.cs` — hosted prefix sweep.
|
||||
* `src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/collectLogsLogic.ts` — button gating matrix, sequence-based progress evaluation.
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Enrollment Time Attribution (F1)
|
||||
description: How a terminal session's wall clock is partitioned into named segments with an explicit unattributed remainder, how ESP-blocking apps and reboots are attributed, and where breakdowns and fleet aggregates are computed, stored and served.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs
|
||||
tags:
|
||||
- backend
|
||||
- metrics
|
||||
- time-attribution
|
||||
- insights
|
||||
timestamp: 2026-07-26T20:00:00+02:00
|
||||
---
|
||||
|
||||
# Enrollment Time Attribution (F1)
|
||||
|
||||
Answers "where did the 47 minutes go?" per session and per fleet. Every number is
|
||||
traceable to observed events; a missing signal produces "unknown" or an explicit
|
||||
unattributed remainder — never a guess, and the partition is never normalized to 100 %.
|
||||
|
||||
# Schema
|
||||
|
||||
## Invariant
|
||||
|
||||
For every computed breakdown:
|
||||
|
||||
```
|
||||
sum(span seconds) + UnattributedSeconds == WallClockSeconds == session DurationSeconds
|
||||
```
|
||||
|
||||
`DurationSeconds` is the authoritative wall clock — for WhiteGlove it is part 1 + part 2
|
||||
WITHOUT the pause, and re-terminal stamps move `CompletedAt`, so `CompletedAt − StartedAt`
|
||||
is never used (it diverges in ~25 % of terminal sessions). Span seconds are floored;
|
||||
sub-second dust lands in the unattributed remainder.
|
||||
|
||||
## Observation windows
|
||||
|
||||
Windows are end-anchored on authoritative timestamps and constructed so their lengths sum
|
||||
exactly to `DurationSeconds`:
|
||||
|
||||
* Non-WhiteGlove: one window `[CompletedAt − DurationSeconds, CompletedAt]`.
|
||||
* WhiteGlove (ONE session row, two windows): part 2 = `[ResumedAt, CompletedAt]`; part 1 =
|
||||
`[part1End − (DurationSeconds − part2), part1End]` where `part1End` is anchored on the
|
||||
`whiteglove_part1_complete` event. The pause between the windows is never attributed.
|
||||
Fallback anchors set the `WhiteGloveAnchorsIncomplete` quality flag.
|
||||
|
||||
## Segments
|
||||
|
||||
Spans are sliced at phase-declaration events (only declaration events carry
|
||||
`Phase != Unknown`) plus `desktop_arrived`, in canonical Sequence order; anchors whose
|
||||
timestamp runs backward are dropped (`ClockSkewDropped`).
|
||||
|
||||
| EnrollmentPhase | Segment key |
|
||||
|---|---|
|
||||
| Start / DevicePreparation / DeviceSetup | `device_prep` |
|
||||
| AppsDevice | `esp_apps` |
|
||||
| AccountSetup / FinalizingSetup | `identity_hello` |
|
||||
| AppsUser | `user_esp` |
|
||||
| Complete, `desktop_arrived` event | `desktop_handoff` |
|
||||
| Failed | attribution ends — tail stays unattributed |
|
||||
|
||||
## ESP-blocking apps (positive evidence only)
|
||||
|
||||
Per-app intervals use EVENT timestamps (first started/download event → LAST terminal
|
||||
event, covering IME retries) — never the agent payload timing, which freezes at the first
|
||||
terminal transition. Blocking membership joins the app id against the session's latest
|
||||
list-carrying `esp_config_detected` emission: listed ⇒ blocking; absent ⇒ unknown, never
|
||||
false (`BlockingSetUnknown` / `BlockingSetTruncated` flags). Critical-path occupancy =
|
||||
overlap-merged union of blocking intervals clipped to the `esp_apps` spans;
|
||||
`EspAppsOccupancySeconds` is null (unknown) when no lists were observed.
|
||||
|
||||
The fleet what-if bound per app X is `max(0, cpEnd − cpEndWithoutX)` (latest interval end
|
||||
with vs. without X; the idle gap before X counts — the ESP also waits for X to start).
|
||||
It is an upper bound by construction; every consumer says "up to", never "you will save".
|
||||
|
||||
## Reboots
|
||||
|
||||
`system_reboot_detected` is detection-time-stamped by the NEXT agent run; the real boot
|
||||
moment is its `lastBootUtc` payload. Reboot spans are therefore the event-stream gap
|
||||
bracketing `lastBootUtc`, deduped per boot, clipped to the observation windows, and carried
|
||||
as a cross-cutting annotation (they overlap the segment they started in — never a slice of
|
||||
the partition).
|
||||
|
||||
## Persistence & compute
|
||||
|
||||
* `SessionTimeBreakdowns` (PK = TenantId, RK = SessionId): spans, reboot spans, blocking
|
||||
intervals (top 20 + uncapped count), quality flags, `AttributionVersion`. Written once at
|
||||
the session-terminal single-writer seam (`TableSessionRepository.UpdateSessionStatusAsync`,
|
||||
Succeeded/Failed only — Incomplete deliberately has no duration). Deleted with its session
|
||||
(deletion-manifest step) and on tenant offboarding.
|
||||
* Self-healing 30-day maintenance sweep (2h tick): backfills missing breakdowns, folds
|
||||
late-terminating sessions into their StartedAt-date aggregates, and enforces version
|
||||
purity — a stale-version row whose events aged out counts as missing, never mixed.
|
||||
* `TimeAttributionAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}|{class}` and
|
||||
`rolling30|{class}`): per enrollment class (`user_driven`, `whiteglove`, `self_deploying`,
|
||||
`device_preparation` — never mixed) median/p75/p90 per segment over the fixed six-segment
|
||||
stack (incl. `unattributed`; a session without a segment contributes 0), plus top blocking
|
||||
apps (≥5 sessions per app row). Clean/flagged/missing counts are part of every row; rows
|
||||
exist below the ≥20 UI gate on purpose (the UI needs the n for "insufficient data (n=…)").
|
||||
Daily rows retain 180 days; `rolling30` rows are refreshed whole (a median of per-day
|
||||
medians is not the range median — the rolling rows are the honest range statistics).
|
||||
|
||||
## Surfaces
|
||||
|
||||
* `GET api/sessions/{sessionId}/time-attribution` (MemberRead + tenant query param) —
|
||||
breakdown row or `breakdown: null` (pre-feature / non-terminal / Incomplete = no lane).
|
||||
* `GET api/metrics/time-attribution` (MemberRead) / `GET api/global/metrics/time-attribution`
|
||||
(GlobalReadOrAdmin, optional `tenantId`) — `{ windowDays, classes (rolling), daily }`.
|
||||
* Web: attribution lane under the session-detail phase timeline; "Time attribution" section
|
||||
on Fleet Health. MCP: `get_time_attribution` (sessionId mode / fleet mode).
|
||||
|
||||
# Examples
|
||||
|
||||
A 30-minute user-driven session: `device_prep` 5m → `esp_apps` 10m (occupancy 4m by one
|
||||
blocking app) → `identity_hello` 8m → `user_esp` 5m → `desktop_handoff` 2m, unattributed 0.
|
||||
A WhiteGlove session with a 2-day pause reports wall clock = part1 + part2 only; the resume
|
||||
gap before the first part-2 phase declaration is unattributed, not guessed.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 1).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.TimeAttribution.cs` — persistence.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs` — sweep + aggregation.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs` — golden fixtures pinning the invariant.
|
||||
* `tasks/insights-expansion-spec.md` — F1 specification and source-data audit (§0.5).
|
||||
@@ -20,7 +20,10 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
|
||||
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
|
||||
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
|
||||
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
|
||||
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
|
||||
|
||||
# Rules
|
||||
|
||||
@@ -30,11 +33,19 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
# Backend
|
||||
|
||||
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
|
||||
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
|
||||
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
|
||||
|
||||
# MCP Server
|
||||
|
||||
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
|
||||
|
||||
# Web Portal
|
||||
|
||||
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link and the Static Web Apps SSR runtime queues them server-side into a multi-minute tail. Records the follow-up measurement (the Standard plan changed nothing; the prefetch storm was an accidental keep-alive), where browser telemetry actually lives, and why this curl build cannot be trusted for HTTP versions.
|
||||
|
||||
# Security & Identity
|
||||
|
||||
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
|
||||
|
||||
+33
-1
@@ -1,6 +1,34 @@
|
||||
# Log
|
||||
|
||||
## 2026-07-23
|
||||
## 2026-07-28
|
||||
|
||||
* **Update**: `agent/autopilot-ztd-diagnostics.md` — presentation layer for the startup error backfill (customer feedback: an unannounced wall of pre-agent Error events with a single identical timestamp read like a live failure). Agent: the backfill scan now buffers the capped record set, emits an Info summary event first (`modern_deployment_log`, `backfillSummary: true` + `replayedCount`/`droppedOverCap`/`lookbackMinutes`), then replays oldest-first so the timeline sequence follows event-log chronology. Web: `EventTimeline.tsx` badges `backfilled: true` events („⟲ Backfilled", tooltip) and renders the original `timeCreated` as the row time with a "(reported …)" suffix plus a `Recorded` metadata line — works retroactively for already-stored events. Severity is deliberately not downgraded.
|
||||
|
||||
* **Creation**: Added `agent/gather-rule-debug-log.md` — the new per-tenant setting `EnableGatherRuleDebugLog` (ConfigVersion 35 → 36, portal toggle under Settings → Agent, `EnableImeMatchLog`-style bool→path projection in `RemoteConfigMerger`). When enabled, the agent writes `%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`: one pipe-delimited line per evaluation decision across ten previously silent "produced nothing" exits — disabled rules, `interval` without `intervalSeconds` (never runs), the first-tick-after-one-full-interval window, phase-scope skips incl. `currentPhase=Unknown`, per-(rule, phase) dedup, empty collector results (`emitOnlyIfExists`), `on_change` hash suppression (hash prefix + streak), guard blocks, logparser per-file outcomes (position skips, parse failures with a `format=text` hint when every line fails CMTrace parsing, `maxLines`/wildcard caps), and full exception stacks that the agent log drops. 10 MB cap + one `.old` generation; no hot-reload (per-enrollment lifecycle); flag survives the offline config cache; `--run-gather-rules` always traces and echoes under `--console`; zero-rules delivery leaves a one-line file instead of nothing. Side fix: `GatherRuleExecutorHost` now passes the logger into `InformationalEventPost`, so gather-event emissions appear in the agent DEBUG log.
|
||||
|
||||
* **Correction + Update**: `web/portal-navigation-prefetch.md`. The HTTP/1.1 claim in the 2026-07-27 entry below is **wrong** and has been struck from the document: `portal.autopilotmonitor.com` negotiates **h2**, verified by `openssl s_client -alpn h2,http/1.1` and a .NET `HttpClient` returning `HTTP/2.0`. The false reading came from the Windows/Schannel curl build in this environment, which has no HTTP/2 support and therefore always reports `1.1` — do not use it to determine HTTP versions. There is no six-connection cap in play; the queue is server-side in the SWA SSR runtime, which does not change the fix or the evidence, only the mechanism. Added an `Outcome` section with 20 h of post-change telemetry: the Free → Standard plan change (16:02:44 UTC) produced **no measurable latency gain** once stratified by the gap to the previous request (p50 Free → Standard: `<10s` 85 → 95 ms, `5-30min` 429 → 441 ms; tail rates 1.64 %/0.374 % over 2 s/10 s vs 6 and 1 events at n = 258 against 4.2 and 1.0 expected; `browserTimings` Mann-Whitney z = −0.24, p = 0.81). The freezes were removed by `prefetch={false}` (16:34:13 UTC): worst `browserTimings.totalDuration` 225,277 → 4,856 ms. The prefetch storm was also an **accidental keep-alive** — requests under 10 s apart fell from 85 % to 46 %, and on the identical never-cacheable `/sessions/{id}` route the median rose 87 → 211 ms and p90 465 → 1,480 ms, with one 11,441 ms abandoned request (`resultCode 0`) after 517 minutes idle. Synthetic bursts warm the runtime and must not be used to benchmark this (round 1 p50 288 ms vs rounds 4–25 p50 113 ms).
|
||||
|
||||
## 2026-07-27
|
||||
|
||||
* **Creation**: Added `web/portal-navigation-prefetch.md` (and a `Web Portal` section to the index) — root cause of the minute-long portal freezes. `GlobalSidebar` and `Navbar` render the whole navigation on every page, so the App Router's default viewport prefetch issued one `?_rsc=` request per visible link against `portal.autopilotmonitor.com` (~10,400 in 14 days). *(The original entry attributed the freeze to HTTP/1.1 and a six-connection-per-origin cap — see the 2026-07-28 correction; the queue is server-side.)* When the Static Web Apps managed runtime queued the prefetches (p50 91 ms, p95 896 ms, but max 234 s / 21 samples over 30 s), the navigation the user wanted was stuck behind that queue. Measured on the 2026-07-27 17:25 CEST incident: `/admin/presence?_rsc=` 151 s, three siblings finishing within 340 ms of each other at ~37.5 s, all `200`, while the backend API answered in 1.6–2.2 s and `browserTimings` showed `networkDuration` 16 ms against `processingDuration` 225 s. Every navigation Link now sets `prefetch={false}` — these routes are client components behind `ProtectedRoute` that fetch their own data, so the prefetched RSC payload carried no benefit. The doc also records that browser telemetry lives in a **separate** App Insights resource (`AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`) that the MCP `query_backend_logs` tool cannot reach, which is why the freezes left no trace in the backend logs.
|
||||
|
||||
* **Creation**: Added `backend/rule-regression-radar.md` — F3 regression radar (analyze rules only; gather rules wait for their per-batch stats dedup fix): per (tenant, rule) daily hit rate from `RuleStats`, firing only when the trailing 7-day window has ≥5 hit sessions and ≥20 evaluated sessions, the rate is ≥2× the prior 28-day baseline AND the Wilson 95 % intervals are disjoint (deterministic `MetricsMath` primitive with hand-computed test vectors). Suppressed for deleted rules, rules younger than baseline+window (`CreatedAt` grace), rules edited inside the window (`UpdatedAt`), empty baselines, and the `RuleRegressionRadarDisabled` app-setting kill switch. Alert episodes live as `ruleregression|{ruleId}` tracker rows (one bell + one `RuleFrequencyRegression` ops event per episode; numbers refreshed while active; re-armed under 1.5× baseline, on fire-stop, or by the 30-day tracker retention). On-fire dimension correlation over osBuild/model/agentVersion/imeVersion with the "correlated, not necessarily causal" wording contract. Surfaces: `regressions[]` in the rule-stats response (MCP inherits), the analyze-rules page's 30-day sparkline and regression badge.
|
||||
|
||||
* **Creation**: Added `backend/device-journeys.md` — F2 device history / First-Time-Right: the device key (trim + case-fold serial, junk/placeholder exclusion incl. the agent's `Unknown` WMI sentinel), journey grouping over the terminal set Succeeded/Failed/Incomplete (first success ends a journey, a >30-day gap from the previous attempt's CompletedAt starts one, WhiteGlove's single session row is ONE attempt, open sessions are never attempts), the `DeviceHistories` chain (cap 20, written inline at the terminal seam, healed by the sweep, deleted-session refs pruned tombstone-driven, deliberately not in the per-session deletion manifest) and the additive `DeviceJourneyAggregates` daily rows (window rate = ratio of sums — no rolling row needed, 180-day retention). Surfaces: the session-detail "Attempt N" banner with server-computed attempt numbers (virtual-attempt rule for live sessions), the Fleet Health First-time-right section (≥20-journey gate, attempt histogram, repeat-devices violator list), and the MCP `get_device_history` tool (serial mode / fleet FTR mode).
|
||||
|
||||
## 2026-07-25
|
||||
|
||||
* **Update**: `agent/secureboot-ca2023-detection.md` — review follow-up on 90012d9 hardened the privilege scope, the repo's only privilege-adjustment code and therefore the precedent for any future one. The process token moved from a raw `IntPtr` to a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR owns it from the moment `OpenProcessToken` returns and the critical finalizer releases it even when `Dispose` never runs — previously an async exception between the successful open and the caller's `using` leaked the handle *with the privilege enabled and no owner left to restore it*. `Dispose` became idempotent and now releases the handle in a `finally`, so a throwing restore cannot strand it; the restore obligation is recorded immediately after the successful `AdjustTokenPrivileges` with nothing throwable in between. The whole enable → read → restore sequence runs under a static gate because the privilege is adjusted on the *process* token: a concurrent reader would see "already enabled", correctly skip its own restore, and then lose the privilege mid-read when the first reader disposed, reporting `privilege_denied` on a machine that holds it.
|
||||
|
||||
## 2026-07-24
|
||||
|
||||
* **Creation**: Added `backend/server-actions-on-demand-diagnostics.md` — the ServerAction channel (queue on `Sessions.PendingActionsJson`, deliver piggy-backed on the next ingest response, at-least-once with `Type|RuleId|QueuedAt` dedup and sequential dispatch) and the on-demand log collection built on it: the portal's always-visible **Collect Logs** button, the new `EndpointPolicy.TenantAdminOrOperator` tier with the mandatory in-function per-type re-gate (Operators queue only `request_diagnostics`), the member-readable `diagnosticsUploadConfigured` feature flag, the admin quick-config path that must queue `rotate_config` before `request_diagnostics` (works against the deployed fleet — a one-time override would not, the mode gate is agent-side), sequence-based progress matching immune to agent clock skew, and the offboarding cascade's hosted-prefix sweep closing the latest-wins orphan-blob gap.
|
||||
|
||||
* **Update**: `agent/esp-policy-provider-stall.md` — the customer's follow-up on issue #106 corrected the field-case mechanics: `TrackingPoliciesCreated=1` WAS set under the foreign `ConfigMgr` provider, so the ESP ignores a foreign provider's completion values and waits on the Intune-registered `Sidecar` provider by name (the rename to `Sidecar` creates no tracking entries yet unblocks instantly). The completeness-keyed detector would have missed exactly this — the incident it was built for. The tripwire is now two-armed under one event type discriminated by `reason`: `provider_incomplete` (unchanged dwell on continuously-incomplete providers) and `sidecar_provider_missing` (≥ 1 Setup/Apps provider registered without `Sidecar`, completeness deliberately ignored, clock reset when Sidecar registers later). Sidecar-missing wins the `reason` when both arms cross in one round; the cross-restart fingerprint gains a `sidecarMissing` sentinel; payload adds `reason` and `sidecarMissingForMinutes`. Evidence for the name-keyed wait: Niehaus' documented Intune WAP payload always registers `Sidecar` (ConfigMgr only via a co-management settings policy) and Microsoft's ESP troubleshooting models app tracking exclusively through the Sidecar subkey.
|
||||
|
||||
* **Creation**: Added `agent/esp-policy-provider-stall.md` — a customer diagnosed that pre-provisioned devices with the ConfigMgr client as a blocking app occasionally park the user ESP at "Apps (Identifying)" for days: only a `ConfigMgr` provider is registered under `EnrollmentStatusTracking\Device\Setup\Apps\PolicyProviders` (no `Sidecar` = IME) and `TrackingPoliciesCreated` never becomes 1 — a wait the CSP defines without any timeout; renaming the key to `Sidecar` unblocks immediately. The agent now ships the always-on `esp_policy_provider_stalled` tripwire (60-s tick, 15-min continuous-incompleteness dwell, one-shot per provider, `StartupEventGate` fingerprint across restarts, purely observational via the InformationalEvent rail — no toggle, no ConfigVersion bump), generalized to any provider in both `Setup/Apps` and `DevicePreparation` kinds rather than hardcoding the ConfigMgr case. Companion change: `SOFTWARE\Microsoft\Windows\Autopilot` joins the gather-rule registry allowlist so tenants can target the subtree with custom rules (e.g. `not_exists` on the Sidecar key).
|
||||
|
||||
* **Creation**: Added `agent/secureboot-ca2023-detection.md` — ANALYZE-SEC-001 warned on devices whose firmware already carried the 2023 certificate, because the agent only read the `SecureBoot\Servicing\UEFICA2023Status` registry value, which mirrors the Windows Update rollout rather than the firmware (customer report). The agent now reads the UEFI `db`/`KEK` variables natively (`GetFirmwareEnvironmentVariableExW` with `SeSystemEnvironmentPrivilege` enabled on the process token — first privilege-adjustment code in the repo) and emits per-certificate booleans on `secureboot_status`. Suppression rides on a one-sided marker, `uefiCa2023FirmwareConfirmed`, emitted only on positive confirmation: the engine's `not_exists` precondition fails on any present value, so a two-sided boolean could not distinguish "confirmed" from "checked and missing", while the marker's absence keeps old agents and firmware-missing devices on the v2 path. Rule v3.0.0 is therefore safe to seed before the agent release.
|
||||
|
||||
* **Update**: `url-registry.md` — the first release after the alias migration surfaced Front Door caching as a correctness hazard: the alias served the previous `version.json` (`X-Cache: TCP_REMOTE_HIT`) after the origin had rotated, and since manifest and ZIP are independent cache entries, a stale manifest with a fresh ZIP fails the bootstrap SHA-256 check. Route caching on `/agent/*` is now disabled and every blob upload stamps `Cache-Control: no-cache` (CI `Send-Blob`, local `build.ps1`, `versions/*.json` manifests) — all agent-container blobs rotate in place, so nothing there is safely cacheable.
|
||||
|
||||
@@ -40,6 +68,10 @@
|
||||
|
||||
* **Creation**: Added `agent/endpoint-migration.md` — config-channel agent re-home (`MigrateToApiBaseUrl` on `GET agent/config`, ConfigVersion 33): backend or per-tenant API-URL moves without DNS indirection (impossible due to mTLS/Flex-TLS). Live-fetch-only with cache strip, `AgentEndpointMigrationRules` allowlist validated on both sides, one hop, kill-switch wins. Built as the lesson from the 2026-07-18 WEU→GWC cutover, which hard-cut all in-flight agents.
|
||||
|
||||
## 2026-07-26
|
||||
|
||||
* **Creation**: Added `backend/time-attribution.md` — the F1 enrollment time attribution: exact-partition invariant against the session's authoritative DurationSeconds, two-window WhiteGlove model, segment mapping from phase-declaration events, positive-evidence ESP-blocking join with "up to" what-if bounds, reboot spans from event gaps around `lastBootUtc`, and the SessionTimeBreakdowns / TimeAttributionAggregates tables with their terminal-seam + maintenance-sweep compute paths and read surfaces (web lane, fleet panel, MCP `get_time_attribution`).
|
||||
|
||||
## 2026-07-17
|
||||
|
||||
* **Creation**: Added `agent/build-counter-blob.md` — agent build numbers now come from a shared public counter blob (`buildcounter-v{N}.txt`, reserve-before-build with ETag-CAS) so local `build.ps1` and CI `build-agent.yml` builds can never mint the same number. Documents the CI parity fixes (SummaryDialog.exe.config in the ZIP, EXE-vs-manifest version check, hash-oracle update gated on stable cutover) and the Sigstore provenance attestation on CI-built ZIPs.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Portal Navigation Prefetch & the SWA Runtime Bottleneck
|
||||
description: Why every navigation Link in the portal sets prefetch={false} — the App Router's viewport prefetch fires one RSC request per visible link, and the Static Web Apps SSR runtime queues them server-side into a multi-minute latency tail. Also records what the follow-up measurement showed about the Standard SKU and the keep-alive side effect.
|
||||
resource: src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
|
||||
tags:
|
||||
- web
|
||||
- performance
|
||||
- nextjs
|
||||
- static-web-apps
|
||||
- observability
|
||||
timestamp: 2026-07-27T18:00:00+02:00
|
||||
---
|
||||
|
||||
# Context
|
||||
|
||||
The portal is a Next.js 15 App Router application hosted on Azure Static Web Apps. Every page under
|
||||
the authenticated shell is a client component behind `ProtectedRoute` that fetches its own data from
|
||||
the Function App (`autopilotmonitor-api-eu`) after mount. The RSC payload a route returns on
|
||||
navigation therefore carries no page data — only the shell.
|
||||
|
||||
`GlobalSidebar` and `Navbar` render the full navigation on every page. With the App Router's default
|
||||
prefetch behaviour, each visible `<Link>` triggers a background `GET /<route>?_rsc=<hash>` as soon as
|
||||
it enters the viewport.
|
||||
|
||||
# Mechanism
|
||||
|
||||
Two properties combine into a portal-wide freeze:
|
||||
|
||||
1. **Fan-out.** The navigation is fully rendered on each page load, so a single navigation issues one
|
||||
RSC request per visible link — measured at ~10,400 requests over 14 days.
|
||||
2. **A heavy latency tail on the SSR runtime.** RSC responses are prerendered and edge-cached
|
||||
(`x-nextjs-cache: HIT`, `s-maxage=31536000`), so the median is fast. On a cache miss or cold start
|
||||
the Static Web Apps managed runtime queues them **server-side**, and the queue drains all at once.
|
||||
|
||||
The navigation the user wants is then stuck behind that queue and the tab shows a loading spinner or a
|
||||
blank page until it drains. Nothing fails — the requests eventually return `200`, which is why the
|
||||
failure leaves no error trace anywhere.
|
||||
|
||||
> **Correction (2026-07-28).** The original version of this document named a third property: that
|
||||
> `portal.autopilotmonitor.com` negotiates HTTP/1.1 and the prefetches exhaust the six-connection
|
||||
> browser cap. **That is wrong.** It came from `curl -w '%{http_version}'` on a Windows build of curl
|
||||
> whose Schannel backend has no HTTP/2 support, so it can only ever report `1.1`. ALPN says otherwise:
|
||||
> `openssl s_client -alpn h2,http/1.1` negotiates **h2** on the portal host, confirmed by a .NET
|
||||
> `HttpClient` request returning `HTTP/2.0`. There is no six-connection cap and connection starvation
|
||||
> was never the mechanism — the queue is server-side. Verify HTTP versions with `openssl s_client
|
||||
> -alpn` or `HttpClient`, never with this curl build.
|
||||
|
||||
# Evidence
|
||||
|
||||
Incident of 2026-07-27, 17:25–17:29 CEST. Browser-side dependencies during the freeze:
|
||||
|
||||
| Request | Duration | Status |
|
||||
| --- | --- | --- |
|
||||
| `/admin/presence?_rsc=…` | 151,461 ms | 200 |
|
||||
| `/gather-rules?_rsc=…` | 52,283 ms | 200 |
|
||||
| `/progress?_rsc=…` | 37,878 ms | 200 |
|
||||
| `/analyze-rules?_rsc=…` | 37,559 ms | 200 |
|
||||
| `/audit?_rsc=…` | 37,539 ms | 200 |
|
||||
| Backend API, same window | 1,600–2,200 ms | 200 |
|
||||
|
||||
The three ~37.5 s responses complete within 340 ms of each other — a queue draining, not independent
|
||||
slow requests. The corresponding `browserTimings` row for `/geographic-performance` reports
|
||||
`networkDuration` = 16 ms against `processingDuration` = 225,095 ms: the network delivered the
|
||||
document immediately and the browser then waited nearly four minutes for the `load` event.
|
||||
|
||||
Fourteen-day distribution of RSC prefetches: n = 10,428, p50 = 91 ms, p95 = 896 ms, max = 234,230 ms,
|
||||
38 over 10 s, 21 over 30 s.
|
||||
|
||||
# Outcome, measured 2026-07-28
|
||||
|
||||
Two changes landed within half an hour of each other on 2026-07-27: the SWA plan went Free → Standard
|
||||
at 16:02:44 UTC, and `prefetch={false}` went live at 16:34:13 UTC. Twenty hours of production
|
||||
telemetry separate their effects.
|
||||
|
||||
**The Standard plan produced no measurable latency gain.** Latency is governed almost entirely by the
|
||||
gap to the previous request — the SSR runtime cools down — so raw percentiles only measure the traffic
|
||||
mix. Stratified by that gap, the two eras are indistinguishable (p50, Free → Standard): `<10s`
|
||||
85 → 95 ms · `10-60s` 100 → 117 ms · `1-5min` 295 → 310 ms · `5-30min` 429 → 441 ms. Tail rates match
|
||||
too: Free ran 1.64 % over 2 s and 0.374 % over 10 s (n = 10,163); Standard produced 6 and 1 events at
|
||||
n = 258 against 4.2 and 1.0 expected. `browserTimings.totalDuration` by Mann-Whitney: n = 436 vs 48,
|
||||
z = −0.24, **p = 0.81**.
|
||||
|
||||
**The prefetch fix is what removed the freezes** — worst `browserTimings.totalDuration` fell from
|
||||
225,277 ms to 4,856 ms, and the 30-second events stopped.
|
||||
|
||||
**It also removed an accidental keep-alive.** The prefetch storm kept the SSR runtime warm: 85 % of
|
||||
requests used to arrive less than 10 s apart, now 46 % do. On an identical never-cacheable route
|
||||
(`/sessions/{id}`) the median rose from 87 ms to 211 ms and p90 from 465 ms to 1,480 ms. After 517
|
||||
minutes idle, one request took 11,441 ms and was abandoned (`resultCode 0`). The trade — minute-long
|
||||
freezes gone, ~150 ms added to a cold navigation — is worth it, but it is a trade.
|
||||
|
||||
Do not benchmark this with a synthetic burst. Firing requests in quick succession warms the runtime
|
||||
and measures the best case: in a 25-round `curl --parallel-max 6` run, round 1 came in at p50 288 ms
|
||||
and rounds 4–25 at p50 113 ms.
|
||||
|
||||
# Diagnosing this class of problem
|
||||
|
||||
The backend Application Insights resource is the wrong place to look — a freeze appears there as an
|
||||
*absence* of requests, never as an error. Browser telemetry lives in a **separate** Application
|
||||
Insights resource addressed by `AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`, which the MCP
|
||||
`query_backend_logs` tool does not reach. Query it through
|
||||
`.claude/commands/scripts/query-appinsights.sh` with that ID exported, and read `dependencies`
|
||||
(client-side fetches, including RSC prefetches) and `browserTimings` (the network/processing split of
|
||||
a document load).
|
||||
|
||||
# Contract
|
||||
|
||||
Navigation `<Link>` elements in `GlobalSidebar` and `Navbar` set `prefetch={false}`. The prefetched
|
||||
RSC payload buys nothing for these routes, so the only remaining cost of a click is the route's JS
|
||||
chunk, which the browser caches after first use.
|
||||
|
||||
Do not remove the prop to "speed up navigation" without first re-measuring the tail in the web
|
||||
Application Insights resource — the prefetch is cheap only while the runtime is warm.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx` — nav link rendering, rationale comment
|
||||
* `src/Web/autopilot-monitor-web/components/Navbar.tsx` — top-bar and notification links
|
||||
* [SWA and Oryx node pinning](../architecture.md) — hosting model for the web app
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ESP-006",
|
||||
"title": "ESP Failure Recovered After User Retry",
|
||||
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
|
||||
"severity": "info",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 85,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "advisory",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_advisory",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "recovered",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_recovered",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "retry",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_retry_detected",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "category",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_recovered",
|
||||
"dataField": "category",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "failedSubcategory",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_recovered",
|
||||
"dataField": "failedSubcategory",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "likelyCulpritApps",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_advisory",
|
||||
"dataField": "likelyCulpritApps",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "minutesSinceFailure",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_recovered",
|
||||
"dataField": "minutesSinceFailure",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "retry", "condition": "exists", "weight": 10 },
|
||||
{ "signal": "likelyCulpritApps", "condition": "exists", "weight": 5 }
|
||||
],
|
||||
"explanation": "The Enrollment Status Page reported a **failure** in the `{{category}}` phase (`{{failedSubcategory}}` subcategory, most likely app(s): `{{likelyCulpritApps}}`) — and then **recovered**: the failed step left its failed state and completed successfully about `{{minutesSinceFailure}}` min later.\n\nThat pattern is consistent with the user pressing **'Try again'** on the ESP failure page (the profile allows failure-page actions; the agent cannot observe the click itself). The failure the user saw was real, but it did not stick — the ESP re-ran the step, it succeeded, and the enrollment continued.\n\nRead the session timeline as one arc: `esp_failure_advisory` (the failure, with the likely culprit app) → `esp_failure_retry_detected` (the retry) → `esp_failure_recovered` (the recovery). If this pattern repeats across devices for the same app, that app's install is flaky on first attempt — fix the app rather than the devices. Any literal `{{…}}` placeholder means that detail was not recorded for this session.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Make the first install attempt stick",
|
||||
"steps": [
|
||||
"Check the likely culprit app(s) (`{{likelyCulpritApps}}`) in Intune: an app that fails on the first ESP pass but succeeds on retry usually has a dependency/ordering or detection-timing problem.",
|
||||
"If the app shows 'never started installing' in esp_failure_settle_started, verify its assignment and applicability rules — ESP counted it as required, but IME never processed it.",
|
||||
"If the pattern repeats across the tenant, consider removing the app from the ESP blocking list until the first-attempt install is reliable — users are seeing an avoidable failure screen.",
|
||||
"No device-side action is needed for this session: the failure recovered and the enrollment continued."
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["esp", "recovery", "try-again", "retry", "continue-anyway", "enrollment"]
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-SEC-001",
|
||||
"title": "Secure Boot UEFI CA 2023 certificate not deployed",
|
||||
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed.",
|
||||
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.0.0",
|
||||
"version": "3.0.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
@@ -16,6 +16,14 @@
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"description": "Skip on virtual machines — Secure Boot DB is host-controlled (Hyper-V/VMware/etc), the inside-the-VM UEFICA2023Status registry value is not the right indicator and not actionable for the VM admin."
|
||||
},
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiCa2023FirmwareConfirmed",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"description": "Skip when the firmware db itself confirms the Windows UEFI CA 2023 certificate — the marker is only emitted on positive firmware confirmation, so old agents (field absent) and unconfirmed devices keep evaluating the rule. The servicing registry status lags behind actual firmware state (OEM factory image, WinCS, manual deployment, or WU simply not run yet)."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
@@ -54,13 +62,33 @@
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "db_ca2023_firmware_missing",
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiDbHasWindowsUefiCa2023",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "kek_2023_missing",
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiKekHasMicrosoftKek2kCa2023",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "ca2023_notfound", "condition": "exists", "weight": 20 },
|
||||
{ "signal": "ca2023_error", "condition": "exists", "weight": 10 }
|
||||
{ "signal": "ca2023_error", "condition": "exists", "weight": 10 },
|
||||
{ "signal": "db_ca2023_firmware_missing", "condition": "exists", "weight": 20 },
|
||||
{ "signal": "kek_2023_missing", "condition": "exists", "weight": 10 }
|
||||
],
|
||||
"explanation": "This device has Secure Boot enabled but the **Windows UEFI CA 2023** certificate has not been deployed to the Secure Boot DB.\n\nThe original Windows UEFI CA 2011 certificates **expired on June 27, 2026 — this deadline has passed**. The automatic pre-expiry rollout window is over: this device no longer receives Secure Boot security updates (DB/DBX) and cannot trust 2023-signed boot managers until the 2023 certificate is deployed. Deploying it now requires active remediation (Windows servicing opt-in and, on some models, an OEM firmware update) rather than waiting for an automatic update.\n\nCurrent UEFI CA 2023 status: `{{uefiCA2023Status}}`",
|
||||
"explanation": "This device has Secure Boot enabled but the **Windows UEFI CA 2023** certificate has not been deployed to the Secure Boot DB.\n\nThe original Windows UEFI CA 2011 certificates **expired on June 27, 2026 — this deadline has passed**. The automatic pre-expiry rollout window is over: this device no longer receives Secure Boot security updates (DB/DBX) and cannot trust 2023-signed boot managers until the 2023 certificate is deployed. Deploying it now requires active remediation (Windows servicing opt-in and, on some models, an OEM firmware update) rather than waiting for an automatic update.\n\nOn devices running a current agent this finding is verified against the firmware itself: the agent reads the UEFI `db` signature database directly, and this warning is automatically suppressed when the firmware already contains the 2023 certificate — regardless of the Windows servicing status below.\n\nCurrent UEFI CA 2023 servicing status: `{{uefiCA2023Status}}`",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Deploy UEFI CA 2023 certificate now",
|
||||
@@ -69,7 +97,7 @@
|
||||
"Use Intune policy to enable Secure Boot DB update (Windows UEFI CA 2023)",
|
||||
"Check with the device OEM whether a firmware update is required for the DB update to apply on this model",
|
||||
"Alternatively, use the WinCS CLI tool from Microsoft for manual deployment",
|
||||
"Verify deployment: check registry HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\Servicing\\UEFICA2023Status = 'Updated'",
|
||||
"Verify deployment against the firmware directly: [Text.Encoding]::ASCII.GetString((Get-SecureBootUEFI -Name db).Bytes).Contains('Windows UEFI CA 2023') — this is authoritative; the registry value HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\Servicing\\UEFICA2023Status = 'Updated' only reflects the Windows Update rollout",
|
||||
"After DB update: ensure the boot manager is updated to a 2023-signed version via monthly cumulative update"
|
||||
]
|
||||
}
|
||||
|
||||
+144
-4
@@ -1884,6 +1884,110 @@
|
||||
"user-experience"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-ESP-006",
|
||||
"title": "ESP Failure Recovered After User Retry",
|
||||
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
|
||||
"severity": "info",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 85,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "advisory",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_advisory",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "recovered",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_recovered",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "retry",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_retry_detected",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "category",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_recovered",
|
||||
"dataField": "category",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "failedSubcategory",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_recovered",
|
||||
"dataField": "failedSubcategory",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "likelyCulpritApps",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_advisory",
|
||||
"dataField": "likelyCulpritApps",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "minutesSinceFailure",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_failure_recovered",
|
||||
"dataField": "minutesSinceFailure",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "retry",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "likelyCulpritApps",
|
||||
"condition": "exists",
|
||||
"weight": 5
|
||||
}
|
||||
],
|
||||
"explanation": "The Enrollment Status Page reported a **failure** in the `{{category}}` phase (`{{failedSubcategory}}` subcategory, most likely app(s): `{{likelyCulpritApps}}`) — and then **recovered**: the failed step left its failed state and completed successfully about `{{minutesSinceFailure}}` min later.\n\nThat pattern is consistent with the user pressing **'Try again'** on the ESP failure page (the profile allows failure-page actions; the agent cannot observe the click itself). The failure the user saw was real, but it did not stick — the ESP re-ran the step, it succeeded, and the enrollment continued.\n\nRead the session timeline as one arc: `esp_failure_advisory` (the failure, with the likely culprit app) → `esp_failure_retry_detected` (the retry) → `esp_failure_recovered` (the recovery). If this pattern repeats across devices for the same app, that app's install is flaky on first attempt — fix the app rather than the devices. Any literal `{{…}}` placeholder means that detail was not recorded for this session.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Make the first install attempt stick",
|
||||
"steps": [
|
||||
"Check the likely culprit app(s) (`{{likelyCulpritApps}}`) in Intune: an app that fails on the first ESP pass but succeeds on retry usually has a dependency/ordering or detection-timing problem.",
|
||||
"If the app shows 'never started installing' in esp_failure_settle_started, verify its assignment and applicability rules — ESP counted it as required, but IME never processed it.",
|
||||
"If the pattern repeats across the tenant, consider removing the app from the ESP blocking list until the first-attempt install is reliable — users are seeing an avoidable failure screen.",
|
||||
"No device-side action is needed for this session: the failure recovered and the enrollment continued."
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"esp",
|
||||
"recovery",
|
||||
"try-again",
|
||||
"retry",
|
||||
"continue-anyway",
|
||||
"enrollment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-001",
|
||||
"title": "Expected Machine Certificate Not Found",
|
||||
@@ -2178,10 +2282,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-SEC-001",
|
||||
"title": "Secure Boot UEFI CA 2023 certificate not deployed",
|
||||
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed.",
|
||||
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.0.0",
|
||||
"version": "3.0.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
@@ -2192,6 +2296,14 @@
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"description": "Skip on virtual machines — Secure Boot DB is host-controlled (Hyper-V/VMware/etc), the inside-the-VM UEFICA2023Status registry value is not the right indicator and not actionable for the VM admin."
|
||||
},
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiCa2023FirmwareConfirmed",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"description": "Skip when the firmware db itself confirms the Windows UEFI CA 2023 certificate — the marker is only emitted on positive firmware confirmation, so old agents (field absent) and unconfirmed devices keep evaluating the rule. The servicing registry status lags behind actual firmware state (OEM factory image, WinCS, manual deployment, or WU simply not run yet)."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
@@ -2230,6 +2342,24 @@
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "db_ca2023_firmware_missing",
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiDbHasWindowsUefiCa2023",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "kek_2023_missing",
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiKekHasMicrosoftKek2kCa2023",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -2242,9 +2372,19 @@
|
||||
"signal": "ca2023_error",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "db_ca2023_firmware_missing",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "kek_2023_missing",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "This device has Secure Boot enabled but the **Windows UEFI CA 2023** certificate has not been deployed to the Secure Boot DB.\n\nThe original Windows UEFI CA 2011 certificates **expired on June 27, 2026 — this deadline has passed**. The automatic pre-expiry rollout window is over: this device no longer receives Secure Boot security updates (DB/DBX) and cannot trust 2023-signed boot managers until the 2023 certificate is deployed. Deploying it now requires active remediation (Windows servicing opt-in and, on some models, an OEM firmware update) rather than waiting for an automatic update.\n\nCurrent UEFI CA 2023 status: `{{uefiCA2023Status}}`",
|
||||
"explanation": "This device has Secure Boot enabled but the **Windows UEFI CA 2023** certificate has not been deployed to the Secure Boot DB.\n\nThe original Windows UEFI CA 2011 certificates **expired on June 27, 2026 — this deadline has passed**. The automatic pre-expiry rollout window is over: this device no longer receives Secure Boot security updates (DB/DBX) and cannot trust 2023-signed boot managers until the 2023 certificate is deployed. Deploying it now requires active remediation (Windows servicing opt-in and, on some models, an OEM firmware update) rather than waiting for an automatic update.\n\nOn devices running a current agent this finding is verified against the firmware itself: the agent reads the UEFI `db` signature database directly, and this warning is automatically suppressed when the firmware already contains the 2023 certificate — regardless of the Windows servicing status below.\n\nCurrent UEFI CA 2023 servicing status: `{{uefiCA2023Status}}`",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Deploy UEFI CA 2023 certificate now",
|
||||
@@ -2253,7 +2393,7 @@
|
||||
"Use Intune policy to enable Secure Boot DB update (Windows UEFI CA 2023)",
|
||||
"Check with the device OEM whether a firmware update is required for the DB update to apply on this model",
|
||||
"Alternatively, use the WinCS CLI tool from Microsoft for manual deployment",
|
||||
"Verify deployment: check registry HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\Servicing\\UEFICA2023Status = 'Updated'",
|
||||
"Verify deployment against the firmware directly: [Text.Encoding]::ASCII.GetString((Get-SecureBootUEFI -Name db).Bytes).Contains('Windows UEFI CA 2023') — this is authoritative; the registry value HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\Servicing\\UEFICA2023Status = 'Updated' only reflects the Windows Update rollout",
|
||||
"After DB update: ensure the boot manager is updated to a 2023-signed version via monthly cumulative update"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"prefixes": [
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup",
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\OOBE",
|
||||
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon"
|
||||
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon",
|
||||
"SOFTWARE\\Microsoft\\Windows\\Autopilot"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+48
@@ -285,6 +285,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(@"C:\Temp\custom-ime.log", agent.ImeMatchLogPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_maps_enable_gather_rule_debug_log_true_to_default_path()
|
||||
{
|
||||
var agent = NewAgentConfig();
|
||||
agent.GatherRuleDebugLogPath = null;
|
||||
|
||||
var remote = FullRemote();
|
||||
remote.EnableGatherRuleDebugLog = true;
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(agent.GatherRuleDebugLogPath));
|
||||
Assert.Equal(
|
||||
Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath),
|
||||
agent.GatherRuleDebugLogPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_maps_enable_gather_rule_debug_log_false_clears_path()
|
||||
{
|
||||
var agent = NewAgentConfig();
|
||||
agent.GatherRuleDebugLogPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
|
||||
var remote = FullRemote();
|
||||
remote.EnableGatherRuleDebugLog = false;
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.Null(agent.GatherRuleDebugLogPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_preserves_custom_gather_rule_debug_log_path_when_remote_disables()
|
||||
{
|
||||
// A dev-time --gather-debug-log <custom> override seeds a non-default path on the
|
||||
// AgentConfiguration. When the tenant remote flips EnableGatherRuleDebugLog off we
|
||||
// keep the custom path rather than clobbering a deliberate dev override.
|
||||
var agent = NewAgentConfig();
|
||||
agent.GatherRuleDebugLogPath = @"C:\Temp\custom-gather.log";
|
||||
|
||||
var remote = FullRemote();
|
||||
remote.EnableGatherRuleDebugLog = false;
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.Equal(@"C:\Temp\custom-gather.log", agent.GatherRuleDebugLogPath);
|
||||
}
|
||||
|
||||
// ============================================================= Null / partial tolerance
|
||||
|
||||
[Fact]
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using DeviceInfoCollector = AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.DeviceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Audit Q2 — <see cref="DeviceInfoCollector.RefreshEspConfiguration"/>
|
||||
/// re-reads the ESP blocking lists and re-emits <c>esp_config_detected</c> exactly when the
|
||||
/// payload changed: the registry lists grow progressively (one timestamped subkey per CSP
|
||||
/// status write) and the user-scope lists appear only after sign-in, so the early emissions
|
||||
/// are structurally partial. The StartupEventGate keeps an unchanged re-read silent.
|
||||
/// Probe overrides are static slots → serialized with the other probe-driven suites.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class DeviceInfoCollectorEspRefreshTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 7, 27, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string DeviceApp1 = "11111111-1111-1111-1111-111111111111";
|
||||
private const string DeviceApp2 = "22222222-2222-2222-2222-222222222222";
|
||||
private const string UserApp1 = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
private static EspTrackingInfoSnapshot Snapshot(string[] deviceApps, string[] userApps)
|
||||
{
|
||||
var all = deviceApps.Concat(userApps).Distinct().ToArray();
|
||||
return new EspTrackingInfoSnapshot(
|
||||
msiProductCodes: Array.Empty<string>(),
|
||||
modernAppPfns: Array.Empty<string>(),
|
||||
win32AppIds: all,
|
||||
userWin32AppIds: userApps,
|
||||
msiCount: 0,
|
||||
modernCount: 0,
|
||||
win32Count: all.Length);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> EspConfigEvents(FakeSignalIngressSink ingress) =>
|
||||
ingress.Posted
|
||||
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == SharedEventTypes.EspConfigDetected)
|
||||
.ToList();
|
||||
|
||||
private static IReadOnlyList<string> TypedList(FakeSignalIngressSink.PostedSignal signal, string key)
|
||||
{
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(signal.TypedPayload);
|
||||
return Assert.IsAssignableFrom<IReadOnlyList<string>>(data[key]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refresh_reemits_when_lists_grew_and_stays_silent_when_unchanged()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(T0);
|
||||
var collector = new DeviceInfoCollector(
|
||||
"session-1", "tenant-1",
|
||||
new InformationalEventPost(ingress, clock), logger,
|
||||
signalIngress: null, clock: clock,
|
||||
startupGate: new StartupEventGate(tmp.Path, logger));
|
||||
|
||||
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
|
||||
_ => new EspFirstSyncSnapshot(
|
||||
skipUser: false, skipDevice: false,
|
||||
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
|
||||
|
||||
// Early read (agent start / DeviceSetup detection): partial device list, empty user list.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1 }, Array.Empty<string>())))
|
||||
{
|
||||
collector.CollectEspConfiguration();
|
||||
}
|
||||
var afterEarly = EspConfigEvents(ingress);
|
||||
Assert.Single(afterEarly);
|
||||
Assert.Empty(TypedList(afterEarly[0], "espTrackedUserWin32AppIds"));
|
||||
|
||||
// Apps sub-phase opened: the device list grew and sign-in populated the user list.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1, DeviceApp2 }, new[] { UserApp1 })))
|
||||
{
|
||||
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAppsUser);
|
||||
}
|
||||
var afterGrowth = EspConfigEvents(ingress);
|
||||
Assert.Equal(2, afterGrowth.Count);
|
||||
Assert.Contains(DeviceApp2, TypedList(afterGrowth[1], "espTrackedWin32AppIds"));
|
||||
Assert.Equal(new[] { UserApp1 }, TypedList(afterGrowth[1], "espTrackedUserWin32AppIds"));
|
||||
|
||||
// A later trigger with an UNCHANGED registry state re-reads but emits nothing —
|
||||
// the StartupEventGate suppresses the identical payload.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1, DeviceApp2 }, new[] { UserApp1 })))
|
||||
{
|
||||
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAccountSetup);
|
||||
}
|
||||
Assert.Equal(2, EspConfigEvents(ingress).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_refreshes_never_emit_an_older_snapshot_after_a_newer_one()
|
||||
{
|
||||
// Codex review: the refresh triggers each run on their own Task.Run — without the
|
||||
// collector-side lock, two collects can interleave read-before-emit and the LATER
|
||||
// emission carries the OLDER (smaller) list. The backend keeps the latest
|
||||
// list-bearing emission as positive evidence, so that would silently shrink it.
|
||||
// The probe returns a strictly growing list per read; with the read→emit critical
|
||||
// section, emission order must equal read order — sizes strictly ascending.
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(T0);
|
||||
var collector = new DeviceInfoCollector(
|
||||
"session-1", "tenant-1",
|
||||
new InformationalEventPost(ingress, clock), logger,
|
||||
signalIngress: null, clock: clock,
|
||||
startupGate: new StartupEventGate(tmp.Path, logger));
|
||||
|
||||
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
|
||||
_ => new EspFirstSyncSnapshot(
|
||||
skipUser: false, skipDevice: false,
|
||||
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
|
||||
|
||||
var readCounter = 0;
|
||||
using var tracking = new EspTrackingInfoProbe.ScopedOverride(_ =>
|
||||
{
|
||||
var n = Interlocked.Increment(ref readCounter);
|
||||
var ids = Enumerable.Range(1, n)
|
||||
.Select(i => $"{i:d8}-1111-2222-3333-444444444444")
|
||||
.ToArray();
|
||||
return Snapshot(ids, Array.Empty<string>());
|
||||
});
|
||||
|
||||
var tasks = Enumerable.Range(0, 12)
|
||||
.Select(i => Task.Run(() => collector.RefreshEspConfiguration($"stress-{i}")))
|
||||
.ToArray();
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
var sizes = EspConfigEvents(ingress)
|
||||
.Select(e => TypedList(e, "espTrackedWin32AppIds").Count)
|
||||
.ToList();
|
||||
// Every refresh saw a strictly larger list (gate never suppresses) and each
|
||||
// read→emit is atomic, so the emission sequence is exactly 1..12.
|
||||
Assert.Equal(Enumerable.Range(1, 12), sizes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
logFolder: Tmp.Path,
|
||||
patterns: new List<ImeLogPattern>(),
|
||||
logger: Logger);
|
||||
// Mirror ImeLogHost wiring: the historic-replay guard judges staleness against the
|
||||
// (virtual) agent clock so tests stay deterministic.
|
||||
Tracker.UtcNowProvider = () => Clock.UtcNow;
|
||||
}
|
||||
|
||||
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> DecisionSignals(DecisionSignalKind kind) =>
|
||||
|
||||
+139
@@ -405,5 +405,144 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
var started = trace.Events.Find(e => e.EventType == SharedEventTypes.DesktopDetectorStarted);
|
||||
Assert.Equal(0, started.Data["noCandidateThresholdMinutes"]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OOBE-state flip (oobe_state_completed) — observational one-shot on the poll
|
||||
// ============================================================================
|
||||
// The WinRT OutOfBoxExperienceState flip in_progress->completed is an owner-
|
||||
// independent desktop corroboration for DAD-starved sessions (4d5a0b78 class).
|
||||
// Tests drive polls synchronously via PollOnceForTest after Stop() so the real
|
||||
// timer cannot race, and pin the owner seams to an excluded user so a real
|
||||
// explorer.exe on the test machine can never set _desktopArrived (which would
|
||||
// short-circuit PollForDesktop before the flip check).
|
||||
|
||||
private static DesktopArrivalDetector BuildFlipDetector(TempDirectory tmp, TraceCapture trace, System.Func<string> oobeProvider)
|
||||
{
|
||||
var detector = new DesktopArrivalDetector(new AgentLogger(tmp.Path, AgentLogLevel.Info), noCandidateTimeoutMinutes: 0)
|
||||
{
|
||||
OnTraceEvent = trace.Sink,
|
||||
OobeStateProvider = oobeProvider,
|
||||
SessionOwnerResolver = _ => "SYSTEM",
|
||||
ProcessOwnerResolver = _ => "SYSTEM",
|
||||
};
|
||||
return detector;
|
||||
}
|
||||
|
||||
/// <summary>Returns the queued states in order, then repeats the last one forever.</summary>
|
||||
private static System.Func<string> StateQueue(params string[] states)
|
||||
{
|
||||
var queue = new Queue<string>(states);
|
||||
var last = states[states.Length - 1];
|
||||
return () => queue.Count > 0 ? queue.Dequeue() : last;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OobeFlip_emits_exactly_once_with_payload()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var trace = new TraceCapture();
|
||||
// Baseline sample happens synchronously inside Start()/ArmTimer.
|
||||
using var detector = BuildFlipDetector(tmp, trace, StateQueue("in_progress", "completed"));
|
||||
|
||||
detector.Start();
|
||||
detector.Stop();
|
||||
detector.PollOnceForTest(); // reads "completed" -> flip
|
||||
detector.PollOnceForTest(); // still "completed" -> no second emit
|
||||
|
||||
Assert.Equal(1, trace.CountOf(SharedEventTypes.OobeStateCompleted));
|
||||
var flip = trace.Events.Find(e => e.EventType == SharedEventTypes.OobeStateCompleted);
|
||||
Assert.Equal("in_progress", flip.Data["previousState"]);
|
||||
Assert.True(flip.Data.ContainsKey("pollCount"));
|
||||
Assert.True(flip.Data.ContainsKey("minutesSinceStart"));
|
||||
Assert.False((bool)flip.Data["desktopArrived"]);
|
||||
Assert.True(flip.Data.ContainsKey("wmiErrorCount"));
|
||||
Assert.True(flip.Data.ContainsKey("wtsErrorCount"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OobeFlip_does_not_emit_when_state_is_already_completed()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var trace = new TraceCapture();
|
||||
using var detector = BuildFlipDetector(tmp, trace, StateQueue("completed"));
|
||||
|
||||
detector.Start();
|
||||
detector.Stop();
|
||||
detector.PollOnceForTest();
|
||||
detector.PollOnceForTest();
|
||||
|
||||
// Productive machines read "completed" from the very first sample — the flip
|
||||
// event must never fire there (the agent_started field already records the state).
|
||||
Assert.Equal(0, trace.CountOf(SharedEventTypes.OobeStateCompleted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OobeFlip_does_not_emit_when_in_progress_was_never_seen()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var trace = new TraceCapture();
|
||||
using var detector = BuildFlipDetector(tmp, trace, StateQueue("unavailable", "completed"));
|
||||
|
||||
detector.Start();
|
||||
detector.Stop();
|
||||
detector.PollOnceForTest();
|
||||
|
||||
// Old builds without the WinRT contract report "unavailable"; a later
|
||||
// "completed" without a positively observed "in_progress" is not a flip.
|
||||
Assert.Equal(0, trace.CountOf(SharedEventTypes.OobeStateCompleted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OobeFlip_survives_transient_unavailable_between_polls()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var trace = new TraceCapture();
|
||||
using var detector = BuildFlipDetector(tmp, trace, StateQueue("in_progress", "unavailable", "completed"));
|
||||
|
||||
detector.Start();
|
||||
detector.Stop();
|
||||
detector.PollOnceForTest(); // transient reflection failure — must NOT clear the baseline
|
||||
detector.PollOnceForTest(); // "completed" -> flip still detected
|
||||
|
||||
Assert.Equal(1, trace.CountOf(SharedEventTypes.OobeStateCompleted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OobeFlip_provider_throwing_never_emits_and_never_pollutes_wmi_error_count()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var trace = new TraceCapture();
|
||||
using var detector = BuildFlipDetector(tmp, trace, () => throw new System.InvalidOperationException("boom"));
|
||||
|
||||
detector.Start(); // baseline sample throws -> swallowed
|
||||
detector.Stop();
|
||||
detector.PollOnceForTest();
|
||||
detector.PollOnceForTest();
|
||||
|
||||
Assert.Equal(0, trace.CountOf(SharedEventTypes.OobeStateCompleted));
|
||||
// The flip check has its own try/catch — a throw must not fall into
|
||||
// PollForDesktop's outer catch, which increments the WMI error counter the
|
||||
// session-4d5a0b78 liveness diagnostics rely on. first_poll carries that counter.
|
||||
var firstPoll = trace.Events.Find(e => e.EventType == SharedEventTypes.DesktopDetectorFirstPoll);
|
||||
Assert.Equal(0, firstPoll.Data["wmiErrorCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OobeFlip_one_shot_flag_survives_ResetForRealUserSwitch()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var trace = new TraceCapture();
|
||||
using var detector = BuildFlipDetector(tmp, trace,
|
||||
StateQueue("in_progress", "completed", "in_progress", "completed"));
|
||||
|
||||
detector.Start();
|
||||
detector.Stop();
|
||||
detector.PollOnceForTest(); // flip #1
|
||||
detector.ResetForRealUserSwitch(); // re-arms + re-samples baseline ("in_progress")
|
||||
detector.Stop();
|
||||
detector.PollOnceForTest(); // "completed" again — must NOT re-emit
|
||||
|
||||
Assert.Equal(1, trace.CountOf(SharedEventTypes.OobeStateCompleted));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -290,9 +290,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
[InlineData(@"SAM\SAM\Domains\Account\Users")]
|
||||
[InlineData(@"SECURITY\Policy\Secrets")]
|
||||
[InlineData(@"HKLM\SOFTWARE\Microsoft\EnrollmentsSomethingElse")] // prefix spoofing
|
||||
[InlineData(@"HKLM\SOFTWARE\Microsoft\Windows\AutopilotSomethingElse")] // sibling spoof of the Autopilot prefix
|
||||
public void RegistryCollector_OffAllowlistPath_IsBlockedBeforeTheHiveIsOpened(string target)
|
||||
=> AssertBlocked(new RegistryCollector().Execute(Rule("registry", target), Context()), target);
|
||||
|
||||
// Session 5d735290: the EnrollmentStatusTracking subtree (ESP policy providers / tracking
|
||||
// state) is allow-listed so tenants can build gather rules on the co-management stall
|
||||
// signature the esp_policy_provider_stalled detector reports (e.g. not_exists on the
|
||||
// Sidecar provider key).
|
||||
[Theory]
|
||||
[InlineData(@"SOFTWARE\Microsoft\Windows\Autopilot")]
|
||||
[InlineData(@"SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\Device\Setup\Apps\PolicyProviders\Sidecar")]
|
||||
public void RegistryGuard_AutopilotEnrollmentTrackingSubtree_IsAllowed(string subPath)
|
||||
=> Assert.True(GatherRuleGuards.IsRegistryPathAllowed(subPath));
|
||||
|
||||
[Theory]
|
||||
[InlineData("SELECT * FROM Win32_UserAccount")]
|
||||
[InlineData("SELECT * FROM Win32_ShadowCopy")]
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Writer unit tests for <see cref="GatherRuleDebugLog"/>: line format, lazy directory
|
||||
/// creation, size-cap rotation, and the null-writer no-op path on the context.
|
||||
/// </summary>
|
||||
public sealed class GatherRuleDebugLogTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public GatherRuleDebugLogTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private string LogPath => Path.Combine(_tmp.Path, "sub", "gather_rules_debug.log");
|
||||
|
||||
[Fact]
|
||||
public void Write_produces_pipe_delimited_line_and_creates_directory_lazily()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger);
|
||||
Assert.False(File.Exists(LogPath)); // lazy — nothing until the first write
|
||||
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageScope, "skipped: out of scope");
|
||||
|
||||
var lines = File.ReadAllLines(LogPath);
|
||||
Assert.Equal(2, lines.Length); // header + entry
|
||||
Assert.Contains("| - | config | -- gather rule debug log started --", lines[0]);
|
||||
var parts = lines[1].Split(new[] { " | " }, StringSplitOptions.None);
|
||||
Assert.Equal(4, parts.Length);
|
||||
Assert.Equal("RULE-1", parts[1]);
|
||||
Assert.Equal("scope", parts[2]);
|
||||
Assert.Equal("skipped: out of scope", parts[3]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_flattens_newlines_except_for_error_stage()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger);
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageCollector, "line1\r\nline2");
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageError, "boom\r\n at Frame()");
|
||||
|
||||
var content = File.ReadAllText(LogPath);
|
||||
Assert.Contains("line1 line2", content);
|
||||
Assert.Contains(" at Frame()", content); // error stage keeps the stack multi-line
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_null_ruleId_renders_dash()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger);
|
||||
log.Write(null, GatherRuleDebugLog.StageConfig, "no rules delivered");
|
||||
|
||||
Assert.Contains(" | - | config | no rules delivered", File.ReadAllText(LogPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_rotates_once_when_file_exceeds_cap()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger, maxBytes: 512);
|
||||
for (int i = 0; i < 20; i++)
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageExec, new string('x', 100));
|
||||
|
||||
var oldPath = Path.Combine(Path.GetDirectoryName(LogPath), "gather_rules_debug.old.log");
|
||||
Assert.True(File.Exists(oldPath), "expected one .old rotation generation");
|
||||
Assert.True(File.Exists(LogPath));
|
||||
Assert.Contains("-- rotated", File.ReadAllText(LogPath) + File.ReadAllText(oldPath));
|
||||
// Never more than one .old generation
|
||||
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(LogPath), "*.old.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteStandalone_writes_single_config_line()
|
||||
{
|
||||
GatherRuleDebugLog.WriteStandalone(LogPath, "no gather rules delivered by backend — nothing to execute", _logger);
|
||||
|
||||
var lines = File.ReadAllLines(LogPath);
|
||||
Assert.Contains(lines, l => l.Contains("no gather rules delivered by backend"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_DebugLog_is_noop_without_writer()
|
||||
{
|
||||
var context = new GatherRuleContext(
|
||||
_logger, "sess", "tenant", _ => { }, null, new LogFilePositionTracker());
|
||||
|
||||
context.DebugLog("RULE-1", GatherRuleDebugLog.StageScope, "should not throw, should not write");
|
||||
|
||||
Assert.False(File.Exists(LogPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Debug-trace coverage of <see cref="GatherRuleExecutor"/> (EnableGatherRuleDebugLog):
|
||||
/// every "silently produced nothing" outcome must leave an explanatory line in the trace
|
||||
/// file — registration summary, missing interval, scope skips, on_change suppression,
|
||||
/// empty collector results.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorDebugLogTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly GatherRuleExecutor _executor;
|
||||
private readonly string _debugLogPath;
|
||||
|
||||
private const string AbsentPath =
|
||||
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_7e3a1f4b-0000-0000-0000-000000000000";
|
||||
|
||||
public GatherRuleExecutorDebugLogTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
|
||||
_executor = new GatherRuleExecutor(
|
||||
"sess", "tenant", _ => { }, logger,
|
||||
debugLogPath: _debugLogPath)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private string TraceContent() => File.Exists(_debugLogPath) ? File.ReadAllText(_debugLogPath) : string.Empty;
|
||||
|
||||
private static GatherRule Rule(string id, Action<GatherRule>? mutate = null)
|
||||
{
|
||||
var rule = new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
};
|
||||
mutate?.Invoke(rule);
|
||||
return rule;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateRules_writes_registration_summary_per_rule()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-REG-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.EmitMode = "on_change"; }),
|
||||
Rule("GATHER-REG-002", r => r.Enabled = false),
|
||||
});
|
||||
_executor.WaitForStartupRules(30);
|
||||
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("GATHER-REG-001 | config | registered: trigger=interval, collector=registry", trace);
|
||||
Assert.Contains("emitMode=on_change", trace);
|
||||
Assert.Contains("interval=3600s", trace);
|
||||
Assert.Contains("GATHER-REG-002 | config | rule disabled — will never run", trace);
|
||||
Assert.Contains("interval timer scheduled every 3600s (first run after one full interval", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateRules_flags_interval_rule_without_intervalSeconds()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-NOINT-001", r => { r.Trigger = "interval"; r.IntervalSeconds = null; }),
|
||||
});
|
||||
|
||||
Assert.Contains("GATHER-NOINT-001 | error | trigger=interval but intervalSeconds is missing — rule will NEVER run",
|
||||
TraceContent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopedRule_skip_traces_unknown_phase_reason()
|
||||
{
|
||||
var rule = Rule("GATHER-SCOPE-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.ActivePhases = new List<string> { "DeviceSetup" }; });
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
// Synchronous internal check mirrors the timer-tick gate; the reason must name Unknown.
|
||||
string reason;
|
||||
Assert.False(_executor.IsRuleInScope(rule, out reason));
|
||||
Assert.Contains("currentPhase=Unknown", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopedStartupRule_deferral_is_traced()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-DEFER-001", r => r.ActivePhases = new List<string> { "DeviceSetup" }),
|
||||
});
|
||||
|
||||
Assert.Contains("GATHER-DEFER-001 | scope | startup rule deferred — currentPhase=Unknown", TraceContent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnChange_suppression_and_reemission_are_traced()
|
||||
{
|
||||
var rule = Rule("GATHER-OC-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.EmitMode = "on_change"; });
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
string hashPrefix;
|
||||
int streak;
|
||||
var first = new Dictionary<string, object> { ["exists"] = false };
|
||||
Assert.True(_executor.ShouldEmitOnChange(rule, first, out hashPrefix, out streak));
|
||||
Assert.Equal(0, streak);
|
||||
Assert.False(string.IsNullOrEmpty(hashPrefix));
|
||||
|
||||
Assert.False(_executor.ShouldEmitOnChange(rule, new Dictionary<string, object> { ["exists"] = false }, out hashPrefix, out streak));
|
||||
Assert.Equal(1, streak);
|
||||
Assert.False(_executor.ShouldEmitOnChange(rule, new Dictionary<string, object> { ["exists"] = false }, out hashPrefix, out streak));
|
||||
Assert.Equal(2, streak);
|
||||
|
||||
var changed = new Dictionary<string, object> { ["exists"] = true };
|
||||
Assert.True(_executor.ShouldEmitOnChange(rule, changed, out hashPrefix, out streak));
|
||||
Assert.Equal(2, streak); // the streak that just ended
|
||||
Assert.Equal(2, changed["suppressedPolls"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyCollectorResult_is_traced_via_startup_execution()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-EMPTY-001", r => r.Parameters = new Dictionary<string, string> { ["emitOnlyIfExists"] = "true" }),
|
||||
});
|
||||
Assert.True(_executor.WaitForStartupRules(30));
|
||||
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("GATHER-EMPTY-001 | exec | executing: collector=registry", trace);
|
||||
Assert.Contains("GATHER-EMPTY-001 | collector | registry key/value not found", trace);
|
||||
Assert.Contains("GATHER-EMPTY-001 | collector | collector returned EMPTY result — nothing emitted", trace);
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Debug-trace coverage of <see cref="LogParserCollector"/>: the classic
|
||||
/// "rule delivers nothing" cases must be explained in the trace — zero matches,
|
||||
/// position-tracker skips, and plain-text logs parsed in the default cmtrace mode.
|
||||
/// </summary>
|
||||
public sealed class LogParserCollectorDebugLogTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly GatherRuleContext _context;
|
||||
private readonly string _debugLogPath;
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — parse targets sit beside the
|
||||
// test assembly instead, admitted via UnrestrictedMode.
|
||||
private readonly string _outsideUsers = Path.Combine(
|
||||
AppContext.BaseDirectory, "gather-debug-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public LogParserCollectorDebugLogTests()
|
||||
{
|
||||
Directory.CreateDirectory(_outsideUsers);
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
|
||||
_context = new GatherRuleContext(
|
||||
logger, "sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
null,
|
||||
new LogFilePositionTracker(),
|
||||
new GatherRuleDebugLog(_debugLogPath, logger))
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
try { Directory.Delete(_outsideUsers, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private string TraceContent() => File.Exists(_debugLogPath) ? File.ReadAllText(_debugLogPath) : string.Empty;
|
||||
|
||||
private static GatherRule Rule(string target, string pattern, string? format = null) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-LP-DEBUG",
|
||||
Title = "logparser debug test",
|
||||
CollectorType = "logparser",
|
||||
Target = target,
|
||||
Parameters = format == null
|
||||
? new Dictionary<string, string> { ["pattern"] = pattern }
|
||||
: new Dictionary<string, string> { ["pattern"] = pattern, ["format"] = format },
|
||||
Trigger = "startup",
|
||||
OutputEventType = "logparser_debug_test",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ZeroMatches_writes_per_file_outcome_line()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "app.log");
|
||||
File.WriteAllText(path, "nothing interesting\nstill nothing\n");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "WILL_NEVER_MATCH_[0-9]+", format: "text"), _context);
|
||||
|
||||
Assert.Empty(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("app.log: read 2 lines", trace);
|
||||
Assert.Contains("matched 0", trace);
|
||||
Assert.Contains("mode=text", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondRun_without_new_content_traces_position_skip()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "twice.log");
|
||||
File.WriteAllText(path, "one line\n");
|
||||
var rule = Rule(path, "one", format: "text");
|
||||
|
||||
var collector = new LogParserCollector();
|
||||
collector.Execute(rule, _context); // reads the file, advances the position
|
||||
collector.Execute(rule, _context); // nothing new
|
||||
|
||||
Assert.Contains("twice.log: no new content (position", TraceContent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlainTextFile_in_default_cmtrace_mode_gets_format_hint()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "plain.log");
|
||||
File.WriteAllText(path, "plain line one\nplain line two\n");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "plain"), _context); // default = cmtrace mode
|
||||
|
||||
Assert.Empty(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("parseFailures=2", trace);
|
||||
Assert.Contains("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingFile_traces_no_files_matched()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "does-not-exist.log");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "x", format: "text"), _context);
|
||||
|
||||
Assert.Contains("no files matched target", TraceContent());
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracker-level historic-replay guard (session eaf3d8c4 part 2): source lines > 24 h older
|
||||
/// than now are content from a previous enrollment whose IME log survived on disk. The guard
|
||||
/// in <c>HandlePatternMatch</c> skips app-mutating actions for such lines so replayed apps
|
||||
/// never enter <c>_packageStates</c> / phase snapshots / persistence — keeping
|
||||
/// app_tracking_summary, culprit lists and final-status clean. Script actions pass through
|
||||
/// (the adapter suppresses their emissions), and SimulationMode bypasses the guard entirely.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerHistoricReplayGuardTests
|
||||
{
|
||||
private static readonly DateTime Now = new DateTime(2026, 7, 23, 15, 42, 0, DateTimeKind.Utc);
|
||||
|
||||
private static List<ImeLogPattern> TestPatterns() => new List<ImeLogPattern>
|
||||
{
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-POLICIES", Category = "always", Enabled = true,
|
||||
Pattern = @"policies discovered = (?<policies>\[.*\])",
|
||||
Action = "policiesDiscovered",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-INSTALLED", Category = "always", Enabled = true,
|
||||
Pattern = @"app (?<id>[\w-]+) installed",
|
||||
Action = "updateStateInstalled",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-ESP-PHASE", Category = "always", Enabled = true,
|
||||
Pattern = @"In EspPhase: (?<espPhase>\w+)",
|
||||
Action = "espPhaseDetected",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-SCRIPT-START", Category = "always", Enabled = true,
|
||||
Pattern = @"script (?<id>[\w-]+) started",
|
||||
Action = "scriptStarted",
|
||||
Parameters = new Dictionary<string, string> { ["scriptType"] = "platform" },
|
||||
},
|
||||
};
|
||||
|
||||
private static ImeLogTracker BuildTracker(TempDirectory tmp)
|
||||
{
|
||||
var tracker = new ImeLogTracker(
|
||||
logFolder: tmp.Path,
|
||||
patterns: TestPatterns(),
|
||||
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info));
|
||||
tracker.UtcNowProvider = () => Now;
|
||||
return tracker;
|
||||
}
|
||||
|
||||
private const string PoliciesLine = @"policies discovered = [{""Id"":""app-1"",""Name"":""App One""}]";
|
||||
|
||||
[Fact]
|
||||
public void Stale_policies_line_does_not_populate_package_states()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = BuildTracker(tmp);
|
||||
|
||||
tracker.ProcessLogMessageForTest(PoliciesLine, Now.AddDays(-7));
|
||||
|
||||
Assert.Empty(tracker.GetAllKnownPackageStates());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fresh_relog_of_same_policies_is_tracked_after_stale_skip()
|
||||
{
|
||||
// IME re-logs the full policy JSON on every app-policy check-in — skipping the
|
||||
// stale copy cannot starve the current enrollment.
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = BuildTracker(tmp);
|
||||
|
||||
tracker.ProcessLogMessageForTest(PoliciesLine, Now.AddDays(-7));
|
||||
tracker.ProcessLogMessageForTest(PoliciesLine, Now.AddMinutes(-2));
|
||||
|
||||
var all = tracker.GetAllKnownPackageStates();
|
||||
var pkg = Assert.Single(all);
|
||||
Assert.Equal("app-1", pkg.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stale_state_update_against_fresh_app_is_skipped()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = BuildTracker(tmp);
|
||||
var stateChanges = 0;
|
||||
tracker.OnAppStateChanged = (_, __, ___) => stateChanges++;
|
||||
|
||||
tracker.ProcessLogMessageForTest(PoliciesLine, Now.AddMinutes(-5)); // fresh discovery
|
||||
tracker.ProcessLogMessageForTest("app app-1 installed", Now.AddDays(-7)); // stale mutation
|
||||
|
||||
Assert.Equal(0, stateChanges);
|
||||
var pkg = Assert.Single(tracker.GetAllKnownPackageStates());
|
||||
Assert.NotEqual(AppInstallationState.Installed, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stale_esp_phase_line_does_not_block_fresh_phase_detection()
|
||||
{
|
||||
// A stale "AccountSetup" would advance _currentPhaseOrder and make the fresh
|
||||
// enrollment's DeviceSetup bounce as "backward" — the guard must skip it.
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = BuildTracker(tmp);
|
||||
var phases = new List<string>();
|
||||
tracker.OnEspPhaseChanged = p => phases.Add(p);
|
||||
|
||||
tracker.ProcessLogMessageForTest("In EspPhase: AccountSetup", Now.AddDays(-7));
|
||||
tracker.ProcessLogMessageForTest("In EspPhase: DeviceSetup", Now.AddMinutes(-1));
|
||||
|
||||
Assert.Equal(new[] { "DeviceSetup" }, phases);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stale_script_line_still_reaches_script_handlers()
|
||||
{
|
||||
// Script tracker state is harmless for stale lines (adapter suppresses the
|
||||
// emissions; the stale-slot hardening covers leftovers) — the guard must not
|
||||
// swallow script actions.
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = BuildTracker(tmp);
|
||||
var started = new List<ScriptStartedInfo>();
|
||||
tracker.OnScriptStarted = i => started.Add(i);
|
||||
|
||||
tracker.ProcessLogMessageForTest("script aaaa1111-0000-0000-0000-000000000000 started", Now.AddDays(-7));
|
||||
|
||||
Assert.Single(started);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimulationMode_bypasses_the_guard()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = BuildTracker(tmp);
|
||||
tracker.SimulationMode = true;
|
||||
|
||||
tracker.ProcessLogMessageForTest(PoliciesLine, Now.AddDays(-7));
|
||||
|
||||
Assert.Single(tracker.GetAllKnownPackageStates());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lines_without_timestamp_are_not_treated_as_stale()
|
||||
{
|
||||
// Non-CMTrace lines carry no source timestamp (entry == null) — never stale.
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = BuildTracker(tmp);
|
||||
|
||||
tracker.ProcessLogMessageForTest(PoliciesLine, sourceTimestampUtc: null);
|
||||
|
||||
Assert.Single(tracker.GetAllKnownPackageStates());
|
||||
}
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
#nullable enable
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the pure netsh-output parser of the WiFi fallback path. netsh labels follow
|
||||
/// the OS UI language — the parser must extract SSID/signal/radio/channel from the label
|
||||
/// variants of the languages commonly seen in enrollments, and degrade gracefully (fields
|
||||
/// absent, never throw) on unrecognized ones.
|
||||
/// </summary>
|
||||
public sealed class NetshWifiFallbackTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_english_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
There is 1 interface on the system:
|
||||
|
||||
Name : Wi-Fi
|
||||
Description : Intel(R) Wi-Fi 6 AX201 160MHz
|
||||
GUID : 754b6c07-5ae9-49d5-ba6c-4fbd6e38bbf6
|
||||
State : connected
|
||||
SSID : contoso-corp
|
||||
AP BSSID : 3c:37:12:53:c8:44
|
||||
Band : 5 GHz
|
||||
Channel : 116
|
||||
Radio type : 802.11ax
|
||||
Signal : 94%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-corp", r!.Ssid);
|
||||
Assert.Equal(94, r.SignalPercent);
|
||||
Assert.Equal("802.11ax", r.RadioType);
|
||||
Assert.Equal(116, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_german_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
Es ist 1 Schnittstelle auf dem System vorhanden:
|
||||
|
||||
Name : WLAN
|
||||
Beschreibung : Intel(R) Wi-Fi 6 AX201 160MHz
|
||||
Status : Verbunden
|
||||
SSID : fabrikam-wlan
|
||||
Kanal : 44
|
||||
Funktyp : 802.11ac
|
||||
Signal : 87%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("fabrikam-wlan", r!.Ssid);
|
||||
Assert.Equal(87, r.SignalPercent);
|
||||
Assert.Equal("802.11ac", r.RadioType);
|
||||
Assert.Equal(44, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_spanish_output_extracts_signal_and_channel()
|
||||
{
|
||||
const string output = @"
|
||||
Nombre : Wi-Fi
|
||||
SSID : contoso-es
|
||||
Canal : 6
|
||||
Tipo de radio : 802.11n
|
||||
Señal : 72%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-es", r!.Ssid);
|
||||
Assert.Equal(72, r.SignalPercent);
|
||||
Assert.Equal("802.11n", r.RadioType);
|
||||
Assert.Equal(6, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_chinese_simplified_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
名称 : WLAN
|
||||
SSID : contoso-cn
|
||||
信道 : 149
|
||||
无线电类型 : 802.11ac
|
||||
信号 : 68%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-cn", r!.Ssid);
|
||||
Assert.Equal(68, r.SignalPercent);
|
||||
Assert.Equal("802.11ac", r.RadioType);
|
||||
Assert.Equal(149, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_korean_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
이름 : Wi-Fi
|
||||
SSID : contoso-kr
|
||||
채널 : 36
|
||||
라디오 종류 : 802.11ax
|
||||
신호 : 91%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-kr", r!.Ssid);
|
||||
Assert.Equal(91, r.SignalPercent);
|
||||
Assert.Equal("802.11ax", r.RadioType);
|
||||
Assert.Equal(36, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_signal_with_space_before_percent_is_parsed()
|
||||
{
|
||||
var r = NetshWifiFallback.Parse(" Signal : 81 %\r\n SSID : x\r\n");
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal(81, r!.SignalPercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ap_bssid_does_not_overwrite_ssid()
|
||||
{
|
||||
const string output = @"
|
||||
SSID : contoso-corp
|
||||
AP BSSID : aa:bb:cc:dd:ee:ff
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.Equal("contoso-corp", r!.Ssid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_finnish_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
Nimi : Wi-Fi
|
||||
SSID : contoso-fi
|
||||
Kanava : 100
|
||||
Radiotyyppi : 802.11ax
|
||||
Signaali : 79%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-fi", r!.Ssid);
|
||||
Assert.Equal(79, r.SignalPercent);
|
||||
Assert.Equal("802.11ax", r.RadioType);
|
||||
Assert.Equal(100, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_indonesian_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
Nama : Wi-Fi
|
||||
SSID : contoso-id
|
||||
Saluran : 11
|
||||
Jenis radio : 802.11n
|
||||
Sinyal : 64%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-id", r!.Ssid);
|
||||
Assert.Equal(64, r.SignalPercent);
|
||||
Assert.Equal("802.11n", r.RadioType);
|
||||
Assert.Equal(11, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_unknown_language_without_ssid_returns_null()
|
||||
{
|
||||
// Greek is not in the enrollment-geography label set -> nothing to report.
|
||||
var r = NetshWifiFallback.Parse(" Κατάσταση : συνδεδεμένο\r\n Σήμα : 90%\r\n");
|
||||
|
||||
Assert.Null(r);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_unknown_language_still_yields_ssid()
|
||||
{
|
||||
// SSID is untranslated in all locales — partial result beats none.
|
||||
var r = NetshWifiFallback.Parse(" SSID : contoso-gr\r\n Σήμα : 90%\r\n");
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-gr", r!.Ssid);
|
||||
Assert.Null(r.SignalPercent);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("Der WLAN-AutoKonfig-Dienst (wlansvc) wird nicht ausgeführt.")]
|
||||
public void Parse_empty_or_error_output_returns_null(string? output)
|
||||
{
|
||||
Assert.Null(NetshWifiFallback.Parse(output));
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
#nullable enable
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the native-WLAN-API WiFi reader. The WLAN state of the test machine is
|
||||
/// unknown (CI runners/VMs have no WLAN service), so the live path only pins the
|
||||
/// fail-soft contract; the pure helpers (SSID decode, PHY-type mapping) are pinned
|
||||
/// exactly — their output feeds wifi_signal_info event payloads and must stay
|
||||
/// consistent with the strings netsh historically reported.
|
||||
/// </summary>
|
||||
public sealed class WifiInfoReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryGetCurrentConnection_never_throws_and_returns_sane_values()
|
||||
{
|
||||
var result = WifiInfoReader.TryGetCurrentConnection();
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
Assert.True(result.SignalPercent.HasValue, "native API path must always carry signal quality");
|
||||
Assert.InRange(result.SignalPercent.Value, 0, 100);
|
||||
if (result.Channel.HasValue)
|
||||
Assert.True(result.Channel.Value > 0, "channel must be positive when present");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- DecodeSsid ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_decodes_utf8_bytes()
|
||||
{
|
||||
var raw = new byte[32];
|
||||
var ssid = Encoding.UTF8.GetBytes("Contoso-Büro");
|
||||
ssid.CopyTo(raw, 0);
|
||||
|
||||
Assert.Equal("Contoso-Büro", WifiInfoReader.DecodeSsid(raw, (uint)ssid.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_returns_null_for_empty_or_missing()
|
||||
{
|
||||
Assert.Null(WifiInfoReader.DecodeSsid(null, 5));
|
||||
Assert.Null(WifiInfoReader.DecodeSsid(new byte[32], 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_clamps_length_to_buffer_size()
|
||||
{
|
||||
var raw = Encoding.UTF8.GetBytes("abc");
|
||||
|
||||
// A corrupt native length beyond the buffer must not throw.
|
||||
Assert.Equal("abc", WifiInfoReader.DecodeSsid(raw, 200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_uses_only_declared_length()
|
||||
{
|
||||
var raw = new byte[32];
|
||||
Encoding.UTF8.GetBytes("HomeNet-Rest-Ignored").CopyTo(raw, 0);
|
||||
|
||||
Assert.Equal("HomeNet", WifiInfoReader.DecodeSsid(raw, 7));
|
||||
}
|
||||
|
||||
// ----- PhyTypeToRadioType -------------------------------------------
|
||||
|
||||
// int parameters (cast inside): DOT11_PHY_TYPE is internal and may not appear in a
|
||||
// public test method signature (CS0051).
|
||||
[Theory]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ofdm, "802.11a")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_hrdsss, "802.11b")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_erp, "802.11g")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ht, "802.11n")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_vht, "802.11ac")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dmg, "802.11ad")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_he, "802.11ax")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_eht, "802.11be")]
|
||||
public void PhyTypeToRadioType_maps_known_generations(int phyType, string expected)
|
||||
{
|
||||
Assert.Equal(expected, WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_unknown)]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_fhss)]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dsss)]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_irbaseband)]
|
||||
[InlineData(9999)]
|
||||
public void PhyTypeToRadioType_returns_null_for_legacy_or_unknown(int phyType)
|
||||
{
|
||||
Assert.Null(WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#nullable enable
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Smoke tests for the WinRT <c>SystemSetupInfo.OutOfBoxExperienceState</c> reader.
|
||||
/// The actual state on the test machine is unknown (CI runners are post-OOBE; Server
|
||||
/// SKUs may lack the UniversalApiContract entirely), so only the fail-soft contract
|
||||
/// is pinned: never throw, always return a member of the known value set.
|
||||
/// </summary>
|
||||
public sealed class OobeStateReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Read_never_throws_and_returns_known_value()
|
||||
{
|
||||
var value = OobeStateReader.Read();
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(value));
|
||||
Assert.True(
|
||||
value == "not_started"
|
||||
|| value == "in_progress"
|
||||
|| value == "completed"
|
||||
|| value == OobeStateReader.Unavailable
|
||||
|| value.StartsWith("unknown_"),
|
||||
$"unexpected reader value '{value}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_is_stable_across_repeated_calls()
|
||||
{
|
||||
// Exercises the cached-PropertyInfo path (second call skips resolution).
|
||||
var first = OobeStateReader.Read();
|
||||
var second = OobeStateReader.Read();
|
||||
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-51
@@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
/// <item>a locked tree must abort WITHOUT deleting anything (old script force-deleted
|
||||
/// marker + state around the locks and orphaned the binaries),</item>
|
||||
/// <item>a straggler agent process (the incident's second instance) must be found by
|
||||
/// NAME, killed, and the cleanup must then complete,</item>
|
||||
/// NAME, killed, and the cleanup must then complete — simulated via function
|
||||
/// shadowing, NEVER by launching a renamed executable (EDR isolation, see the test),</item>
|
||||
/// <item>concurrent cleanup scripts must be serialized by the lock file.</item>
|
||||
/// </list>
|
||||
/// Timings are dialed down via the test-only BuildCleanupScript parameters so each case
|
||||
@@ -111,60 +112,55 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Straggler_process_holding_the_tree_is_killed_and_cleanup_completes()
|
||||
public void Straggler_agent_process_is_waited_on_and_force_killed_via_name_backstop()
|
||||
{
|
||||
// Full incident replay: a SECOND agent-named process (here: a renamed
|
||||
// powershell.exe, matching how only the process NAME identifies agents) holds a
|
||||
// file in the tree open. The old script waited on the launcher PID only and ran
|
||||
// its force-delete against the live locks. The hardened script must find the
|
||||
// straggler by name, kill it after the wait deadline, and then delete everything.
|
||||
var fakeExe = Path.Combine(_root, _fakeProcessName + ".exe");
|
||||
File.Copy(SystemPaths.PowerShell, fakeExe);
|
||||
// Incident replay of the second-instance mechanics WITHOUT spawning a process:
|
||||
// an earlier version of this test copied powershell.exe under the fake agent name
|
||||
// and launched it — textbook MITRE T1036.003 ("Rename Legitimate Utilities"),
|
||||
// which Defender for Endpoint answered with a machine ISOLATION on the dev box
|
||||
// (see tasks/lessons.md). Instead, a harness script shadows Get-Process /
|
||||
// Stop-Process with functions (PowerShell resolves functions before cmdlets, so
|
||||
// the dot-sourced cleanup script binds to the stubs): Get-Process reports a live
|
||||
// agent-named straggler on every probe; Stop-Process records the kill. This pins
|
||||
// the same contract — the script polls by NAME until the deadline, then
|
||||
// force-kills by NAME before deleting — with zero EDR-visible behavior.
|
||||
var probeLog = Path.Combine(_root, "probe.log");
|
||||
var cleanupScriptPath = Path.Combine(_root, $"cleanup-{Guid.NewGuid():N}.ps1");
|
||||
File.WriteAllText(cleanupScriptPath, BuildScript(processWaitSeconds: 2));
|
||||
|
||||
var lockTarget = Path.Combine(_basePath, "Agent", "fake.exe").Replace("'", "''");
|
||||
Process straggler = null;
|
||||
try
|
||||
{
|
||||
straggler = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = fakeExe,
|
||||
Arguments = "-NoProfile -Command \"$f = [IO.File]::Open('" + lockTarget +
|
||||
"', 'Open', 'Read', 'None'); Start-Sleep -Seconds 120\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
var harness = @"
|
||||
$probeLog = 'LOG_PATH'
|
||||
$global:AmProbeCount = 0
|
||||
function Get-Process {
|
||||
[CmdletBinding()]
|
||||
param([string[]]$Name)
|
||||
if ($Name -contains 'FAKE_NAME') {
|
||||
$global:AmProbeCount++
|
||||
Add-Content -Path $probeLog -Value ('probe ' + $global:AmProbeCount)
|
||||
return [pscustomobject]@{ Name = 'FAKE_NAME'; Id = 424242 }
|
||||
}
|
||||
Microsoft.PowerShell.Management\Get-Process @PSBoundParameters
|
||||
}
|
||||
function Stop-Process {
|
||||
[CmdletBinding()]
|
||||
param([Parameter(ValueFromPipeline = $true)]$InputObject, [switch]$Force)
|
||||
process { Add-Content -Path $probeLog -Value ('kill ' + $InputObject.Id + ' force=' + $Force) }
|
||||
}
|
||||
. 'SCRIPT_PATH'
|
||||
"
|
||||
.Replace("LOG_PATH", probeLog)
|
||||
.Replace("FAKE_NAME", _fakeProcessName)
|
||||
.Replace("SCRIPT_PATH", cleanupScriptPath);
|
||||
|
||||
// Give the straggler time to acquire its file handle before cleanup starts.
|
||||
var acquireDeadline = DateTime.UtcNow.AddSeconds(20);
|
||||
var acquired = false;
|
||||
while (!acquired && DateTime.UtcNow < acquireDeadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (File.Open(Path.Combine(_basePath, "Agent", "fake.exe"),
|
||||
FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
acquired = true; // share violation ⇒ straggler holds the file
|
||||
}
|
||||
if (!acquired) System.Threading.Thread.Sleep(200);
|
||||
}
|
||||
Assert.True(acquired, "straggler never acquired its file lock");
|
||||
RunScript(harness);
|
||||
|
||||
RunScript(BuildScript(processWaitSeconds: 2));
|
||||
|
||||
Assert.True(straggler.HasExited, "straggler must be force-killed by the name-based backstop");
|
||||
Assert.False(Directory.Exists(_basePath), "after the kill the tree must be fully deleted");
|
||||
Assert.Empty(Directory.GetFileSystemEntries(_root, "*.del-*"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (straggler != null && !straggler.HasExited) straggler.Kill(); } catch { }
|
||||
try { straggler?.Dispose(); } catch { }
|
||||
}
|
||||
var log = File.ReadAllLines(probeLog);
|
||||
Assert.Contains("kill 424242 force=True", log);
|
||||
Assert.True(Array.FindAll(log, l => l.StartsWith("probe")).Length >= 2,
|
||||
"script must poll by name in the wait loop AND re-probe for the kill backstop");
|
||||
Assert.False(Directory.Exists(_basePath), "after the kill the tree must be fully deleted");
|
||||
Assert.Empty(Directory.GetFileSystemEntries(_root, "*.del-*"));
|
||||
Assert.False(File.Exists(LockPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+89
@@ -226,6 +226,95 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
return archive.Entries.Select(e => e.FullName).ToArray();
|
||||
}
|
||||
|
||||
private static string ZipEntryText(byte[] zipBytes, string entryName)
|
||||
{
|
||||
using var ms = new MemoryStream(zipBytes);
|
||||
using var archive = new ZipArchive(ms, ZipArchiveMode.Read);
|
||||
var entry = archive.GetEntry(entryName);
|
||||
Assert.NotNull(entry);
|
||||
using var reader = new StreamReader(entry!.Open(), Encoding.UTF8);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
// ── Nullable outcome (on-demand mid-session) — gate + sessioninfo semantics ──
|
||||
|
||||
/// <summary>
|
||||
/// Sentinel subclass: throws before any HTTP so gate-pass-through is observable as a
|
||||
/// non-null error result (a skip returns null BEFORE the archive is ever built).
|
||||
/// </summary>
|
||||
private sealed class ThrowOnBuildService : DiagnosticsPackageService
|
||||
{
|
||||
public ThrowOnBuildService(AgentConfiguration cfg, AgentLogger logger, BackendApiClient api)
|
||||
: base(cfg, logger, api) { }
|
||||
internal override byte[] BuildArchiveBytes(bool? enrollmentSucceeded) =>
|
||||
throw new System.InvalidOperationException("gate-passed-sentinel");
|
||||
}
|
||||
|
||||
private static DiagnosticsPackageService BuildGateProbe(AgentLogger logger, string mode)
|
||||
{
|
||||
var cfg = Cfg();
|
||||
cfg.DiagnosticsUploadEnabled = true;
|
||||
cfg.DiagnosticsUploadMode = mode;
|
||||
var apiClient = new BackendApiClient(
|
||||
httpClient: new System.Net.Http.HttpClient(),
|
||||
baseUrl: "http://localhost",
|
||||
manufacturer: string.Empty,
|
||||
model: string.Empty,
|
||||
serialNumber: string.Empty,
|
||||
useBootstrapTokenAuth: false,
|
||||
bootstrapToken: null,
|
||||
agentVersion: "0.0.0",
|
||||
logger: logger);
|
||||
return new ThrowOnBuildService(cfg, logger, apiClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task CreateAndUploadAsync_null_outcome_passes_OnFailure_gate()
|
||||
{
|
||||
// On-demand mid-session (no outcome yet): the OnFailure gate must NOT skip —
|
||||
// there is no known success to skip on. Reaching the archive build (sentinel
|
||||
// throw → error result) proves the gate was passed; a skip would return null.
|
||||
using var rig = new Rig();
|
||||
var result = await BuildGateProbe(rig.Logger, "OnFailure")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: "server-requested");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("gate-passed-sentinel", result!.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task CreateAndUploadAsync_known_success_still_skipped_by_OnFailure_gate()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var result = await BuildGateProbe(rig.Logger, "OnFailure")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: true);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task CreateAndUploadAsync_null_outcome_still_blocked_by_mode_Off()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var result = await BuildGateProbe(rig.Logger, "Off")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: null);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "Succeeded")]
|
||||
[InlineData(false, "Failed")]
|
||||
[InlineData(null, "In Progress")]
|
||||
public void BuildArchiveBytes_sessioninfo_reflects_nullable_outcome(bool? outcome, string expected)
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var bytes = rig.Build().BuildArchiveBytes(outcome);
|
||||
|
||||
var sessionInfo = ZipEntryText(bytes, "sessioninfo.txt");
|
||||
Assert.Contains($"Enrollment Result: {expected}", sessionInfo);
|
||||
}
|
||||
|
||||
// ── BuildBlobUploadUrl — destination-aware URL construction ─────────────────
|
||||
|
||||
[Theory]
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Session 4910a5a5 (2026-07-23) — post-terminal recovery story. Field sequence:
|
||||
/// DeviceSetup/Apps failed (culprit app never started installing) → settle window expired
|
||||
/// → terminal EspFailureDetected → the user pressed "Try again" ~23 min later → Apps left
|
||||
/// the failed state → 10/10 installed → DeviceSetup resolved. Pre-fix, that recovery only
|
||||
/// appeared as a generic <c>subcategory_state_change</c>. Coverage:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>esp_failure_retry_detected</c> fires once when the failed subcategory leaves
|
||||
/// the failed state AFTER the terminal fire.</item>
|
||||
/// <item><c>esp_failure_recovered</c> fires once when the category then resolves to
|
||||
/// success.</item>
|
||||
/// <item>An in-settle-window recovery keeps emitting only
|
||||
/// <c>esp_failure_settle_recovered</c> — the post-terminal pair stays silent.</item>
|
||||
/// <item>Apps failures carry <c>LikelyCulpritApps</c> on the EspFailureDetected args,
|
||||
/// never-started apps ranked first.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class EspFailureRecoveryStoryTests
|
||||
{
|
||||
private static readonly DateTime Fixed = new DateTime(2026, 7, 23, 4, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
public FakeSignalIngressSink Sink { get; } = new FakeSignalIngressSink();
|
||||
public List<EspFailureDetectedEventArgs> EspFailures { get; } = new List<EspFailureDetectedEventArgs>();
|
||||
public ProvisioningStatusTracker Tracker { get; }
|
||||
|
||||
public Fixture(Func<IReadOnlyList<AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState>>? packageStatesProbe = null)
|
||||
{
|
||||
var clock = new VirtualClock(Fixed);
|
||||
var post = new InformationalEventPost(Sink, clock);
|
||||
var logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
|
||||
Tracker = new ProvisioningStatusTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: post,
|
||||
logger: logger,
|
||||
appxScanner: new FakeAppxDeploymentFailureScanner(),
|
||||
backgroundDispatcher: action => action(),
|
||||
packageStatesProbe: packageStatesProbe);
|
||||
Tracker.EspFailureDetected += (_, args) => EspFailures.Add(args);
|
||||
}
|
||||
|
||||
public IReadOnlyList<(string EventType, IReadOnlyDictionary<string, object> Data)> CapturedEvents()
|
||||
{
|
||||
return Sink.Posted
|
||||
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent && p.Payload != null)
|
||||
.Select(p =>
|
||||
{
|
||||
var eventType = p.Payload!.TryGetValue(SignalPayloadKeys.EventType, out var et) ? et : "";
|
||||
var data = p.TypedPayload as IReadOnlyDictionary<string, object>
|
||||
?? (IReadOnlyDictionary<string, object>)new Dictionary<string, object>();
|
||||
return (eventType, data);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Drive the session-4910a5a5 shape up to the terminal fire.</summary>
|
||||
public void DriveToTerminalDeviceSetupAppsFailure()
|
||||
{
|
||||
Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": null,
|
||||
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
|
||||
""AppsSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Apps (9 of 10 installed)""}
|
||||
}");
|
||||
Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": null,
|
||||
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
|
||||
""AppsSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Apps (Error)""}
|
||||
}");
|
||||
Tracker.TriggerSettleTimerForTest("DeviceSetupCategory.Status");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
Tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostTerminalRetry_EmitsRetryDetected_Once()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
f.DriveToTerminalDeviceSetupAppsFailure();
|
||||
Assert.Single(f.EspFailures);
|
||||
|
||||
// "Try again": Apps leaves the failed state well after the settle window.
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": null,
|
||||
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
|
||||
""AppsSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Apps (Working on it...)""}
|
||||
}");
|
||||
|
||||
var retry = Assert.Single(f.CapturedEvents(), e => e.EventType == "esp_failure_retry_detected");
|
||||
Assert.Equal("DeviceSetup", (string)retry.Data["category"]);
|
||||
Assert.Equal("Apps", (string)retry.Data["subcategory"]);
|
||||
Assert.Equal("failed", (string)retry.Data["previousState"]);
|
||||
Assert.Equal("inProgress", (string)retry.Data["newState"]);
|
||||
Assert.Equal("Provisioning_DeviceSetup_Apps_Failed", (string)retry.Data["failureType"]);
|
||||
Assert.True(retry.Data.ContainsKey("minutesSinceFailure"));
|
||||
|
||||
// A later unrelated transition does not re-emit.
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": null,
|
||||
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
|
||||
""AppsSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Apps (10 of 10 installed)""}
|
||||
}");
|
||||
Assert.Single(f.CapturedEvents(), e => e.EventType == "esp_failure_retry_detected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostTerminalRecovery_EmitsRecovered_OnCategorySuccess()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
f.DriveToTerminalDeviceSetupAppsFailure();
|
||||
|
||||
// Retry → all apps installed → Windows resolves the category boolean.
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": null,
|
||||
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
|
||||
""AppsSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Apps (Working on it...)""}
|
||||
}");
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": true,
|
||||
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
|
||||
""AppsSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Apps (10 of 10 installed)""}
|
||||
}");
|
||||
|
||||
var recovered = Assert.Single(f.CapturedEvents(), e => e.EventType == "esp_failure_recovered");
|
||||
Assert.Equal("DeviceSetup", (string)recovered.Data["category"]);
|
||||
Assert.Equal("Apps", (string)recovered.Data["failedSubcategory"]);
|
||||
Assert.Equal("Provisioning_DeviceSetup_Apps_Failed", (string)recovered.Data["failureType"]);
|
||||
Assert.True(recovered.Data.ContainsKey("minutesSinceFailure"));
|
||||
|
||||
// Duplicate category-success writes do not re-emit (fire-once).
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": true,
|
||||
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
|
||||
""AppsSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Apps (10 of 10 installed, take 2)""}
|
||||
}");
|
||||
Assert.Single(f.CapturedEvents(), e => e.EventType == "esp_failure_recovered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InSettleWindowRecovery_KeepsPostTerminalPairSilent()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": null,
|
||||
""AppsSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Apps (0 of 1 installed)""}
|
||||
}");
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": null,
|
||||
""AppsSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Apps (Error)""}
|
||||
}");
|
||||
// Recovery INSIDE the settle window — terminal fire is suppressed.
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": null,
|
||||
""AppsSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Apps (Working on it...)""}
|
||||
}");
|
||||
f.Tracker.TriggerSettleTimerForTest("DeviceSetupCategory.Status");
|
||||
Assert.Empty(f.EspFailures);
|
||||
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": true,
|
||||
""AppsSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Apps (1 of 1 installed)""}
|
||||
}");
|
||||
|
||||
var events = f.CapturedEvents();
|
||||
Assert.Contains(events, e => e.EventType == "esp_failure_settle_recovered");
|
||||
Assert.DoesNotContain(events, e => e.EventType == "esp_failure_retry_detected");
|
||||
Assert.DoesNotContain(events, e => e.EventType == "esp_failure_recovered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppsFailure_TerminalArgs_CarryLikelyCulprits_NeverStartedFirst()
|
||||
{
|
||||
var neverStarted = BuildApp("app-bg", "Teams Backgrounds v2",
|
||||
AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Unknown,
|
||||
downloadingSeen: false);
|
||||
var startedButPending = BuildApp("app-slow", "Slow Installer",
|
||||
AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Unknown,
|
||||
downloadingSeen: true);
|
||||
var installed = BuildApp("app-ok", "Falcon Sensor",
|
||||
AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Installed,
|
||||
downloadingSeen: true);
|
||||
using var f = new Fixture(() => new[] { startedButPending, neverStarted, installed });
|
||||
|
||||
f.DriveToTerminalDeviceSetupAppsFailure();
|
||||
|
||||
var fired = Assert.Single(f.EspFailures);
|
||||
Assert.Equal(2, fired.LikelyCulpritApps.Count);
|
||||
// Never-started app ranked first — it starved the ESP gate without any IME event.
|
||||
Assert.Equal("Teams Backgrounds v2", fired.LikelyCulpritApps[0]);
|
||||
Assert.Equal("Slow Installer", fired.LikelyCulpritApps[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonAppsFailure_TerminalArgs_CarryNoCulprits()
|
||||
{
|
||||
var neverStarted = BuildApp("app-bg", "Teams Backgrounds v2",
|
||||
AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Unknown,
|
||||
downloadingSeen: false);
|
||||
using var f = new Fixture(() => new[] { neverStarted });
|
||||
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": null,
|
||||
""SecurityPoliciesSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Working""}
|
||||
}");
|
||||
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
|
||||
""categorySucceeded"": null,
|
||||
""SecurityPoliciesSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Failed""}
|
||||
}");
|
||||
f.Tracker.TriggerSettleTimerForTest("DeviceSetupCategory.Status");
|
||||
|
||||
var fired = Assert.Single(f.EspFailures);
|
||||
Assert.Empty(fired.LikelyCulpritApps);
|
||||
}
|
||||
|
||||
private static AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState BuildApp(
|
||||
string id, string name,
|
||||
AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState state,
|
||||
bool downloadingSeen)
|
||||
{
|
||||
var pkg = new AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState(id, 0);
|
||||
pkg.UpdateName(name);
|
||||
pkg.UpdateIntent(AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppIntent.Install);
|
||||
pkg.UpdateTargeted(AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppTargeted.Device);
|
||||
// DownloadingOrInstallingSeen is derived inside UpdateState (Downloading..Installing
|
||||
// range) — drive it through a Downloading transition instead of setting it directly.
|
||||
if (downloadingSeen)
|
||||
pkg.UpdateState(AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Downloading);
|
||||
if (state != AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Unknown
|
||||
&& state != AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Downloading)
|
||||
pkg.UpdateState(state);
|
||||
return pkg;
|
||||
}
|
||||
}
|
||||
}
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Session 5d735290 — pure detection logic of <see cref="EspPolicyProviderStallDetector"/>:
|
||||
/// the CSP completion rule and the dwell/one-shot transition. No registry, no timers.
|
||||
/// </summary>
|
||||
public sealed class EspPolicyProviderStallLogicTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 7, 23, 10, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Dwell = TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes);
|
||||
|
||||
private static PolicyProviderState SetupApps(string name, int? tracking, string scope = "device") =>
|
||||
new PolicyProviderState(name, scope, EspPolicyProviderProbe.KindSetupApps, tracking, null);
|
||||
|
||||
private static PolicyProviderState DevicePrep(string name, int? installState) =>
|
||||
new PolicyProviderState(name, "device", EspPolicyProviderProbe.KindDevicePreparation, null, installState);
|
||||
|
||||
private static EspPolicyProviderSnapshot Snapshot(params PolicyProviderState[] providers) =>
|
||||
new EspPolicyProviderSnapshot(providers);
|
||||
|
||||
// ------------------------------------------------- completion rule (CSP contract)
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, true)] // TrackingPoliciesCreated=true
|
||||
[InlineData(0, false)] // explicit false (the CSP default)
|
||||
[InlineData(null, false)] // value missing — the field case
|
||||
[InlineData(2, false)] // anything else is not "true"
|
||||
public void SetupApps_provider_complete_only_at_tracking_1(int? tracking, bool expected)
|
||||
=> Assert.Equal(expected, EspPolicyProviderProbe.IsProviderComplete(
|
||||
EspPolicyProviderProbe.KindSetupApps, tracking, null));
|
||||
|
||||
[Theory]
|
||||
[InlineData(2, true)] // NotRequired
|
||||
[InlineData(3, true)] // Completed
|
||||
[InlineData(1, false)] // NotInstalled
|
||||
[InlineData(4, false)] // Error
|
||||
[InlineData(null, false)]
|
||||
public void DevicePreparation_provider_complete_at_state_2_or_3(int? state, bool expected)
|
||||
=> Assert.Equal(expected, EspPolicyProviderProbe.IsProviderComplete(
|
||||
EspPolicyProviderProbe.KindDevicePreparation, null, state));
|
||||
|
||||
[Fact]
|
||||
public void TryReadInt_tolerates_dword_string_and_bool_forms()
|
||||
{
|
||||
Assert.Equal(1, EspPolicyProviderProbe.TryReadInt(1));
|
||||
Assert.Equal(3, EspPolicyProviderProbe.TryReadInt(3L));
|
||||
Assert.Equal(1, EspPolicyProviderProbe.TryReadInt(" 1 "));
|
||||
Assert.Equal(1, EspPolicyProviderProbe.TryReadInt(true));
|
||||
Assert.Equal(0, EspPolicyProviderProbe.TryReadInt(false));
|
||||
Assert.Null(EspPolicyProviderProbe.TryReadInt(null));
|
||||
Assert.Null(EspPolicyProviderProbe.TryReadInt("not-a-number"));
|
||||
Assert.Null(EspPolicyProviderProbe.TryReadInt(new byte[] { 1 }));
|
||||
}
|
||||
|
||||
// ------------------------------------------------- dwell transition
|
||||
|
||||
[Fact]
|
||||
public void Empty_or_missing_snapshot_clears_dwell_state_and_never_stalls()
|
||||
{
|
||||
var previous = new Dictionary<string, DateTime> { ["setupApps|device|ConfigMgr"] = T0 };
|
||||
|
||||
var missing = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
EspPolicyProviderSnapshot.Empty, previous, new HashSet<string>(), T0, false, T0.AddHours(2), Dwell);
|
||||
Assert.Empty(missing.UpdatedFirstSeenUtc);
|
||||
Assert.Empty(missing.NewlyStalled);
|
||||
Assert.Null(missing.UpdatedSidecarMissingSinceUtc); // arm 2 resets too
|
||||
Assert.Null(missing.SidecarMissingStalledForMinutes);
|
||||
|
||||
var registeredButEmpty = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
Snapshot(), previous, new HashSet<string>(), T0, false, T0.AddHours(2), Dwell);
|
||||
Assert.Empty(registeredButEmpty.UpdatedFirstSeenUtc);
|
||||
Assert.Empty(registeredButEmpty.NewlyStalled);
|
||||
Assert.Null(registeredButEmpty.UpdatedSidecarMissingSinceUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Incomplete_provider_accrues_dwell_but_does_not_stall_before_threshold()
|
||||
{
|
||||
var snapshot = Snapshot(SetupApps("ConfigMgr", tracking: null));
|
||||
|
||||
var round1 = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
snapshot, new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
|
||||
Assert.Equal(T0, round1.UpdatedFirstSeenUtc["setupApps|device|ConfigMgr"]);
|
||||
Assert.Empty(round1.NewlyStalled);
|
||||
|
||||
var round2 = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
snapshot, round1.UpdatedFirstSeenUtc, new HashSet<string>(),
|
||||
round1.UpdatedSidecarMissingSinceUtc, false, T0.AddMinutes(14), Dwell);
|
||||
Assert.Equal(T0, round2.UpdatedFirstSeenUtc["setupApps|device|ConfigMgr"]); // first-seen preserved
|
||||
Assert.Empty(round2.NewlyStalled);
|
||||
Assert.Null(round2.SidecarMissingStalledForMinutes); // 14 min < dwell on arm 2 as well
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_stalls_at_dwell_threshold_with_correct_minutes()
|
||||
{
|
||||
var snapshot = Snapshot(SetupApps("ConfigMgr", tracking: 0));
|
||||
var seen = new Dictionary<string, DateTime> { ["setupApps|device|ConfigMgr"] = T0 };
|
||||
|
||||
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
snapshot, seen, new HashSet<string>(), null, false, T0.AddMinutes(15), Dwell);
|
||||
|
||||
var stalled = Assert.Single(eval.NewlyStalled);
|
||||
Assert.Equal("ConfigMgr", stalled.Provider.Name);
|
||||
Assert.Equal(15.0, stalled.StalledForMinutes, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Completion_or_disappearance_resets_the_dwell_clock()
|
||||
{
|
||||
var key = "setupApps|device|ConfigMgr";
|
||||
var seen = new Dictionary<string, DateTime> { [key] = T0 };
|
||||
|
||||
// Completes mid-dwell → dropped from the map.
|
||||
var completed = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
Snapshot(SetupApps("ConfigMgr", tracking: 1)), seen, new HashSet<string>(), null, false, T0.AddMinutes(10), Dwell);
|
||||
Assert.Empty(completed.UpdatedFirstSeenUtc);
|
||||
|
||||
// Re-appears incomplete much later → dwell restarts from the new now, no stall yet.
|
||||
var reappeared = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
Snapshot(SetupApps("ConfigMgr", tracking: 0)), completed.UpdatedFirstSeenUtc,
|
||||
new HashSet<string>(), null, false, T0.AddHours(3), Dwell);
|
||||
Assert.Equal(T0.AddHours(3), reappeared.UpdatedFirstSeenUtc[key]);
|
||||
Assert.Empty(reappeared.NewlyStalled);
|
||||
|
||||
// Disappearance behaves the same as completion.
|
||||
var disappeared = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
Snapshot(SetupApps("Sidecar", tracking: 1)), new Dictionary<string, DateTime> { [key] = T0 },
|
||||
new HashSet<string>(), null, false, T0.AddMinutes(20), Dwell);
|
||||
Assert.False(disappeared.UpdatedFirstSeenUtc.ContainsKey(key));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Already_fired_provider_is_never_reported_again()
|
||||
{
|
||||
var snapshot = Snapshot(SetupApps("ConfigMgr", tracking: null));
|
||||
var seen = new Dictionary<string, DateTime> { ["setupApps|device|ConfigMgr"] = T0 };
|
||||
var fired = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "setupApps|device|ConfigMgr" };
|
||||
|
||||
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
snapshot, seen, fired, null, false, T0.AddHours(5), Dwell);
|
||||
|
||||
Assert.Empty(eval.NewlyStalled);
|
||||
Assert.True(eval.UpdatedFirstSeenUtc.ContainsKey("setupApps|device|ConfigMgr")); // still tracked
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mixed_kinds_and_scopes_are_tracked_under_distinct_keys()
|
||||
{
|
||||
var snapshot = Snapshot(
|
||||
SetupApps("ConfigMgr", tracking: null),
|
||||
SetupApps("ConfigMgr", tracking: null, scope: "user:S-1-5-21-1111"),
|
||||
DevicePrep("ConfigMgr", installState: 1));
|
||||
|
||||
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
snapshot, new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
|
||||
|
||||
Assert.Equal(3, eval.UpdatedFirstSeenUtc.Count);
|
||||
Assert.Contains("setupApps|device|ConfigMgr", eval.UpdatedFirstSeenUtc.Keys);
|
||||
Assert.Contains("setupApps|user:S-1-5-21-1111|ConfigMgr", eval.UpdatedFirstSeenUtc.Keys);
|
||||
Assert.Contains("devicePreparation|device|ConfigMgr", eval.UpdatedFirstSeenUtc.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SidecarRegistered_matches_setupApps_sidecar_only()
|
||||
{
|
||||
Assert.True(Snapshot(SetupApps("Sidecar", tracking: 0)).SidecarRegistered);
|
||||
Assert.True(Snapshot(SetupApps("sidecar", tracking: 1, scope: "user:S-1-5-21-1")).SidecarRegistered);
|
||||
Assert.False(Snapshot(SetupApps("ConfigMgr", tracking: 1)).SidecarRegistered);
|
||||
Assert.False(Snapshot(DevicePrep("Sidecar", installState: 3)).SidecarRegistered); // wrong kind
|
||||
Assert.False(EspPolicyProviderSnapshot.Empty.SidecarRegistered);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- arm 2: sidecar-missing transition
|
||||
|
||||
[Fact]
|
||||
public void Issue106_foreign_provider_complete_but_sidecar_missing_stalls_after_dwell()
|
||||
{
|
||||
// The actual field case: ConfigMgr with TrackingPoliciesCreated=1 — "complete" by the
|
||||
// CSP value contract, arm 1 never fires — yet the ESP waits on the absent Sidecar.
|
||||
var snapshot = Snapshot(SetupApps("ConfigMgr", tracking: 1));
|
||||
|
||||
var round1 = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
snapshot, new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
|
||||
Assert.Empty(round1.NewlyStalled); // arm 1 stays silent...
|
||||
Assert.Equal(T0, round1.UpdatedSidecarMissingSinceUtc); // ...but arm 2 starts its clock
|
||||
Assert.Null(round1.SidecarMissingStalledForMinutes);
|
||||
|
||||
var round2 = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
snapshot, round1.UpdatedFirstSeenUtc, new HashSet<string>(),
|
||||
round1.UpdatedSidecarMissingSinceUtc, false, T0.AddMinutes(15), Dwell);
|
||||
Assert.Empty(round2.NewlyStalled);
|
||||
Assert.Equal(15.0, round2.SidecarMissingStalledForMinutes!.Value, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sidecar_registering_resets_the_sidecar_missing_clock()
|
||||
{
|
||||
// ConfigMgr first (legitimate startup ordering) → clock runs...
|
||||
var round1 = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
Snapshot(SetupApps("ConfigMgr", tracking: 1)),
|
||||
new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
|
||||
Assert.Equal(T0, round1.UpdatedSidecarMissingSinceUtc);
|
||||
|
||||
// ...Sidecar joins → condition clears, state resets.
|
||||
var round2 = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
Snapshot(SetupApps("ConfigMgr", tracking: 1), SetupApps("Sidecar", tracking: 0)),
|
||||
round1.UpdatedFirstSeenUtc, new HashSet<string>(),
|
||||
round1.UpdatedSidecarMissingSinceUtc, false, T0.AddMinutes(10), Dwell);
|
||||
Assert.Null(round2.UpdatedSidecarMissingSinceUtc);
|
||||
Assert.Null(round2.SidecarMissingStalledForMinutes);
|
||||
|
||||
// Sidecar gone again much later → clock restarts from the new now, no instant stall.
|
||||
var round3 = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
Snapshot(SetupApps("ConfigMgr", tracking: 1)),
|
||||
round2.UpdatedFirstSeenUtc, new HashSet<string>(),
|
||||
round2.UpdatedSidecarMissingSinceUtc, false, T0.AddHours(3), Dwell);
|
||||
Assert.Equal(T0.AddHours(3), round3.UpdatedSidecarMissingSinceUtc);
|
||||
Assert.Null(round3.SidecarMissingStalledForMinutes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sidecar_missing_one_shot_and_deviceprep_only_scoping()
|
||||
{
|
||||
// Already fired → never re-reported, but the clock keeps running.
|
||||
var latched = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
Snapshot(SetupApps("ConfigMgr", tracking: 1)),
|
||||
new Dictionary<string, DateTime>(), new HashSet<string>(), T0, true, T0.AddHours(5), Dwell);
|
||||
Assert.Null(latched.SidecarMissingStalledForMinutes);
|
||||
Assert.Equal(T0, latched.UpdatedSidecarMissingSinceUtc);
|
||||
|
||||
// DevicePreparation providers alone don't constitute the Setup/Apps wait — arm 2 off.
|
||||
var devicePrepOnly = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
Snapshot(DevicePrep("ConfigMgr", installState: 1)),
|
||||
new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
|
||||
Assert.Null(devicePrepOnly.UpdatedSidecarMissingSinceUtc);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emission surface of the detector via the real <see cref="InformationalEventPost"/> rail.
|
||||
/// Static probe override → serial collection (same contract as
|
||||
/// <c>EspConfigDetectedTrackingFieldsTests</c>).
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class EspPolicyProviderStallDetectorEmissionTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private (EspPolicyProviderStallDetector sut, FakeSignalIngressSink sink, VirtualClock clock)
|
||||
BuildDetector(StartupEventGate? gate = null)
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(At);
|
||||
var post = new InformationalEventPost(sink, clock);
|
||||
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate);
|
||||
return (sut, sink, clock);
|
||||
}
|
||||
|
||||
private static EspPolicyProviderSnapshot ConfigMgrOnly() => new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps,
|
||||
trackingPoliciesCreated: null, installationState: null),
|
||||
});
|
||||
|
||||
private static EspPolicyProviderSnapshot SidecarCompleteConfigMgrIncomplete() => new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
new PolicyProviderState("Sidecar", "device", EspPolicyProviderProbe.KindSetupApps, 1, null),
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps, 0, null),
|
||||
});
|
||||
|
||||
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> StalledEvents(FakeSignalIngressSink sink) =>
|
||||
sink.Posted.Where(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == "esp_policy_provider_stalled").ToList();
|
||||
|
||||
[Fact]
|
||||
public void CustomerScenario_configmgr_without_sidecar_emits_one_warning_after_dwell()
|
||||
{
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => ConfigMgrOnly());
|
||||
var (sut, sink, clock) = BuildDetector();
|
||||
|
||||
sut.Tick(); // first sighting — dwell starts
|
||||
Assert.Empty(StalledEvents(sink));
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
|
||||
sut.Tick(); // threshold crossed → emit
|
||||
|
||||
var evt = Assert.Single(StalledEvents(sink));
|
||||
Assert.Equal(DecisionSignalKind.InformationalEvent, evt.Kind);
|
||||
Assert.Equal("Warning", evt.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", evt.Payload[SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal(EspPolicyProviderStallDetector.SourceLabel, evt.Payload[SignalPayloadKeys.Source]);
|
||||
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
|
||||
// Both arms cross in the same round (ConfigMgr incomplete AND Sidecar absent) →
|
||||
// ONE event; sidecar-missing is the dominant reason.
|
||||
Assert.Equal(EspPolicyProviderStallDetector.ReasonSidecarMissing, data["reason"]);
|
||||
Assert.Equal(false, data["sidecarRegistered"]);
|
||||
Assert.Equal(15.0, data["sidecarMissingForMinutes"]);
|
||||
Assert.Equal(EspPolicyProviderStallDetector.DwellMinutes, data["dwellMinutes"]);
|
||||
var stalled = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]);
|
||||
var entry = Assert.Single(stalled);
|
||||
Assert.Equal("ConfigMgr", entry["name"]);
|
||||
Assert.Equal(EspPolicyProviderProbe.KindSetupApps, entry["kind"]);
|
||||
Assert.Equal(15.0, entry["stalledForMinutes"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Issue106_configmgr_complete_without_sidecar_still_emits_sidecar_missing()
|
||||
{
|
||||
// Regression test for the customer's follow-up on issue #106: TrackingPoliciesCreated=1
|
||||
// WAS present under ConfigMgr — the provider looks "complete", arm 1 never fires, yet
|
||||
// the ESP hangs because Sidecar is absent. Arm 2 must catch exactly this.
|
||||
var configMgrComplete = new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps,
|
||||
trackingPoliciesCreated: 1, installationState: null),
|
||||
});
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => configMgrComplete);
|
||||
var (sut, sink, clock) = BuildDetector();
|
||||
|
||||
sut.Tick();
|
||||
Assert.Empty(StalledEvents(sink));
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
|
||||
sut.Tick();
|
||||
|
||||
var evt = Assert.Single(StalledEvents(sink));
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
|
||||
Assert.Equal(EspPolicyProviderStallDetector.ReasonSidecarMissing, data["reason"]);
|
||||
Assert.Equal(false, data["sidecarRegistered"]);
|
||||
Assert.Equal(15.0, data["sidecarMissingForMinutes"]);
|
||||
Assert.Empty(Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]));
|
||||
var providers = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object?>>>(data["providers"]);
|
||||
Assert.Equal(true, Assert.Single(providers)["complete"]); // the misleading "complete" is visible
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sidecar_complete_configmgr_incomplete_reports_only_configmgr_with_sidecar_flag()
|
||||
{
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => SidecarCompleteConfigMgrIncomplete());
|
||||
var (sut, sink, clock) = BuildDetector();
|
||||
|
||||
sut.Tick();
|
||||
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
|
||||
sut.Tick();
|
||||
|
||||
var evt = Assert.Single(StalledEvents(sink));
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
|
||||
Assert.Equal(EspPolicyProviderStallDetector.ReasonProviderIncomplete, data["reason"]); // arm 2 off — Sidecar is there
|
||||
Assert.Equal(true, data["sidecarRegistered"]);
|
||||
Assert.False(data.ContainsKey("sidecarMissingForMinutes"));
|
||||
var stalled = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]);
|
||||
Assert.Equal("ConfigMgr", Assert.Single(stalled)["name"]);
|
||||
var providers = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object?>>>(data["providers"]);
|
||||
Assert.Equal(2, providers.Count); // full table always attached
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Latch_prevents_second_emission_for_the_same_provider()
|
||||
{
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => ConfigMgrOnly());
|
||||
var (sut, sink, clock) = BuildDetector();
|
||||
|
||||
sut.Tick();
|
||||
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
|
||||
sut.Tick();
|
||||
clock.Advance(TimeSpan.FromMinutes(30));
|
||||
sut.Tick();
|
||||
sut.Tick();
|
||||
|
||||
Assert.Single(StalledEvents(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Startup_gate_suppresses_same_stall_across_restart_but_reports_new_provider_set()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
// Run 1: fires and persists the fingerprint.
|
||||
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => ConfigMgrOnly()))
|
||||
{
|
||||
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
|
||||
sut.Tick();
|
||||
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
|
||||
sut.Tick();
|
||||
Assert.Single(StalledEvents(sink));
|
||||
}
|
||||
|
||||
// Run 2 (simulated restart — fresh detector, gate reloaded from the same state dir):
|
||||
// identical stall re-accrues dwell but lands on the same fingerprint → suppressed.
|
||||
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => ConfigMgrOnly()))
|
||||
{
|
||||
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
|
||||
sut.Tick();
|
||||
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
|
||||
sut.Tick();
|
||||
Assert.Empty(StalledEvents(sink));
|
||||
}
|
||||
|
||||
// Run 3: a SECOND provider joins the stall → fingerprint changes → reported.
|
||||
var widened = new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps, null, null),
|
||||
new PolicyProviderState("Contoso", "device", EspPolicyProviderProbe.KindSetupApps, 0, null),
|
||||
});
|
||||
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => widened))
|
||||
{
|
||||
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
|
||||
sut.Tick();
|
||||
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
|
||||
sut.Tick();
|
||||
Assert.Single(StalledEvents(sink));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Healthy_or_absent_tracking_never_emits()
|
||||
{
|
||||
var healthy = new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
new PolicyProviderState("Sidecar", "device", EspPolicyProviderProbe.KindSetupApps, 1, null),
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindDevicePreparation, null, 3),
|
||||
});
|
||||
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => healthy))
|
||||
{
|
||||
var (sut, sink, clock) = BuildDetector();
|
||||
sut.Tick();
|
||||
clock.Advance(TimeSpan.FromHours(4));
|
||||
sut.Tick();
|
||||
Assert.Empty(StalledEvents(sink));
|
||||
}
|
||||
|
||||
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => EspPolicyProviderSnapshot.Empty))
|
||||
{
|
||||
var (sut, sink, clock) = BuildDetector();
|
||||
sut.Tick();
|
||||
clock.Advance(TimeSpan.FromHours(4));
|
||||
sut.Tick();
|
||||
Assert.Empty(StalledEvents(sink));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -112,6 +112,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Equal(false, data["backfilled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorBackfillSummary_EmitsInfoLogEvent_AnnouncingTheReplayBlock()
|
||||
{
|
||||
_tracker.EmitErrorBackfillSummary(replayedCount: 6, droppedOverCap: 0);
|
||||
|
||||
var posted = _sink.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent);
|
||||
Assert.Equal("modern_deployment_log", posted.Payload![SignalPayloadKeys.EventType]);
|
||||
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(posted.TypedPayload);
|
||||
Assert.Equal(true, data["backfillSummary"]);
|
||||
Assert.Equal(6, data["replayedCount"]);
|
||||
Assert.Equal(0, data["droppedOverCap"]);
|
||||
Assert.Equal(240, data["lookbackMinutes"]);
|
||||
|
||||
var message = (string)posted.Payload[SignalPayloadKeys.Message];
|
||||
Assert.Contains("Replaying 6 error event(s)", message);
|
||||
Assert.Contains("before the agent started", message);
|
||||
Assert.DoesNotContain("cap", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorBackfillSummary_MentionsDroppedRecords_WhenCapWasHit()
|
||||
{
|
||||
_tracker.EmitErrorBackfillSummary(replayedCount: 25, droppedOverCap: 7);
|
||||
|
||||
var posted = _sink.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent);
|
||||
var message = (string)posted.Payload![SignalPayloadKeys.Message];
|
||||
Assert.Contains("7 older record(s) beyond the 25-record cap", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledHarmlessId_StillRunsThroughRollupSuppression()
|
||||
{
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the pure parts of the UEFI Secure Boot certificate probe: the ASCII substring
|
||||
/// search over raw signature-list bytes and the <c>secureboot_status</c> payload shaping
|
||||
/// (in particular the one-sided <c>uefiCa2023FirmwareConfirmed</c> marker whose ABSENCE
|
||||
/// is load-bearing for ANALYZE-SEC-001's not_exists precondition). The actual firmware
|
||||
/// read is only smoke-tested: test machines may be VMs, non-elevated, or legacy BIOS,
|
||||
/// so only the fail-soft contract is asserted.
|
||||
/// </summary>
|
||||
public sealed class UefiSecureBootCertReaderTests
|
||||
{
|
||||
// --- ContainsAscii ---
|
||||
|
||||
[Fact]
|
||||
public void ContainsAscii_finds_needle_embedded_in_binary_noise()
|
||||
{
|
||||
var bytes = Concat(new byte[] { 0x00, 0xA1, 0x30, 0x82 },
|
||||
Encoding.ASCII.GetBytes("Windows UEFI CA 2023"),
|
||||
new byte[] { 0x00, 0xFF });
|
||||
|
||||
Assert.True(UefiSecureBootCertReader.ContainsAscii(bytes, "Windows UEFI CA 2023"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsAscii_finds_needle_at_the_very_end()
|
||||
{
|
||||
var bytes = Concat(new byte[] { 0x01, 0x02 }, Encoding.ASCII.GetBytes("KEK 2K CA 2023"));
|
||||
|
||||
Assert.True(UefiSecureBootCertReader.ContainsAscii(bytes, "KEK 2K CA 2023"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsAscii_rejects_partial_overlap_and_absent_needle()
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes("Windows UEFI CA 2011 something else");
|
||||
|
||||
Assert.False(UefiSecureBootCertReader.ContainsAscii(bytes, "Windows UEFI CA 2023"));
|
||||
// Repeated prefix bytes must not confuse the scan.
|
||||
var tricky = Encoding.ASCII.GetBytes("ababac");
|
||||
Assert.True(UefiSecureBootCertReader.ContainsAscii(tricky, "abac"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsAscii_handles_null_empty_and_oversized_needle()
|
||||
{
|
||||
Assert.False(UefiSecureBootCertReader.ContainsAscii(null, "x"));
|
||||
Assert.False(UefiSecureBootCertReader.ContainsAscii(new byte[0], "x"));
|
||||
Assert.False(UefiSecureBootCertReader.ContainsAscii(new byte[] { 1, 2 }, ""));
|
||||
Assert.False(UefiSecureBootCertReader.ContainsAscii(new byte[] { 1, 2 }, "abc"));
|
||||
}
|
||||
|
||||
// --- Payload shaping ---
|
||||
|
||||
[Fact]
|
||||
public void Marker_emitted_only_when_db_ok_and_ca2023_present()
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
DeviceInfoCollector.AppendFirmwareCertFields(data, new UefiSecureBootCertSnapshot
|
||||
{
|
||||
DbStatus = UefiSecureBootCertReader.StatusOk,
|
||||
KekStatus = UefiSecureBootCertReader.StatusOk,
|
||||
DbHasWindowsUefiCa2023 = true,
|
||||
KekHasMicrosoftKek2kCa2023 = true
|
||||
});
|
||||
|
||||
Assert.Equal(true, data["uefiCa2023FirmwareConfirmed"]);
|
||||
Assert.Equal("ok", data["uefiFirmwareReadStatus"]);
|
||||
Assert.Equal(true, data["uefiDbHasWindowsUefiCa2023"]);
|
||||
Assert.Equal(true, data["uefiKekHasMicrosoftKek2kCa2023"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Marker_absent_when_ca2023_missing_even_though_read_succeeded()
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
DeviceInfoCollector.AppendFirmwareCertFields(data, new UefiSecureBootCertSnapshot
|
||||
{
|
||||
DbStatus = UefiSecureBootCertReader.StatusOk,
|
||||
KekStatus = UefiSecureBootCertReader.StatusOk,
|
||||
DbHasWindowsUefiCa2023 = false,
|
||||
DbHasWindowsProductionPca2011 = true
|
||||
});
|
||||
|
||||
// The marker must be OMITTED, not false — not_exists fails on any present value.
|
||||
Assert.False(data.ContainsKey("uefiCa2023FirmwareConfirmed"));
|
||||
Assert.Equal(false, data["uefiDbHasWindowsUefiCa2023"]);
|
||||
Assert.Equal(true, data["uefiDbHasWindowsProductionPca2011"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_db_read_emits_status_but_no_cert_booleans_and_no_marker()
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
DeviceInfoCollector.AppendFirmwareCertFields(data, new UefiSecureBootCertSnapshot
|
||||
{
|
||||
DbStatus = UefiSecureBootCertReader.StatusPrivilegeDenied,
|
||||
KekStatus = UefiSecureBootCertReader.StatusPrivilegeDenied
|
||||
});
|
||||
|
||||
Assert.Equal(UefiSecureBootCertReader.StatusPrivilegeDenied, data["uefiFirmwareReadStatus"]);
|
||||
Assert.False(data.ContainsKey("uefiDbHasWindowsUefiCa2023"));
|
||||
Assert.False(data.ContainsKey("uefiKekHasMicrosoftKek2kCa2023"));
|
||||
Assert.False(data.ContainsKey("uefiCa2023FirmwareConfirmed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Db_ok_but_kek_failed_keeps_db_booleans_and_reports_kek_status()
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
DeviceInfoCollector.AppendFirmwareCertFields(data, new UefiSecureBootCertSnapshot
|
||||
{
|
||||
DbStatus = UefiSecureBootCertReader.StatusOk,
|
||||
KekStatus = UefiSecureBootCertReader.StatusVariableNotFound,
|
||||
DbHasWindowsUefiCa2023 = true
|
||||
});
|
||||
|
||||
Assert.Equal(UefiSecureBootCertReader.StatusVariableNotFound, data["uefiFirmwareReadStatus"]);
|
||||
Assert.Equal(true, data["uefiDbHasWindowsUefiCa2023"]);
|
||||
Assert.Equal(true, data["uefiCa2023FirmwareConfirmed"]);
|
||||
Assert.False(data.ContainsKey("uefiKekHasMicrosoftKek2kCa2023"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_snapshot_leaves_payload_untouched()
|
||||
{
|
||||
var data = new Dictionary<string, object> { { "uefiSecureBootEnabled", true } };
|
||||
DeviceInfoCollector.AppendFirmwareCertFields(data, null);
|
||||
|
||||
Assert.Single(data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeFirmwareVerdict_covers_all_shapes()
|
||||
{
|
||||
Assert.Equal("unavailable", DeviceInfoCollector.DescribeFirmwareVerdict(null));
|
||||
Assert.Equal(UefiSecureBootCertReader.StatusNotUefi, DeviceInfoCollector.DescribeFirmwareVerdict(
|
||||
new UefiSecureBootCertSnapshot { DbStatus = UefiSecureBootCertReader.StatusNotUefi }));
|
||||
Assert.Equal("CA2023 confirmed", DeviceInfoCollector.DescribeFirmwareVerdict(
|
||||
new UefiSecureBootCertSnapshot { DbStatus = UefiSecureBootCertReader.StatusOk, DbHasWindowsUefiCa2023 = true }));
|
||||
Assert.Equal("CA2023 missing", DeviceInfoCollector.DescribeFirmwareVerdict(
|
||||
new UefiSecureBootCertSnapshot { DbStatus = UefiSecureBootCertReader.StatusOk, DbHasWindowsUefiCa2023 = false }));
|
||||
}
|
||||
|
||||
// --- Reader smoke (real firmware; machine state unknown) ---
|
||||
|
||||
[Fact]
|
||||
public void Read_never_throws_and_reports_a_status_for_both_variables()
|
||||
{
|
||||
var snapshot = UefiSecureBootCertReader.Read();
|
||||
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.False(string.IsNullOrEmpty(snapshot.DbStatus));
|
||||
Assert.False(string.IsNullOrEmpty(snapshot.KekStatus));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SeSystemEnvironmentPrivilege is adjusted on the PROCESS token, so the enable →
|
||||
/// read → restore sequence is a process-wide side effect. Unserialized, a second
|
||||
/// reader observes "already enabled", skips its own restore, and then loses the
|
||||
/// privilege mid-read when the first reader disposes — surfacing as a privilege_denied
|
||||
/// that disagrees with the other readers. The machine's actual privilege state does
|
||||
/// not matter here: whatever it is, every reader must report the SAME status.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Read_is_serialized_across_concurrent_callers()
|
||||
{
|
||||
const int readers = 8;
|
||||
var snapshots = new UefiSecureBootCertSnapshot[readers];
|
||||
var failures = new System.Exception[readers];
|
||||
|
||||
var threads = new System.Threading.Thread[readers];
|
||||
for (var i = 0; i < readers; i++)
|
||||
{
|
||||
var index = i;
|
||||
threads[i] = new System.Threading.Thread(() =>
|
||||
{
|
||||
try { snapshots[index] = UefiSecureBootCertReader.Read(); }
|
||||
catch (System.Exception ex) { failures[index] = ex; }
|
||||
});
|
||||
threads[i].Start();
|
||||
}
|
||||
foreach (var thread in threads) thread.Join();
|
||||
|
||||
Assert.All(failures, Assert.Null);
|
||||
Assert.All(snapshots, s => Assert.False(string.IsNullOrEmpty(s.DbStatus)));
|
||||
Assert.All(snapshots, s => Assert.Equal(snapshots[0].DbStatus, s.DbStatus));
|
||||
Assert.All(snapshots, s => Assert.Equal(snapshots[0].KekStatus, s.KekStatus));
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] parts)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var part in parts) total += part.Length;
|
||||
var result = new byte[total];
|
||||
var offset = 0;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
System.Array.Copy(part, 0, result, offset, part.Length);
|
||||
offset += part.Length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -37,5 +37,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.False(DeviceInfoHost.IsEndTrigger(DecisionSignalKind.EspPhaseChanged, null));
|
||||
Assert.False(DeviceInfoHost.IsEndTrigger(DecisionSignalKind.InformationalEvent, Phase("FinalizingSetup")));
|
||||
}
|
||||
|
||||
// Audit Q2 — the payload shape the adapter's apps sub-phase declaration produces on the
|
||||
// ingress rail (InformationalEventPost: eventType + declared phase enum name).
|
||||
private static IReadOnlyDictionary<string, string> SubPhaseDeclaration(string declaredPhase) =>
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ SignalPayloadKeys.EventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition },
|
||||
{ AutopilotMonitor.Agent.V2.Core.Telemetry.Events.EventTimelineEmitter.PhaseParamKey, declaredPhase },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Apps_subphase_declarations_classify_as_esp_config_refresh_triggers()
|
||||
{
|
||||
Assert.Equal(DeviceInfoHost.EspConfigTriggerAppsDevice,
|
||||
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("AppsDevice")));
|
||||
Assert.Equal(DeviceInfoHost.EspConfigTriggerAppsUser,
|
||||
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("AppsUser")));
|
||||
// Non-apps phase declarations (e.g. the reducer's Finalizing entry) never classify.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("FinalizingSetup")));
|
||||
// A phase_transition without a declared phase never classifies.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent,
|
||||
new Dictionary<string, string> { { SignalPayloadKeys.EventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition } }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountSetup_phase_classifies_as_the_user_setup_refresh_trigger()
|
||||
{
|
||||
Assert.Equal(DeviceInfoHost.EspConfigTriggerAccountSetup,
|
||||
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, Phase("AccountSetup")));
|
||||
// DeviceSetup is the enrollment-start trigger, not an ESP-config refresh.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, Phase("DeviceSetup")));
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, null));
|
||||
// Unrelated informational events never classify.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent,
|
||||
new Dictionary<string, string> { { SignalPayloadKeys.EventType, "esp_config_detected" } }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Audit Q5 — WMI hardware values are trimmed at the source. BIOS serials become device
|
||||
/// identity keys downstream (F2 device history joins on TenantId + serial), so padding
|
||||
/// must never leave the agent; whitespace-only values collapse to the "Unknown" sentinel
|
||||
/// the retry logic already treats as unresolved.
|
||||
/// </summary>
|
||||
public sealed class HardwareInfoNormalizeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(" PF60H941 ", "PF60H941")]
|
||||
[InlineData("PF60H941", "PF60H941")]
|
||||
[InlineData("\tLENOVO \r\n", "LENOVO")]
|
||||
[InlineData("Unknown", "Unknown")]
|
||||
public void Trims_whitespace_at_the_source(string raw, string expected)
|
||||
=> Assert.Equal(expected, HardwareInfo.NormalizeWmiValue(raw));
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t\r\n")]
|
||||
public void Empty_and_whitespace_only_collapse_to_the_Unknown_sentinel(string raw)
|
||||
=> Assert.Equal("Unknown", HardwareInfo.NormalizeWmiValue(raw));
|
||||
|
||||
[Fact]
|
||||
public void Null_collapses_to_the_Unknown_sentinel()
|
||||
=> Assert.Equal("Unknown", HardwareInfo.NormalizeWmiValue(null));
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
/// <summary>
|
||||
/// Historic-replay suppression for APP events (session eaf3d8c4 part 2): 147 of 157 app
|
||||
/// events in that session were replayed from a previous enrollment's IME log — all 10
|
||||
/// "installed apps" were phantoms. Stale app-state transitions and DO telemetry must be
|
||||
/// suppressed BEFORE the sub-phase declaration, the timing bookkeeping and the terminal
|
||||
/// DecisionSignal dedup, so fire-once flags stay unconsumed and no phantom
|
||||
/// AppInstallCompleted/Failed reaches the engine (which could spuriously re-arm the
|
||||
/// AdvisoryCompletion window). The script twin lives in
|
||||
/// <see cref="ImeLogTrackerAdapterHistoricReplayTests"/>.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerAdapterHistoricAppReplayTests
|
||||
{
|
||||
private static readonly DateTime ClockNow = new DateTime(2026, 7, 23, 15, 42, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Stale_terminal_app_transition_is_fully_suppressed()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-old", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Installed);
|
||||
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.AppInstallComplete));
|
||||
Assert.Empty(f.DecisionSignals(DecisionSignalKind.AppInstallCompleted));
|
||||
// Shadow download_progress (status=completed) and the summary snapshot ride the
|
||||
// same emit — both must be gone too.
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.DownloadProgress));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.AppTrackingSummary));
|
||||
// Timing bookkeeping must not be poisoned (feeds FinalStatusBuilder).
|
||||
Assert.False(adapter.AppTimings.ContainsKey("app-old"));
|
||||
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fresh_run_after_suppressed_stale_run_fires_signal_timing_and_subphase()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-X", 0);
|
||||
|
||||
// Fresh ESP phase so the sub-phase declaration is armed.
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-5);
|
||||
adapter.TriggerEspPhaseFromTest("DeviceSetup");
|
||||
|
||||
// Stale replay of the same app — suppressed, must consume NEITHER the sub-phase
|
||||
// fire-once NOR the terminal-signal dedup for this appId.
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Installed);
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.PhaseTransition));
|
||||
Assert.Empty(f.DecisionSignals(DecisionSignalKind.AppInstallCompleted));
|
||||
|
||||
// The app genuinely runs in THIS enrollment.
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-2);
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Installing);
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-1);
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Installed);
|
||||
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.PhaseTransition)); // sub-phase fired fresh
|
||||
Assert.Single(f.DecisionSignals(DecisionSignalKind.AppInstallCompleted)); // signal fired fresh
|
||||
var timing = adapter.AppTimings["app-X"];
|
||||
Assert.Equal(ClockNow.AddMinutes(-2), timing.StartedAtUtc); // fresh stamps, not stale
|
||||
Assert.Equal(ClockNow.AddMinutes(-1), timing.CompletedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stale_do_telemetry_is_suppressed_fresh_emits()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-do", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-1);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oneshot_summary_is_shared_between_script_and_app_suppressions()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "aaa11111",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Success",
|
||||
});
|
||||
adapter.TriggerAppStateFromTest(new AppPackageState("app-old", 0),
|
||||
AppInstallationState.Installing, AppInstallationState.Installed);
|
||||
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FutureSkew_app_transition_still_emits()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-skew", 0);
|
||||
|
||||
// Mid-enrollment clock jump — genuine current activity, stamped to the clock.
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddHours(2);
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Installed);
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppInstallComplete));
|
||||
Assert.Equal(ClockNow, info.OccurredAtUtc);
|
||||
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
|
||||
Assert.Single(f.DecisionSignals(DecisionSignalKind.AppInstallCompleted));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimulationMode_bypasses_suppression_for_apps_and_scripts()
|
||||
{
|
||||
// The --replay-log-dir dev tool intentionally feeds historic lines.
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
f.Tracker.SimulationMode = true;
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerAppStateFromTest(new AppPackageState("app-sim", 0),
|
||||
AppInstallationState.Installing, AppInstallationState.Installed);
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "bbb22222",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Success",
|
||||
});
|
||||
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.AppInstallComplete));
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
}
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
/// <summary>
|
||||
/// Historic-replay handling (session eaf3d8c4): a previous enrollment's IME log surviving
|
||||
/// on disk made the agent replay week-old script activity — 156 phantom script_completed
|
||||
/// events with ~170 h durations (clock-clamped completion minus RAW stale start) and an
|
||||
/// immediate-upload flood. Script events whose source line is > 24 h stale are now
|
||||
/// suppressed entirely (one-shot <c>historic_ime_replay_detected</c> summary instead),
|
||||
/// and durations are computed from a timeline-consistent timestamp pair with a 24 h
|
||||
/// plausibility backstop.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerAdapterHistoricReplayTests
|
||||
{
|
||||
private static readonly DateTime ClockNow = new DateTime(2026, 7, 23, 15, 42, 0, DateTimeKind.Utc);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Suppression: stale (> 24 h past) source lines are a previous enrollment
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Stale_completion_is_suppressed_and_emits_oneshot_summary()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var ancient = ClockNow.AddDays(-7);
|
||||
f.Tracker.LastMatchedLogTimestamp = ancient;
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "d78c1822",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Failed",
|
||||
StartedAtUtc = ancient.AddMinutes(-30),
|
||||
// Bootstrap marker in stdout — week-old evidence must not drive the
|
||||
// bootstrap-detected trace either.
|
||||
Stdout = "Bootstrap script version: v2.0",
|
||||
});
|
||||
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptFailed));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptTimeoutSuspected));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.AgentTrace));
|
||||
|
||||
var summary = f.InfoEvent(SharedEventTypes.HistoricImeReplayDetected);
|
||||
Assert.Equal(ancient.ToString("o"), summary.Payload!["earliestRejectedSourceTimestamp"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Summary_is_oneshot_across_started_and_completed_suppressions()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerScriptStartedFromTest(new ScriptStartedInfo { PolicyId = "aaa11111", ScriptType = "platform" });
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7).AddMinutes(1);
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "aaa11111",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Success",
|
||||
});
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7).AddMinutes(2);
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "bbb22222",
|
||||
ScriptType = "remediation",
|
||||
ScriptPart = "detection",
|
||||
ExitCode = 0,
|
||||
ComplianceResult = "True",
|
||||
});
|
||||
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptStarted));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
// Exactly ONE summary regardless of how many replayed lines followed — and it dates
|
||||
// the replay window from the FIRST (earliest) suppressed line.
|
||||
var summary = Assert.Single(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
Assert.Equal(ClockNow.AddDays(-7).ToString("o"), summary.Payload!["earliestRejectedSourceTimestamp"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fresh_events_after_suppression_emit_normally()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "aaa11111",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Success",
|
||||
});
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
|
||||
// The agent catches up to current log content — normal emission resumes.
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-2);
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "ccc33333",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Success",
|
||||
StartedAtUtc = ClockNow.AddMinutes(-3),
|
||||
});
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
Assert.Equal("ccc33333", info.Payload!["policyId"]);
|
||||
Assert.Equal("60.00", info.Payload["durationSeconds"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clock_fallback_without_source_timestamp_still_emits()
|
||||
{
|
||||
// derivedFromClock with NO rejected source ts (synthetic/callback path) is not a
|
||||
// replay — must emit, and must not trigger the summary.
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "ddd44444",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Success",
|
||||
});
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
|
||||
Assert.False(info.Payload.ContainsKey("rejectedSourceTimestamp"));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Duration: timeline-consistent pair + plausibility backstop
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void FutureSkew_completion_emits_with_clamped_stamp_and_raw_pair_duration()
|
||||
{
|
||||
// Mid-enrollment clock jump (WhiteGlove +1h CMTrace skew case): the source ts is
|
||||
// rejected as future-skewed — NOT suppressed — and the duration must come from the
|
||||
// raw source pair (both ends on the same skewed timeline), not clamped-now − raw-start.
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var skewed = ClockNow.AddHours(2);
|
||||
f.Tracker.LastMatchedLogTimestamp = skewed;
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "eee55555",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Success",
|
||||
StartedAtUtc = skewed.AddSeconds(-360),
|
||||
});
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
Assert.Equal(ClockNow, info.OccurredAtUtc); // stamp: clamped to clock
|
||||
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
|
||||
Assert.Equal(skewed.ToString("o"), info.Payload["rejectedSourceTimestamp"]);
|
||||
Assert.Equal("360.00", info.Payload["durationSeconds"]); // duration: raw pair
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FutureSkew_failed_platform_script_timeout_heuristic_uses_raw_pair()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var skewed = ClockNow.AddHours(2);
|
||||
f.Tracker.LastMatchedLogTimestamp = skewed;
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "fff66666",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Failed",
|
||||
StartedAtUtc = skewed.AddMinutes(-26), // past the 25-min suspicion threshold
|
||||
});
|
||||
|
||||
var suspected = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptTimeoutSuspected));
|
||||
Assert.Equal("1560.00", suspected.Payload!["durationSeconds"]);
|
||||
Assert.Equal(ClockNow, suspected.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Implausible_duration_above_24h_is_omitted()
|
||||
{
|
||||
// Fresh (accepted) completion paired with an ancient start — e.g. a stale pending
|
||||
// slot a legacy path left behind. The duration would be a cross-run lie; omit it.
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-2);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "abc12345",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Failed",
|
||||
StartedAtUtc = ClockNow.AddHours(-30),
|
||||
});
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptFailed));
|
||||
Assert.False(info.Payload!.ContainsKey("durationSeconds"));
|
||||
Assert.False(info.Payload.ContainsKey("durationBasis"));
|
||||
// The 30 h "duration" must not read as a hung script either.
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptTimeoutSuspected));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tracker slot hardening: stale pending start must not pair with a fresh run
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Fresh_start_discards_stale_pending_slot_from_previous_enrollment()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
// Previous enrollment: replayed start line whose matching result never appeared
|
||||
// (the script was mid-run when that enrollment ended).
|
||||
f.Tracker.SeedPendingPlatformScriptForTesting("beef7777", exitCode: null, exitObservedAtUtc: null,
|
||||
startedAtUtc: ClockNow.AddDays(-7));
|
||||
|
||||
// Same policy genuinely re-runs now — the fresh start must begin a NEW execution.
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddSeconds(-90);
|
||||
f.Tracker.HandlePlatformScriptStarted("beef7777");
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow;
|
||||
f.Tracker.CompletePlatformScriptFromImeResultForTesting("beef7777", "Success", ClockNow);
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
// Without the hardening the slot kept the 7-day-old start: the duration would have
|
||||
// been dropped by the 24 h backstop (or, before that, shown as ~168 h).
|
||||
Assert.Equal("90.00", info.Payload!["durationSeconds"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -243,5 +243,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
|
||||
Assert.Equal(firstStamp, adapter.AppTimings["app-P"].StartedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_to_active_retry_rearms_CompletedAt_and_final_terminal_covers_total_occupancy()
|
||||
{
|
||||
// Audit Q1: the IME retry path (Error -> Downloading -> Installed is permitted by
|
||||
// AppPackageState.UpdateState). Pre-fix, CompletedAt froze at the first Error, so
|
||||
// the final Installed payload carried the first failure's completedAt/duration.
|
||||
using var f = new ImeLogTrackerAdapterFixture(T0);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-R", 0);
|
||||
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Installing);
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(5));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Error);
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(10));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Error, AppInstallationState.Downloading);
|
||||
|
||||
// Re-armed while active again: the retry's download event carries no completedAt,
|
||||
// and the FIRST start stamp is kept.
|
||||
Assert.Null(adapter.AppTimings["app-R"].CompletedAtUtc);
|
||||
var retryDownload = f.InfoEvent(SharedEventTypes.AppDownloadStarted);
|
||||
Assert.False(retryDownload.Payload!.ContainsKey("completedAt"));
|
||||
Assert.False(retryDownload.Payload.ContainsKey("durationSeconds"));
|
||||
Assert.Equal(T0.ToString("o"), retryDownload.Payload["startedAt"]);
|
||||
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(20));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installed);
|
||||
|
||||
var timing = adapter.AppTimings["app-R"];
|
||||
Assert.Equal(T0, timing.StartedAtUtc);
|
||||
Assert.Equal(T0.AddSeconds(35), timing.CompletedAtUtc);
|
||||
Assert.Equal(35.0, timing.DurationSeconds);
|
||||
|
||||
var completed = Assert.Single(f.InfoEvents(SharedEventTypes.AppInstallComplete));
|
||||
Assert.Equal("35.00", completed.Payload!["durationSeconds"]);
|
||||
Assert.Equal(T0.AddSeconds(35).ToString("o"), completed.Payload["completedAt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skipped_then_retried_app_emits_positive_duration_from_retry_start()
|
||||
{
|
||||
// Production regression (session 66f1c5e1, audit Q1): a user-scope app evaluated
|
||||
// Skipped during DeviceSetup (terminal, no start observed), retried after sign-in
|
||||
// (Skipped -> Downloading is permitted), Installed later. Pre-fix payload:
|
||||
// completedAt frozen at the early Skip + startedAt from the retry ->
|
||||
// durationSeconds=-2342. Post-fix: retry re-arms, duration covers retry-start ->
|
||||
// final terminal.
|
||||
using var f = new ImeLogTrackerAdapterFixture(T0);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-S", 0);
|
||||
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Skipped);
|
||||
Assert.Null(adapter.AppTimings["app-S"].StartedAtUtc);
|
||||
Assert.Equal(T0, adapter.AppTimings["app-S"].CompletedAtUtc);
|
||||
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(60));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Skipped, AppInstallationState.Downloading);
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(30));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installed);
|
||||
|
||||
var timing = adapter.AppTimings["app-S"];
|
||||
Assert.Equal(T0.AddSeconds(60), timing.StartedAtUtc);
|
||||
Assert.Equal(T0.AddSeconds(90), timing.CompletedAtUtc);
|
||||
Assert.Equal(30.0, timing.DurationSeconds);
|
||||
|
||||
var completedEvents = f.InfoEvents(SharedEventTypes.AppInstallComplete);
|
||||
Assert.Equal(2, completedEvents.Count);
|
||||
Assert.Equal("30.00", completedEvents[1].Payload!["durationSeconds"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inverted_endpoints_yield_null_DurationSeconds()
|
||||
{
|
||||
// Negative guard (audit Q1): endpoints stamped from out-of-order source lines must
|
||||
// never surface as a negative durationSeconds (observed -2342s in production). The
|
||||
// payload emitter omits the field when DurationSeconds is null.
|
||||
var inverted = new AppInstallTiming(startedAtUtc: T0.AddSeconds(100), completedAtUtc: T0);
|
||||
Assert.Null(inverted.DurationSeconds);
|
||||
|
||||
var wellOrdered = new AppInstallTiming(startedAtUtc: T0, completedAtUtc: T0.AddSeconds(100));
|
||||
Assert.Equal(100.0, wellOrdered.DurationSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+82
@@ -511,6 +511,88 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
$"network_bandwidth_estimate (@{bandwidthIdx}) must precede whiteglove_part1_complete (@{part1CompleteIdx}) so the straggler stays in Part 1.");
|
||||
}
|
||||
|
||||
// ===================================================== WG Part-1 preprov diagnostics
|
||||
// V1 parity (restored 2026-07-28): the seal exit uploads the diagnostics package as a
|
||||
// pre-provisioning intermediate under the SAME mode gating as the terminal path — the
|
||||
// seal counts as a success, so Always uploads, OnFailure skips (a Part-1 failure
|
||||
// terminates via the regular Failed path, which uploads), Off does nothing.
|
||||
|
||||
[Theory]
|
||||
[InlineData("Always", true, 1)]
|
||||
[InlineData("OnFailure", true, 0)]
|
||||
[InlineData("Off", true, 0)]
|
||||
[InlineData("Always", false, 0)]
|
||||
public void Handle_whiteglove_part1_diagnostics_upload_respects_mode(
|
||||
string mode, bool enabled, int expected)
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
var cfg = rig.BuildConfig(diagEnabled: enabled, diagMode: mode);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
Assert.Equal(expected, rig.DiagnosticsUploads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_whiteglove_part1_diagnostics_upload_uses_preprov_suffix_and_succeeded_flag()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
Assert.Equal(1, rig.DiagnosticsUploads);
|
||||
Assert.Equal("preprov", rig.LastDiagnosticsSuffix);
|
||||
Assert.Equal(true, rig.LastDiagnosticsSucceededFlag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_whiteglove_part1_diagnostics_events_precede_part1_complete_marker()
|
||||
{
|
||||
// whiteglove_part1_complete is the authoritative Part-1/Part-2 boundary for the
|
||||
// web split (computeWhiteGloveSplitSequence) — diagnostics_collecting/uploaded
|
||||
// must sequence BEFORE it or they get mis-filed into the resumed Part-2 block.
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult { BlobName = "diag-preprov.zip" };
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
var emitted = rig.EmittedEventTypes.ToList();
|
||||
var collectingIdx = emitted.IndexOf("diagnostics_collecting");
|
||||
var uploadedIdx = emitted.IndexOf("diagnostics_uploaded");
|
||||
var part1CompleteIdx = emitted.IndexOf(Constants.EventTypes.WhiteGlovePart1Complete);
|
||||
|
||||
Assert.True(collectingIdx >= 0, "diagnostics_collecting must have been emitted.");
|
||||
Assert.True(uploadedIdx >= 0, "diagnostics_uploaded must have been emitted.");
|
||||
Assert.True(part1CompleteIdx >= 0, "whiteglove_part1_complete must have been emitted.");
|
||||
Assert.True(uploadedIdx < part1CompleteIdx,
|
||||
$"diagnostics_uploaded (@{uploadedIdx}) must precede whiteglove_part1_complete (@{part1CompleteIdx}) so the upload stays in Part 1.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_whiteglove_part1_still_skips_cleanup_and_marker_when_diagnostics_enabled()
|
||||
{
|
||||
// The preprov upload must not change the Part-1 exit contract: no self-destruct,
|
||||
// no enrollment-complete.marker, whiteglove.complete still written.
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
Assert.Equal(0, rig.CleanupService.Invocations);
|
||||
Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker")));
|
||||
Assert.True(rig.SessionPersistence.IsWhiteGloveResume());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_standalone_reboot_fires_when_reboot_enabled_and_no_self_destruct()
|
||||
{
|
||||
|
||||
+79
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Termination;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
@@ -70,5 +72,82 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.Equal(0, bootToStart, 0);
|
||||
Assert.Equal(10, uptime, 0);
|
||||
}
|
||||
|
||||
// --- DescribeLateStart: outcome-calibrated severity + phrasing (fleet 2026-07-28) ---
|
||||
|
||||
private static void Describe(
|
||||
EnrollmentTerminationOutcome outcome, string oobeState,
|
||||
out EventSeverity severity, out string message, out string note)
|
||||
=> ObservationCoverage.DescribeLateStart(
|
||||
outcome, oobeState, bootToStartSeconds: 34 * 60, uptimeSeconds: 30,
|
||||
out severity, out message, out note);
|
||||
|
||||
[Fact]
|
||||
public void Failed_outcome_is_a_warning_with_the_hung_script_post_mortem_note()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, "in_progress",
|
||||
out var severity, out var message, out var note);
|
||||
|
||||
Assert.Equal(EventSeverity.Warning, severity);
|
||||
Assert.Contains("low coverage of the enrollment window", message);
|
||||
Assert.Contains("post-mortem", note);
|
||||
Assert.Contains("hung ahead of the bootstrap", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimedOut_outcome_is_a_warning_like_a_failure()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.TimedOut, "in_progress",
|
||||
out var severity, out _, out var note);
|
||||
|
||||
Assert.Equal(EventSeverity.Warning, severity);
|
||||
Assert.Contains("post-mortem", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Succeeded_outcome_is_an_info_about_tail_only_coverage_without_alarm_framing()
|
||||
{
|
||||
// The 659c3a90 shape: healthy enrollment, bootstrap merely ran late in the ESP queue.
|
||||
Describe(EnrollmentTerminationOutcome.Succeeded, "in_progress",
|
||||
out var severity, out _, out var note);
|
||||
|
||||
Assert.Equal(EventSeverity.Info, severity);
|
||||
Assert.Contains("succeeded", note);
|
||||
Assert.Contains("IME log replay", note);
|
||||
Assert.DoesNotContain("post-mortem", note);
|
||||
Assert.DoesNotContain("hung", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oobe_completed_at_start_phrases_a_pure_post_mortem()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, "completed",
|
||||
out _, out var message, out _);
|
||||
|
||||
Assert.Contains("OOBE was already completed when the agent started", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oobe_in_progress_at_start_phrases_the_live_tail()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, "in_progress",
|
||||
out _, out var message, out _);
|
||||
|
||||
Assert.Contains("OOBE was still in progress at agent start", message);
|
||||
Assert.Contains("final 30s live", message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not_started")] // flips in after a mid-OOBE reboot — not interpretable
|
||||
[InlineData("unavailable")] // WinRT contract absent / read failed
|
||||
[InlineData("unknown_3")] // unexpected enum value
|
||||
public void Uninterpretable_oobe_states_add_no_claim_to_the_message(string oobeState)
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, oobeState,
|
||||
out _, out var message, out _);
|
||||
|
||||
Assert.DoesNotContain("OOBE", message);
|
||||
Assert.EndsWith("low coverage of the enrollment window.", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// </summary>
|
||||
public string ImeMatchLogPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to write a gather-rule evaluation trace (for debugging).
|
||||
/// If empty, no trace is written. Set from remote config (EnableGatherRuleDebugLog)
|
||||
/// or the --gather-debug-log CLI override.
|
||||
/// </summary>
|
||||
public string GatherRuleDebugLogPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to directory with real IME log files for log replay.
|
||||
/// When set, the agent replays these logs with time compression instead of reading live logs.
|
||||
|
||||
@@ -135,6 +135,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- GatherRuleDebugLog: same bool→path projection as ImeMatchLog
|
||||
// above. `true` expands Constants.GatherRuleDebugLogPath, `false`
|
||||
// nulls the runtime path unless a --gather-debug-log CLI override
|
||||
// supplied a non-default path.
|
||||
if (remoteConfig.EnableGatherRuleDebugLog)
|
||||
{
|
||||
agentConfig.GatherRuleDebugLogPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
var defaultGatherDebugPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
if (string.IsNullOrEmpty(agentConfig.GatherRuleDebugLogPath)
|
||||
|| string.Equals(agentConfig.GatherRuleDebugLogPath, defaultGatherDebugPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
agentConfig.GatherRuleDebugLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Upload interval / batch — direct, V1 parity (no > 0 gate so a
|
||||
// misconfigured tenant can in theory break uploads, but this
|
||||
// surfaces the config bug instead of silently masking it).
|
||||
|
||||
@@ -327,6 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
KeepLogFile = false,
|
||||
EnableGeoLocation = true,
|
||||
EnableImeMatchLog = false,
|
||||
EnableGatherRuleDebugLog = false,
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
GatherRules = new System.Collections.Generic.List<GatherRule>(),
|
||||
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
|
||||
|
||||
+10
-5
@@ -10,9 +10,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// the <c>app_tracking_summary</c> terminal event.
|
||||
/// <para>
|
||||
/// <b>StartedAtUtc</b> is recorded on the first lifecycle transition into Downloading /
|
||||
/// Installing / InProgress. <b>CompletedAtUtc</b> is recorded on the first transition to a
|
||||
/// terminal state (Installed / Skipped / Postponed / Error). Both are "set-once" — subsequent
|
||||
/// identical-state events do not overwrite an earlier stamp.
|
||||
/// Installing / InProgress and never overwritten. <b>CompletedAtUtc</b> is recorded on a
|
||||
/// transition to a terminal state (Installed / Skipped / Postponed / Error) and re-armed
|
||||
/// to <c>null</c> when the app goes terminal → active again (IME retry / re-evaluation,
|
||||
/// audit Q1) — so the final terminal event carries first-start → last-terminal.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AppInstallTiming
|
||||
@@ -30,10 +31,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>
|
||||
/// Elapsed time from first Downloading/Installing transition to terminal state, in
|
||||
/// seconds. <c>null</c> when either endpoint is missing (e.g. app still installing,
|
||||
/// or the adapter missed the start event because it came online mid-install).
|
||||
/// or the adapter missed the start event because it came online mid-install) — and
|
||||
/// <c>null</c> when the endpoints are inverted (CompletedAt before StartedAt: log lines
|
||||
/// from different source files can be processed out of chronological order; observed in
|
||||
/// production as durationSeconds=-2342 on a Skipped-then-retried user-scope app,
|
||||
/// audit Q1). A negative duration is never a real measurement, so it is not emitted.
|
||||
/// </summary>
|
||||
public double? DurationSeconds =>
|
||||
StartedAtUtc.HasValue && CompletedAtUtc.HasValue
|
||||
StartedAtUtc.HasValue && CompletedAtUtc.HasValue && CompletedAtUtc.Value >= StartedAtUtc.Value
|
||||
? (CompletedAtUtc.Value - StartedAtUtc.Value).TotalSeconds
|
||||
: (double?)null;
|
||||
}
|
||||
|
||||
+51
-30
@@ -208,39 +208,60 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
else
|
||||
{
|
||||
// Platform script: AgentExecutor.log entries create/enrich, IME log entries also create
|
||||
if (!_pendingPlatformScripts.ContainsKey(id))
|
||||
{
|
||||
// A fresh start for this policy (no pending entry) begins a NEW execution.
|
||||
// Clear any emitted-marker from a prior run so IME re-evaluations / retries of
|
||||
// the same platform-script policy within one agent lifetime are not silently
|
||||
// deduped away. Within a single run the start always precedes exit/result, so
|
||||
// this never clears the current run's own marker.
|
||||
_platformScriptResultEmitted.Remove(id);
|
||||
_pendingPlatformScripts[id] = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = id,
|
||||
ScriptType = "platform",
|
||||
// Prefer the source CMTrace timestamp so a script that started before the
|
||||
// agent launched (replayed log content) is dated to its real start, not now.
|
||||
// Set only at slot creation: the start line fires twice (agentexecutor + ime
|
||||
// source) but the earliest wins.
|
||||
StartedAtUtc = LastMatchedLogTimestamp ?? DateTime.UtcNow,
|
||||
};
|
||||
// Live "running" indicator — same signal health scripts emit. Gated on slot
|
||||
// creation so the duplicate start line (agentexecutor + ime source) doesn't
|
||||
// double-emit for the same execution.
|
||||
try { OnScriptStarted?.Invoke(new ScriptStartedInfo { PolicyId = id, ScriptType = "platform" }); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: OnScriptStarted handler threw: {ex.Message}"); }
|
||||
}
|
||||
_lastPlatformScriptPolicyId = id;
|
||||
// Started lines fire twice per script (agentexecutor + ime source) and carry no
|
||||
// outcome — the matching `platform script completed` line below carries result+exit
|
||||
// and stays on Info. Keep starts on Debug so Info reflects script outcomes only.
|
||||
_logger.Debug($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})");
|
||||
HandlePlatformScriptStarted(id, source);
|
||||
}
|
||||
}
|
||||
|
||||
// Platform script: AgentExecutor.log entries create/enrich, IME log entries also create.
|
||||
// Internal so the stale-slot hardening is directly testable (the public path arrives via
|
||||
// a regex Match).
|
||||
internal void HandlePlatformScriptStarted(string id, string source = null)
|
||||
{
|
||||
// Stale-slot hardening (session eaf3d8c4): a replayed start line from a previous
|
||||
// enrollment whose matching result never appeared (script was mid-run when that
|
||||
// enrollment ended) leaves a pending slot with a days-old StartedAtUtc. When the
|
||||
// same policy genuinely re-runs now, the fresh start must begin a NEW execution —
|
||||
// otherwise the ancient start pairs with the fresh completion and the duration
|
||||
// lies. > 24 h mirrors the adapter's source-timestamp staleness clamp.
|
||||
var currentStartTs = LastMatchedLogTimestamp ?? DateTime.UtcNow;
|
||||
if (_pendingPlatformScripts.TryGetValue(id, out var pendingSlot)
|
||||
&& pendingSlot.StartedAtUtc.HasValue
|
||||
&& currentStartTs - pendingSlot.StartedAtUtc.Value > TimeSpan.FromHours(24))
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: discarding stale pending platform-script slot for {id} (started {pendingSlot.StartedAtUtc:o})");
|
||||
_pendingPlatformScripts.Remove(id);
|
||||
}
|
||||
if (!_pendingPlatformScripts.ContainsKey(id))
|
||||
{
|
||||
// A fresh start for this policy (no pending entry) begins a NEW execution.
|
||||
// Clear any emitted-marker from a prior run so IME re-evaluations / retries of
|
||||
// the same platform-script policy within one agent lifetime are not silently
|
||||
// deduped away. Within a single run the start always precedes exit/result, so
|
||||
// this never clears the current run's own marker.
|
||||
_platformScriptResultEmitted.Remove(id);
|
||||
_pendingPlatformScripts[id] = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = id,
|
||||
ScriptType = "platform",
|
||||
// Prefer the source CMTrace timestamp so a script that started before the
|
||||
// agent launched (replayed log content) is dated to its real start, not now.
|
||||
// Set only at slot creation: the start line fires twice (agentexecutor + ime
|
||||
// source) but the earliest wins.
|
||||
StartedAtUtc = currentStartTs,
|
||||
};
|
||||
// Live "running" indicator — same signal health scripts emit. Gated on slot
|
||||
// creation so the duplicate start line (agentexecutor + ime source) doesn't
|
||||
// double-emit for the same execution.
|
||||
try { OnScriptStarted?.Invoke(new ScriptStartedInfo { PolicyId = id, ScriptType = "platform" }); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: OnScriptStarted handler threw: {ex.Message}"); }
|
||||
}
|
||||
_lastPlatformScriptPolicyId = id;
|
||||
// Started lines fire twice per script (agentexecutor + ime source) and carry no
|
||||
// outcome — the matching `platform script completed` line below carries result+exit
|
||||
// and stays on Info. Keep starts on Debug so Info reflects script outcomes only.
|
||||
_logger.Debug($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})");
|
||||
}
|
||||
|
||||
// Routes line-by-line script-data handlers (context / exitCode / output) to the right
|
||||
// accumulator based on the pattern's scriptType parameter. Health-script lines feed the
|
||||
// single-slot _pendingHealthScript; platform-script lines feed _pendingPlatformScripts.
|
||||
|
||||
+34
@@ -194,6 +194,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
// Actions that mutate app/phase tracking state (_packageStates, _phasePackageSnapshots,
|
||||
// _currentPhaseOrder, DO telemetry) — the historic-replay guard skips these for source
|
||||
// lines from a previous enrollment so replayed apps never enter tracked state,
|
||||
// persistence, app_tracking_summary, culprit lists or final-status. Script actions are
|
||||
// deliberately NOT here: their tracker state is harmless (stale-slot hardening covers
|
||||
// leftovers) and the adapter suppresses their emissions. espphasedetected IS here — a
|
||||
// stale "In EspPhase: AccountSetup" would advance _currentPhaseOrder and make the fresh
|
||||
// enrollment's DeviceSetup lines bounce as "backward"; IME re-logs the phase constantly,
|
||||
// so fresh lines re-deliver it immediately.
|
||||
private static readonly HashSet<string> AppMutatingActions = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"imeshutdown", "espphasedetected", "setcurrentapp",
|
||||
"updatestateinstalled", "updatestatedownloading", "updatestateinstalling",
|
||||
"updatestateskipped", "updatestateerror", "updatestatepostponed",
|
||||
"captureexitcode", "capturehresult", "captureappversion",
|
||||
"captureapptypewinget", "captureapptypemsi", "captureattemptnumber",
|
||||
"capturedetectionresult", "esptrackstatus", "policiesdiscovered",
|
||||
"ignorecompletedapp", "updatename", "updatewin32appstate",
|
||||
"cancelstuckandsetcurrent", "updatedotelemetry",
|
||||
};
|
||||
|
||||
private void HandlePatternMatch(CompiledPattern pattern, Match match, string message, CmTraceLogEntry entry)
|
||||
{
|
||||
LastMatchedPatternId = pattern.PatternId;
|
||||
@@ -206,6 +227,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
try { OnPatternMatched?.Invoke(pattern.PatternId); }
|
||||
catch (Exception ex) { _logger?.Warning($"OnPatternMatched handler threw: {ex.Message}"); }
|
||||
|
||||
// Historic-replay guard (session eaf3d8c4): a source line > 24 h older than now is
|
||||
// content from a previous enrollment whose IME log survived on disk. App-mutating
|
||||
// actions are skipped BEFORE _seenAppIds so replayed apps poison neither the tracked
|
||||
// state nor the phase-change ignore list. SimulationMode (--replay-log-dir dev tool)
|
||||
// replays historic logs on purpose and bypasses the guard.
|
||||
var isStaleReplayLine = !SimulationMode && entry != null
|
||||
&& (UtcNowProvider() - NormalizeUtc(entry.Timestamp)) > HistoricReplayThreshold;
|
||||
if (isStaleReplayLine && AppMutatingActions.Contains(pattern.Action?.ToLower() ?? string.Empty))
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: skipped app action '{pattern.Action}' for historic line ({entry.Timestamp:o})");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var id = match.Groups["id"]?.Value;
|
||||
|
||||
@@ -71,6 +71,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public double SpeedFactor { get; set; } = 50;
|
||||
private DateTime _lastLogTimestamp = DateTime.MinValue;
|
||||
|
||||
// Source log lines older than this relative to now are content from a previous
|
||||
// enrollment (IME logs surviving a re-enrollment, session eaf3d8c4). Single source of
|
||||
// truth shared with the adapter's ResolveOccurredAt clamp and replay suppression.
|
||||
internal static readonly TimeSpan HistoricReplayThreshold = TimeSpan.FromHours(24);
|
||||
|
||||
// Deterministic-time seam for the historic-replay guard; production default is the
|
||||
// system clock, ImeLogHost rewires it to the agent IClock, tests to their VirtualClock.
|
||||
internal Func<DateTime> UtcNowProvider { get; set; } = () => DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// CMTrace timestamps from IME are emitted in UTC, but DateTimeKind may arrive as
|
||||
/// Unspecified depending on the parser path. Normalize via SpecifyKind=Utc rather than
|
||||
/// ToUniversalTime() to avoid double-conversion when Kind is already UTC.
|
||||
/// </summary>
|
||||
internal static DateTime NormalizeUtc(DateTime value) =>
|
||||
value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||
|
||||
// Background task
|
||||
private Task _pollingTask;
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
+75
@@ -61,6 +61,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
internal Func<int, string> SessionOwnerResolver { get; set; }
|
||||
internal Func<int, string> ProcessOwnerResolver { get; set; }
|
||||
|
||||
// Test seam for the WinRT OOBE-state sample (null = OobeStateReader.Read).
|
||||
internal Func<string> OobeStateProvider { get; set; }
|
||||
|
||||
// OOBE-state flip tracking (observational only — see OobeStateReader contract).
|
||||
// _oobeCompletedEmitted is one-shot per agent lifetime and deliberately NOT reset
|
||||
// by ResetForRealUserSwitch: the OOBE flip is a global OS transition, not per-user.
|
||||
private string _lastOobeState;
|
||||
private bool _oobeCompletedEmitted;
|
||||
|
||||
/// <summary>
|
||||
/// System/service account names that should NOT be considered real users.
|
||||
/// </summary>
|
||||
@@ -161,6 +170,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
_startedAtUtc = DateTime.UtcNow;
|
||||
|
||||
// Baseline OOBE-state sample so the first poll can already detect a flip
|
||||
// (self-contained try/catch inside, like the Emit* helpers).
|
||||
CheckOobeStateFlip();
|
||||
|
||||
_pollingTimer?.Dispose();
|
||||
_pollingTimer = new Timer(
|
||||
PollForDesktop,
|
||||
@@ -186,6 +199,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
_pollCount++;
|
||||
|
||||
CheckOobeStateFlip();
|
||||
|
||||
try
|
||||
{
|
||||
var explorerProcesses = Process.GetProcessesByName("explorer");
|
||||
@@ -381,6 +396,66 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: detector-started emit failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Samples the WinRT OOBE state (<see cref="OobeStateReader"/>) and emits the one-shot
|
||||
/// <c>oobe_state_completed</c> trace event on the in_progress->completed flip.
|
||||
/// Observational only — an owner-independent desktop corroboration for sessions where
|
||||
/// WTS+WMI owner resolution starves and <c>desktop_arrived</c> never fires. Often absent
|
||||
/// in healthy sessions: the polling timer is disposed at desktop arrival, which is when
|
||||
/// the flip lands (empirical probe, session 9c404ae9).
|
||||
/// <para>
|
||||
/// Self-contained try/catch: a throw here must NOT reach <see cref="PollForDesktop"/>'s
|
||||
/// outer catch, which would inflate <see cref="_wmiErrorCountSinceStart"/> and skew the
|
||||
/// session-4d5a0b78 liveness diagnostics. "unavailable" readings never overwrite the last
|
||||
/// known state, so a transient reflection failure between two polls cannot mask the flip.
|
||||
/// One-shot flag survives <see cref="ResetForRealUserSwitch"/> — the flip is a global OS
|
||||
/// transition, not per-user.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void CheckOobeStateFlip()
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = (OobeStateProvider ?? OobeStateReader.Read)();
|
||||
if (string.IsNullOrEmpty(current) || current == OobeStateReader.Unavailable)
|
||||
return;
|
||||
|
||||
if (!_oobeCompletedEmitted
|
||||
&& _lastOobeState == "in_progress"
|
||||
&& current == "completed")
|
||||
{
|
||||
_oobeCompletedEmitted = true;
|
||||
var minutesSinceStart = Math.Round((DateTime.UtcNow - _startedAtUtc).TotalMinutes, 1);
|
||||
_logger.Info("DesktopArrivalDetector: OOBE state flipped in_progress->completed (WinRT SystemSetupInfo)");
|
||||
OnTraceEvent?.Invoke(
|
||||
SharedEventTypes.OobeStateCompleted,
|
||||
$"OOBE state flipped in_progress->completed (WinRT SystemSetupInfo, poll {_pollCount}) — owner-independent desktop corroboration.",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "previousState", "in_progress" },
|
||||
{ "pollCount", _pollCount },
|
||||
{ "minutesSinceStart", minutesSinceStart },
|
||||
{ "desktopArrived", _desktopArrived },
|
||||
{ "wmiErrorCount", _wmiErrorCountSinceStart },
|
||||
{ "wtsErrorCount", _wtsErrorCountSinceStart },
|
||||
});
|
||||
}
|
||||
|
||||
_lastOobeState = current;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Verbose($"DesktopArrivalDetector: OOBE state check failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives a single synchronous poll for unit tests — the production poll is private and
|
||||
/// timer-driven with a fixed 30 s interval (precedent: <see cref="ResolveOwner"/> is
|
||||
/// likewise internal for direct testing).
|
||||
/// </summary>
|
||||
internal void PollOnceForTest() => PollForDesktop(null);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the owning user of an explorer.exe candidate. Session 4d5a0b78 fix
|
||||
/// (2026-06-11): the WMI <c>Win32_Process.GetOwner</c> call failed on every poll on
|
||||
|
||||
+16
-1
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
@@ -38,16 +39,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
public string Category { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Session 4910a5a5: names of the tracked apps most likely behind an Apps-subcategory
|
||||
/// failure (capped, never-started apps ranked first — those starve ESP's app gate
|
||||
/// without ever producing an IME install event). Snapshot from
|
||||
/// <c>ProvisioningStatusTracker.SnapshotTrackedAppsNotCompleted</c> at failure-arming
|
||||
/// time; empty for non-Apps failures and event-log-derived failures. Propagated onto
|
||||
/// the EspTerminalFailure signal payload so the DecisionEngine's
|
||||
/// <c>esp_failure_advisory</c> / <c>enrollment_failed</c> events can name the culprit
|
||||
/// directly.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> LikelyCulpritApps { get; }
|
||||
|
||||
public EspFailureDetectedEventArgs(
|
||||
string failureType,
|
||||
string errorCode = null,
|
||||
string failedSubcategory = null,
|
||||
string category = null)
|
||||
string category = null,
|
||||
IReadOnlyList<string> likelyCulpritApps = null)
|
||||
{
|
||||
FailureType = string.IsNullOrEmpty(failureType) ? "unknown" : failureType;
|
||||
ErrorCode = string.IsNullOrEmpty(errorCode) ? null : errorCode;
|
||||
FailedSubcategory = string.IsNullOrEmpty(failedSubcategory) ? null : failedSubcategory;
|
||||
Category = string.IsNullOrEmpty(category) ? null : category;
|
||||
LikelyCulpritApps = likelyCulpritApps ?? Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Session 5d735290 (2026-07-23) — reads the ESP's registered policy providers from
|
||||
/// <c>HKLM\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking</c>, the registry
|
||||
/// mirror of the EnrollmentStatusTracking CSP
|
||||
/// (https://learn.microsoft.com/en-us/windows/client-management/mdm/enrollmentstatustracking-csp).
|
||||
/// <para>
|
||||
/// CSP contract the ESP enforces (quotes from the doc above):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Setup/Apps/PolicyProviders/<Name></c> — "Existence of this node indicates
|
||||
/// to the ESP that it shouldn't show the tracking status message until the
|
||||
/// TrackingPoliciesCreated node has been set to true." This wait has NO timeout: a
|
||||
/// registered provider that never sets <c>TrackingPoliciesCreated=1</c> parks the ESP at
|
||||
/// "Apps (Identifying)" indefinitely (observed for days in the field).</item>
|
||||
/// <item><c>DevicePreparation/PolicyProviders/<Name>/InstallationState</c> — must reach
|
||||
/// 2 (NotRequired) or 3 (Completed); 1=NotInstalled, 4=Error. ESP applies a default
|
||||
/// 15-minute timeout here, so stalls in this stage are self-surfacing — still reported
|
||||
/// for the diagnostic trail.</item>
|
||||
/// </list>
|
||||
/// Registry layout: <c>Device</c> subkey for device scope, <c>S-<SID></c> subkeys for user
|
||||
/// scope (appear at sign-in), each containing <c>Setup\Apps\PolicyProviders\<Name></c>;
|
||||
/// <c>DevicePreparation\PolicyProviders</c> exists in device scope only.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Known provider names: <c>Sidecar</c> is the Intune Management Extension ("Intune's agents,
|
||||
/// such as SideCar" per the CSP doc); <c>ConfigMgr</c> is the Configuration Manager client
|
||||
/// (registered during Autopilot-into-co-management, see the troubleshooting registry paths in
|
||||
/// https://learn.microsoft.com/en-us/intune/configmgr/comanage/autopilot-enrollment). Microsoft
|
||||
/// warns that ESP policy providers are not aware of each other and that pre-provisioning is
|
||||
/// unsupported with co-management. Field case that motivated this probe (issue #106): a
|
||||
/// pre-provisioned device with the ConfigMgr client installed as a blocking app ended up with
|
||||
/// ONLY <c>ConfigMgr</c> registered under <c>Device\Setup\Apps\PolicyProviders</c> (no
|
||||
/// <c>Sidecar</c>) — and although <c>TrackingPoliciesCreated=1</c> WAS set under it, the user
|
||||
/// ESP hung at "Apps (Identifying)" for hours to days; manually renaming the key to
|
||||
/// <c>Sidecar</c> unblocked it immediately. So the ESP's Apps wait is keyed to the provider
|
||||
/// the Intune service registered at enrollment (always <c>Sidecar</c>; ConfigMgr only joins
|
||||
/// via a co-management settings policy — see the WAP payload documented in
|
||||
/// https://oofhours.com/2023/08/25/run-an-sccm-task-sequence-during-autopilot/), and a
|
||||
/// foreign provider's completion values do not satisfy it. Detection therefore keys on the
|
||||
/// provider NAME topology, not only on completeness:
|
||||
/// <see cref="EspPolicyProviderStallDetector"/> turns this contract into the two-arm
|
||||
/// <c>esp_policy_provider_stalled</c> tripwire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fail-soft like <see cref="EspTrackingInfoProbe"/>: any failure is Debug-logged and yields
|
||||
/// <see cref="EspPolicyProviderSnapshot.Empty"/>; a missing root key (early enrollment,
|
||||
/// non-Autopilot device) is not an error. Values are parsed defensively — a missing or
|
||||
/// unparseable value counts as incomplete (fail-toward-observation).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class EspPolicyProviderProbe
|
||||
{
|
||||
internal const string TrackingRootKeyPath =
|
||||
@"SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking";
|
||||
|
||||
/// <summary>Provider kind: <c>Setup\Apps\PolicyProviders</c> (TrackingPoliciesCreated wait, no timeout).</summary>
|
||||
internal const string KindSetupApps = "setupApps";
|
||||
|
||||
/// <summary>Provider kind: <c>DevicePreparation\PolicyProviders</c> (InstallationState wait, 15-min OS timeout).</summary>
|
||||
internal const string KindDevicePreparation = "devicePreparation";
|
||||
|
||||
/// <summary>The IME's provider name — its absence while other providers are registered is the field-case signature.</summary>
|
||||
internal const string SidecarProviderName = "Sidecar";
|
||||
|
||||
internal const string DeviceScope = "device";
|
||||
|
||||
private const string TrackingPoliciesCreatedValueName = "TrackingPoliciesCreated";
|
||||
private const string InstallationStateValueName = "InstallationState";
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — when non-null, <see cref="Read"/> delegates to this func instead of
|
||||
/// touching the live registry. Use <see cref="ScopedOverride"/> to guarantee cleanup.
|
||||
/// </summary>
|
||||
internal static Func<AgentLogger, EspPolicyProviderSnapshot> TestOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Reads all registered ESP policy providers. <see cref="EspPolicyProviderSnapshot.HasData"/>
|
||||
/// is <c>true</c> when the EnrollmentStatusTracking root key existed and was readable
|
||||
/// (even with no providers registered); <see cref="EspPolicyProviderSnapshot.Empty"/> otherwise.
|
||||
/// </summary>
|
||||
/// <param name="logger">Optional logger — Debug-level trace only; no warn/error.</param>
|
||||
public static EspPolicyProviderSnapshot Read(AgentLogger logger = null)
|
||||
{
|
||||
var probe = TestOverride;
|
||||
if (probe != null) return probe(logger);
|
||||
|
||||
try
|
||||
{
|
||||
using (var rootKey = Registry.LocalMachine.OpenSubKey(TrackingRootKeyPath))
|
||||
{
|
||||
if (rootKey == null)
|
||||
{
|
||||
logger?.Debug("EspPolicyProviderProbe: EnrollmentStatusTracking registry key not found");
|
||||
return EspPolicyProviderSnapshot.Empty;
|
||||
}
|
||||
|
||||
var providers = new List<PolicyProviderState>();
|
||||
|
||||
foreach (var scopeName in rootKey.GetSubKeyNames())
|
||||
{
|
||||
var isDevice = string.Equals(scopeName, "Device", StringComparison.OrdinalIgnoreCase);
|
||||
// User scope repeats the Setup structure under the signed-in user's SID.
|
||||
var isUserSid = scopeName.StartsWith("S-", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isDevice && !isUserSid) continue;
|
||||
|
||||
var scopeLabel = isDevice ? DeviceScope : "user:" + scopeName;
|
||||
using (var scopeKey = rootKey.OpenSubKey(scopeName))
|
||||
{
|
||||
if (scopeKey == null) continue;
|
||||
|
||||
CollectProviders(
|
||||
scopeKey, @"Setup\Apps\PolicyProviders", scopeLabel, KindSetupApps,
|
||||
TrackingPoliciesCreatedValueName, providers, logger);
|
||||
|
||||
if (isDevice)
|
||||
{
|
||||
CollectProviders(
|
||||
scopeKey, @"DevicePreparation\PolicyProviders", scopeLabel, KindDevicePreparation,
|
||||
InstallationStateValueName, providers, logger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new EspPolicyProviderSnapshot(providers);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"EspPolicyProviderProbe: read threw: {ex.Message}");
|
||||
return EspPolicyProviderSnapshot.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectProviders(
|
||||
RegistryKey scopeKey,
|
||||
string providersSubPath,
|
||||
string scopeLabel,
|
||||
string kind,
|
||||
string stateValueName,
|
||||
List<PolicyProviderState> providers,
|
||||
AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var providersKey = scopeKey.OpenSubKey(providersSubPath))
|
||||
{
|
||||
if (providersKey == null) return;
|
||||
|
||||
foreach (var providerName in providersKey.GetSubKeyNames())
|
||||
{
|
||||
using (var providerKey = providersKey.OpenSubKey(providerName))
|
||||
{
|
||||
if (providerKey == null) continue;
|
||||
var value = TryReadInt(providerKey.GetValue(stateValueName));
|
||||
providers.Add(new PolicyProviderState(
|
||||
name: providerName,
|
||||
scope: scopeLabel,
|
||||
kind: kind,
|
||||
trackingPoliciesCreated: kind == KindSetupApps ? value : null,
|
||||
installationState: kind == KindDevicePreparation ? value : null));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"EspPolicyProviderProbe: '{providersSubPath}' ({scopeLabel}) read threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defensive value parse: REG_DWORD expected per CSP (booleans stored as 0/1), but string
|
||||
/// forms are tolerated. Anything missing or unparseable is <c>null</c> — which the
|
||||
/// completion rule treats as incomplete.
|
||||
/// </summary>
|
||||
internal static int? TryReadInt(object rawValue)
|
||||
{
|
||||
switch (rawValue)
|
||||
{
|
||||
case null: return null;
|
||||
case int i: return i;
|
||||
case long l when l >= int.MinValue && l <= int.MaxValue: return (int)l;
|
||||
case string s when int.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed):
|
||||
return parsed;
|
||||
case bool b: return b ? 1 : 0;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure completion rule per the CSP contract: <c>setupApps</c> providers are complete when
|
||||
/// <c>TrackingPoliciesCreated == 1</c>; <c>devicePreparation</c> providers when
|
||||
/// <c>InstallationState</c> is 2 (NotRequired) or 3 (Completed).
|
||||
/// </summary>
|
||||
internal static bool IsProviderComplete(string kind, int? trackingPoliciesCreated, int? installationState)
|
||||
=> kind == KindSetupApps
|
||||
? trackingPoliciesCreated == 1
|
||||
: installationState == 2 || installationState == 3;
|
||||
|
||||
/// <summary>
|
||||
/// Disposable scope that sets <see cref="TestOverride"/> for the lifetime of the scope
|
||||
/// and restores the previous value on Dispose. Nestable. Internal test-only helper.
|
||||
/// </summary>
|
||||
internal sealed class ScopedOverride : IDisposable
|
||||
{
|
||||
private readonly Func<AgentLogger, EspPolicyProviderSnapshot> _previous;
|
||||
private int _disposed;
|
||||
|
||||
public ScopedOverride(Func<AgentLogger, EspPolicyProviderSnapshot> probe)
|
||||
{
|
||||
_previous = TestOverride;
|
||||
TestOverride = probe;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
TestOverride = _previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One registered ESP policy provider with its completion-relevant state.
|
||||
/// <see cref="Key"/> is the stable identity used for dwell tracking and one-shot latching.
|
||||
/// </summary>
|
||||
internal readonly struct PolicyProviderState
|
||||
{
|
||||
public PolicyProviderState(string name, string scope, string kind, int? trackingPoliciesCreated, int? installationState)
|
||||
{
|
||||
Name = name ?? string.Empty;
|
||||
Scope = scope ?? string.Empty;
|
||||
Kind = kind ?? string.Empty;
|
||||
TrackingPoliciesCreated = trackingPoliciesCreated;
|
||||
InstallationState = installationState;
|
||||
IsComplete = EspPolicyProviderProbe.IsProviderComplete(kind, trackingPoliciesCreated, installationState);
|
||||
}
|
||||
|
||||
/// <summary>Provider key name, e.g. "Sidecar" or "ConfigMgr".</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary><c>"device"</c> or <c>"user:S-1-5-21-…"</c>.</summary>
|
||||
public string Scope { get; }
|
||||
|
||||
/// <summary><see cref="EspPolicyProviderProbe.KindSetupApps"/> or <see cref="EspPolicyProviderProbe.KindDevicePreparation"/>.</summary>
|
||||
public string Kind { get; }
|
||||
|
||||
/// <summary>Raw <c>TrackingPoliciesCreated</c> value (setupApps only); <c>null</c> when missing/unparseable.</summary>
|
||||
public int? TrackingPoliciesCreated { get; }
|
||||
|
||||
/// <summary>Raw <c>InstallationState</c> value (devicePreparation only); <c>null</c> when missing/unparseable.</summary>
|
||||
public int? InstallationState { get; }
|
||||
|
||||
/// <summary>Completion per <see cref="EspPolicyProviderProbe.IsProviderComplete"/>.</summary>
|
||||
public bool IsComplete { get; }
|
||||
|
||||
/// <summary>Stable dwell/latch identity: <c>kind|scope|name</c>.</summary>
|
||||
public string Key => Kind + "|" + Scope + "|" + Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of all registered ESP policy providers. <see cref="HasData"/> is <c>false</c> only
|
||||
/// when the EnrollmentStatusTracking root key was missing or unreadable — an empty provider
|
||||
/// list with <see cref="HasData"/> = <c>true</c> is valid data (nothing registered yet).
|
||||
/// </summary>
|
||||
internal readonly struct EspPolicyProviderSnapshot
|
||||
{
|
||||
public static readonly EspPolicyProviderSnapshot Empty = default;
|
||||
|
||||
public EspPolicyProviderSnapshot(IReadOnlyList<PolicyProviderState> providers)
|
||||
{
|
||||
HasData = true;
|
||||
Providers = providers ?? Array.Empty<PolicyProviderState>();
|
||||
}
|
||||
|
||||
/// <summary><c>true</c> when the EnrollmentStatusTracking root key existed and was readable.</summary>
|
||||
public bool HasData { get; }
|
||||
|
||||
/// <summary>All registered providers across scopes and kinds; empty when none registered.</summary>
|
||||
public IReadOnlyList<PolicyProviderState> Providers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when a <c>Setup\Apps</c> provider named "Sidecar" (the IME) is registered in
|
||||
/// any scope — its absence while other providers are registered is the co-management
|
||||
/// field-case signature.
|
||||
/// </summary>
|
||||
public bool SidecarRegistered
|
||||
{
|
||||
get
|
||||
{
|
||||
var providers = Providers;
|
||||
if (providers == null) return false;
|
||||
return providers.Any(p =>
|
||||
p.Kind == EspPolicyProviderProbe.KindSetupApps &&
|
||||
string.Equals(p.Name, EspPolicyProviderProbe.SidecarProviderName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Turns the ESP policy-provider contract read by <see cref="EspPolicyProviderProbe"/> into
|
||||
/// the one-shot <c>esp_policy_provider_stalled</c> Warning via two independent arms, each
|
||||
/// with the same <see cref="DwellMinutes"/>-minute continuous-dwell rule and the full
|
||||
/// provider table in the payload:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Arm 1 — <c>reason=provider_incomplete</c></b>: a registered provider stays
|
||||
/// continuously incomplete (e.g. Sidecar itself never sets TrackingPoliciesCreated).</item>
|
||||
/// <item><b>Arm 2 — <c>reason=sidecar_provider_missing</c></b>: at least one
|
||||
/// <c>Setup\Apps</c> provider is registered but none of them is <c>Sidecar</c> (the IME).
|
||||
/// This is the actual co-management field-case signature (issue #106): the foreign
|
||||
/// <c>ConfigMgr</c> provider had <c>TrackingPoliciesCreated=1</c> — i.e. was "complete"
|
||||
/// by the CSP value contract — yet the user ESP stayed parked at "Apps (Identifying)"
|
||||
/// until the key was renamed to <c>Sidecar</c>. The ESP's Apps wait is keyed to the
|
||||
/// Intune-registered provider by NAME, so provider completeness alone must not gate this
|
||||
/// detection. See the probe's header for sources.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Dwell semantics: arm 1's clock per provider starts when it is first observed incomplete
|
||||
/// and RESETS when the provider completes or disappears; arm 2's clock starts when the
|
||||
/// sidecar-missing condition is first observed and RESETS when Sidecar registers (covers the
|
||||
/// legitimate "ConfigMgr registered before Sidecar" startup ordering) — only
|
||||
/// <i>continuously</i> bad states fire. A missing root key / empty provider list is normal
|
||||
/// early enrollment and clears all dwell state (probe D4). Purely observational: emission
|
||||
/// goes through <see cref="InformationalEventPost"/> (InformationalEvent pass-through,
|
||||
/// dispatch-guard exempt), never into decision state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Duplicate suppression: in-process one-shot per provider key, plus a
|
||||
/// <see cref="StartupEventGate"/> fingerprint over the sorted set of currently-stalled keys so
|
||||
/// an agent restart into the same stall does not re-report it — while a NEW provider joining
|
||||
/// the stall changes the fingerprint and is reported (the event carries the full list).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class EspPolicyProviderStallDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Wall-clock minutes a provider must stay continuously incomplete before it is reported.
|
||||
/// Mirrors the CSP's own DevicePreparation default timeout; the Setup/Apps wait this
|
||||
/// tripwire mainly targets has no OS timeout at all.
|
||||
/// </summary>
|
||||
internal const int DwellMinutes = 15;
|
||||
|
||||
internal const string SourceLabel = "EspPolicyProviderStallDetector";
|
||||
|
||||
/// <summary>Payload <c>reason</c>: a registered provider stayed continuously incomplete (arm 1).</summary>
|
||||
internal const string ReasonProviderIncomplete = "provider_incomplete";
|
||||
|
||||
/// <summary>Payload <c>reason</c>: Setup\Apps providers registered but Sidecar absent (arm 2 — dominant when both arms fire in one round).</summary>
|
||||
internal const string ReasonSidecarMissing = "sidecar_provider_missing";
|
||||
|
||||
/// <summary>
|
||||
/// Sentinel token representing the arm-2 condition in the cross-restart fingerprint.
|
||||
/// Cannot collide with provider keys — those always contain <c>|</c> separators.
|
||||
/// </summary>
|
||||
private const string SidecarMissingFingerprintToken = "sidecarMissing";
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly IClock _clock;
|
||||
private readonly StartupEventGate? _startupGate;
|
||||
private readonly TimeSpan _dwell;
|
||||
|
||||
private readonly Dictionary<string, DateTime> _firstSeenIncompleteUtc =
|
||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _firedKeys =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Arm-2 dwell state: first observation of "setupApps providers without Sidecar"; null = condition not active.</summary>
|
||||
private DateTime? _sidecarMissingSinceUtc;
|
||||
|
||||
/// <summary>Arm-2 in-process one-shot latch (mirror of <see cref="_firedKeys"/>).</summary>
|
||||
private bool _sidecarMissingFired;
|
||||
|
||||
public EspPolicyProviderStallDetector(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
IClock clock,
|
||||
StartupEventGate? startupGate = null,
|
||||
TimeSpan? dwell = null)
|
||||
{
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger;
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_startupGate = startupGate;
|
||||
_dwell = dwell ?? TimeSpan.FromMinutes(DwellMinutes);
|
||||
}
|
||||
|
||||
/// <summary>Host timer callback and test seam — one probe + evaluation round.</summary>
|
||||
internal void Tick()
|
||||
{
|
||||
var snapshot = EspPolicyProviderProbe.Read(_logger);
|
||||
var eval = EvaluateStallTransition(
|
||||
snapshot, _firstSeenIncompleteUtc, _firedKeys,
|
||||
_sidecarMissingSinceUtc, _sidecarMissingFired, _clock.UtcNow, _dwell);
|
||||
|
||||
_firstSeenIncompleteUtc.Clear();
|
||||
foreach (var kv in eval.UpdatedFirstSeenUtc)
|
||||
_firstSeenIncompleteUtc[kv.Key] = kv.Value;
|
||||
_sidecarMissingSinceUtc = eval.UpdatedSidecarMissingSinceUtc;
|
||||
|
||||
if (eval.NewlyStalled.Count > 0 || eval.SidecarMissingStalledForMinutes != null)
|
||||
EmitStalled(snapshot, eval);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure evaluation of one probe round — the entire detection decision, testable without
|
||||
/// registry, timer, or ingress (pattern: <c>PerformanceCollector.EvaluateDiskLowTransition</c>).
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Current provider snapshot.</param>
|
||||
/// <param name="firstSeenIncompleteUtc">Arm-1 dwell state from the previous round (provider key → first-seen-incomplete).</param>
|
||||
/// <param name="alreadyFired">Provider keys already reported this run (never re-report).</param>
|
||||
/// <param name="sidecarMissingSinceUtc">Arm-2 dwell state from the previous round; null = condition was not active.</param>
|
||||
/// <param name="sidecarMissingAlreadyFired">Arm-2 one-shot latch (never re-report).</param>
|
||||
/// <param name="nowUtc">Evaluation timestamp.</param>
|
||||
/// <param name="dwell">Continuous-condition threshold (shared by both arms).</param>
|
||||
internal static StallEvaluation EvaluateStallTransition(
|
||||
EspPolicyProviderSnapshot snapshot,
|
||||
IReadOnlyDictionary<string, DateTime> firstSeenIncompleteUtc,
|
||||
ISet<string> alreadyFired,
|
||||
DateTime? sidecarMissingSinceUtc,
|
||||
bool sidecarMissingAlreadyFired,
|
||||
DateTime nowUtc,
|
||||
TimeSpan dwell)
|
||||
{
|
||||
var result = new StallEvaluation();
|
||||
|
||||
// Missing root key or no registered providers = normal early enrollment: clear all
|
||||
// dwell state (a condition must be continuously OBSERVED to accrue dwell).
|
||||
if (!snapshot.HasData || snapshot.Providers.Count == 0)
|
||||
return result;
|
||||
|
||||
// Arm 1 — a registered provider continuously incomplete.
|
||||
foreach (var provider in snapshot.Providers)
|
||||
{
|
||||
if (provider.IsComplete)
|
||||
continue; // completed → falls out of the dwell map (reset)
|
||||
|
||||
var firstSeen = firstSeenIncompleteUtc.TryGetValue(provider.Key, out var seen)
|
||||
? seen
|
||||
: nowUtc;
|
||||
result.UpdatedFirstSeenUtc[provider.Key] = firstSeen;
|
||||
|
||||
var stalledFor = nowUtc - firstSeen;
|
||||
if (stalledFor >= dwell && !alreadyFired.Contains(provider.Key))
|
||||
result.NewlyStalled.Add(new StalledProvider(provider, stalledFor.TotalMinutes));
|
||||
}
|
||||
|
||||
// Arm 2 — Setup\Apps providers registered, but none of them is Sidecar. Deliberately
|
||||
// ignores provider completeness: the field case (issue #106) had ConfigMgr with
|
||||
// TrackingPoliciesCreated=1 and the ESP still waited. Sidecar registering later
|
||||
// (legitimate startup ordering) clears the condition and resets the clock.
|
||||
if (HasSetupAppsProvider(snapshot) && !snapshot.SidecarRegistered)
|
||||
{
|
||||
var since = sidecarMissingSinceUtc ?? nowUtc;
|
||||
result.UpdatedSidecarMissingSinceUtc = since;
|
||||
|
||||
var missingFor = nowUtc - since;
|
||||
if (missingFor >= dwell && !sidecarMissingAlreadyFired)
|
||||
result.SidecarMissingStalledForMinutes = missingFor.TotalMinutes;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool HasSetupAppsProvider(EspPolicyProviderSnapshot snapshot)
|
||||
=> snapshot.Providers.Any(p => p.Kind == EspPolicyProviderProbe.KindSetupApps);
|
||||
|
||||
private void EmitStalled(EspPolicyProviderSnapshot snapshot, StallEvaluation eval)
|
||||
{
|
||||
foreach (var stalled in eval.NewlyStalled)
|
||||
_firedKeys.Add(stalled.Provider.Key);
|
||||
if (eval.SidecarMissingStalledForMinutes != null)
|
||||
_sidecarMissingFired = true;
|
||||
|
||||
// Cross-restart dedup: fingerprint = sorted set of all fired conditions that are
|
||||
// still active (incomplete providers + the sidecar-missing sentinel). A restart into
|
||||
// the same stall re-accrues dwell and lands on the same fingerprint → suppressed; a
|
||||
// new condition joining the stall changes it → re-emitted.
|
||||
var currentlyStalledKeys = snapshot.Providers
|
||||
.Where(p => !p.IsComplete && _firedKeys.Contains(p.Key))
|
||||
.Select(p => p.Key)
|
||||
.ToList();
|
||||
if (_sidecarMissingFired && HasSetupAppsProvider(snapshot) && !snapshot.SidecarRegistered)
|
||||
currentlyStalledKeys.Add(SidecarMissingFingerprintToken);
|
||||
currentlyStalledKeys.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
var fingerprint = StartupEventGate.HashString(string.Join(";", currentlyStalledKeys));
|
||||
if (_startupGate != null &&
|
||||
!_startupGate.ShouldEmit(Constants.EventTypes.EspPolicyProviderStalled, fingerprint))
|
||||
{
|
||||
_logger?.Debug(
|
||||
"EspPolicyProviderStallDetector: stalled provider set already reported by a " +
|
||||
"previous agent run (fingerprint match) — suppressed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sidecar-missing is the dominant reason when both arms fire in one round: it
|
||||
// explains the ESP hang regardless of the foreign providers' completeness.
|
||||
var reason = eval.SidecarMissingStalledForMinutes != null
|
||||
? ReasonSidecarMissing
|
||||
: ReasonProviderIncomplete;
|
||||
|
||||
var messageParts = new List<string>();
|
||||
if (eval.SidecarMissingStalledForMinutes != null)
|
||||
{
|
||||
var registeredNames = string.Join(", ", snapshot.Providers
|
||||
.Where(p => p.Kind == EspPolicyProviderProbe.KindSetupApps)
|
||||
.Select(p => $"{p.Name} ({p.Scope})"));
|
||||
messageParts.Add(
|
||||
$"expected IME provider '{EspPolicyProviderProbe.SidecarProviderName}' not registered for " +
|
||||
$">= {DwellMinutes} min while Setup/Apps providers exist: {registeredNames}");
|
||||
}
|
||||
var stalledNames = string.Join(", ",
|
||||
eval.NewlyStalled.Select(s => $"{s.Provider.Name} ({s.Provider.Scope}/{s.Provider.Kind})"));
|
||||
if (eval.NewlyStalled.Count > 0)
|
||||
messageParts.Add($"provider(s) incomplete for >= {DwellMinutes} min: {stalledNames}");
|
||||
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["reason"] = reason,
|
||||
["dwellMinutes"] = DwellMinutes,
|
||||
["sidecarRegistered"] = snapshot.SidecarRegistered,
|
||||
["providers"] = snapshot.Providers.Select(p => new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["name"] = p.Name,
|
||||
["scope"] = p.Scope,
|
||||
["kind"] = p.Kind,
|
||||
["trackingPoliciesCreated"] = p.TrackingPoliciesCreated,
|
||||
["installationState"] = p.InstallationState,
|
||||
["complete"] = p.IsComplete,
|
||||
}).ToList(),
|
||||
["stalledProviders"] = eval.NewlyStalled.Select(s => new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["name"] = s.Provider.Name,
|
||||
["scope"] = s.Provider.Scope,
|
||||
["kind"] = s.Provider.Kind,
|
||||
["stalledForMinutes"] = Math.Round(s.StalledForMinutes, 1),
|
||||
}).ToList(),
|
||||
};
|
||||
if (eval.SidecarMissingStalledForMinutes != null)
|
||||
data["sidecarMissingForMinutes"] = Math.Round(eval.SidecarMissingStalledForMinutes.Value, 1);
|
||||
|
||||
var message =
|
||||
"ESP Setup/Apps tracking stalled: " + string.Join("; ", messageParts) +
|
||||
" — the ESP waits on its app-tracking providers without any timeout";
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = _clock.UtcNow,
|
||||
EventType = Constants.EventTypes.EspPolicyProviderStalled,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = SourceLabel,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
ImmediateUpload = true,
|
||||
Message = message,
|
||||
Data = data
|
||||
});
|
||||
_startupGate?.MarkEmitted(Constants.EventTypes.EspPolicyProviderStalled);
|
||||
|
||||
_logger?.Warning(
|
||||
$"EspPolicyProviderStallDetector: {string.Join("; ", messageParts)} " +
|
||||
$"(reason={reason}, sidecarRegistered={snapshot.SidecarRegistered}).");
|
||||
}
|
||||
|
||||
/// <summary>Result of one <see cref="EvaluateStallTransition"/> round.</summary>
|
||||
internal sealed class StallEvaluation
|
||||
{
|
||||
/// <summary>Next round's arm-1 dwell state — only currently-incomplete providers survive.</summary>
|
||||
public Dictionary<string, DateTime> UpdatedFirstSeenUtc { get; } =
|
||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Providers that crossed the dwell threshold this round and were not yet reported (arm 1).</summary>
|
||||
public List<StalledProvider> NewlyStalled { get; } = new List<StalledProvider>();
|
||||
|
||||
/// <summary>Next round's arm-2 dwell state; null = sidecar-missing condition not active (reset).</summary>
|
||||
public DateTime? UpdatedSidecarMissingSinceUtc { get; set; }
|
||||
|
||||
/// <summary>Non-null when the sidecar-missing condition crossed the dwell threshold this round and was not yet reported (arm 2).</summary>
|
||||
public double? SidecarMissingStalledForMinutes { get; set; }
|
||||
}
|
||||
|
||||
internal readonly struct StalledProvider
|
||||
{
|
||||
public StalledProvider(PolicyProviderState provider, double stalledForMinutes)
|
||||
{
|
||||
Provider = provider;
|
||||
StalledForMinutes = stalledForMinutes;
|
||||
}
|
||||
|
||||
public PolicyProviderState Provider { get; }
|
||||
public double StalledForMinutes { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
-22
@@ -631,10 +631,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// the regular <see cref="ProcessRecord"/> pipeline with isBackfill=true, so they arrive
|
||||
/// as modern_deployment_error events carrying backfilled=true plus the original
|
||||
/// timeCreated; the event's own timestamp is emission time, so session StartedAt is not
|
||||
/// dragged backwards. Newest-first, capped, watermark-deduped across restarts.
|
||||
/// dragged backwards. The scan reads newest-first (the cap keeps the newest records),
|
||||
/// but replay is buffered so an Info summary event announces the block BEFORE the errors
|
||||
/// hit the timeline (customer feedback 2026-07-28: an unannounced wall of pre-agent
|
||||
/// Error events reads like a live failure) and the records emit oldest-first, giving the
|
||||
/// timeline sequence the original event-log chronology. Watermark-deduped across restarts.
|
||||
/// </summary>
|
||||
private void BackfillAutopilotErrors()
|
||||
{
|
||||
var buffered = new List<EventRecord>();
|
||||
try
|
||||
{
|
||||
long watermark;
|
||||
@@ -649,7 +654,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
ReverseDirection = true
|
||||
};
|
||||
|
||||
int processed = 0, skippedWatermark = 0, droppedOverCap = 0;
|
||||
int skippedWatermark = 0, droppedOverCap = 0;
|
||||
long maxSeen = watermark;
|
||||
|
||||
using (var reader = new EventLogReader(query))
|
||||
@@ -657,37 +662,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
EventRecord record;
|
||||
while ((record = reader.ReadEvent()) != null)
|
||||
{
|
||||
using (record)
|
||||
var recordId = record.RecordId ?? 0;
|
||||
if (recordId > maxSeen) maxSeen = recordId;
|
||||
|
||||
if (recordId != 0 && recordId <= watermark)
|
||||
{
|
||||
var recordId = record.RecordId ?? 0;
|
||||
if (recordId > maxSeen) maxSeen = recordId;
|
||||
|
||||
if (recordId != 0 && recordId <= watermark)
|
||||
{
|
||||
skippedWatermark++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (processed >= ErrorBackfillMaxRecords)
|
||||
{
|
||||
droppedOverCap++;
|
||||
continue;
|
||||
}
|
||||
|
||||
processed++;
|
||||
ProcessRecord(record, "Autopilot", AutopilotChannel, isBackfill: true);
|
||||
skippedWatermark++;
|
||||
record.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (buffered.Count >= ErrorBackfillMaxRecords)
|
||||
{
|
||||
droppedOverCap++;
|
||||
record.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
buffered.Add(record);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxSeen > watermark)
|
||||
AdvanceAutopilotErrorWatermark(maxSeen);
|
||||
|
||||
if (processed == 0 && droppedOverCap == 0)
|
||||
if (buffered.Count == 0 && droppedOverCap == 0)
|
||||
{
|
||||
_logger.Debug($"ModernDeployment error backfill: nothing new (skippedByWatermark={skippedWatermark})");
|
||||
}
|
||||
else
|
||||
_logger.Info($"ModernDeployment error backfill: forwarded {processed} record(s) " +
|
||||
{
|
||||
EmitErrorBackfillSummary(buffered.Count, droppedOverCap);
|
||||
|
||||
// Scan buffered newest-first — replay in reverse so the emitted sequence
|
||||
// follows the original event-log chronology.
|
||||
for (var i = buffered.Count - 1; i >= 0; i--)
|
||||
{
|
||||
ProcessRecord(buffered[i], "Autopilot", AutopilotChannel, isBackfill: true);
|
||||
}
|
||||
|
||||
_logger.Info($"ModernDeployment error backfill: forwarded {buffered.Count} record(s) " +
|
||||
$"(skippedByWatermark={skippedWatermark}, droppedOverCap={droppedOverCap})");
|
||||
}
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
@@ -701,6 +717,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
_logger.Warning($"ModernDeployment error backfill failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var record in buffered)
|
||||
{
|
||||
try { record.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Announces the error-backfill block on the session timeline BEFORE the replayed
|
||||
/// errors, so pre-agent Error events are readable as history rather than a live
|
||||
/// failure. The web timeline additionally badges each replayed event via its
|
||||
/// backfilled=true payload marker; this summary carries backfillSummary=true instead —
|
||||
/// it is a live emission, not a replayed record.
|
||||
/// </summary>
|
||||
internal void EmitErrorBackfillSummary(int replayedCount, int droppedOverCap)
|
||||
{
|
||||
var message = $"[Autopilot] Replaying {replayedCount} error event(s) recorded in the Autopilot event log " +
|
||||
$"before the agent started (lookback {ErrorBackfillLookbackMinutes / 60}h). " +
|
||||
"These predate monitoring — if enrollment progressed afterwards, they were already resolved by a retry.";
|
||||
if (droppedOverCap > 0)
|
||||
{
|
||||
message += $" {droppedOverCap} older record(s) beyond the {ErrorBackfillMaxRecords}-record cap were not replayed.";
|
||||
}
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.ModernDeploymentLog,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ModernDeploymentWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "backfillSummary", true },
|
||||
{ "replayedCount", replayedCount },
|
||||
{ "droppedOverCap", droppedOverCap },
|
||||
{ "lookbackMinutes", ErrorBackfillLookbackMinutes }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+189
-1
@@ -116,6 +116,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// failure details that fire after the settle window expires.
|
||||
private Dictionary<string, Timer> _provisioningFailureSettleTimers;
|
||||
private Dictionary<string, EspFailureDetectedEventArgs> _provisioningFailureSettleArgs;
|
||||
// Session 4910a5a5 — post-terminal recovery story. Populated when the settle window
|
||||
// expires and EspFailureDetected actually fires (terminal path); consumed by the
|
||||
// esp_failure_retry_detected / esp_failure_recovered emitters so a late "Try again"
|
||||
// recovery (23 min after the failure in the field session) is visible on the timeline
|
||||
// instead of hiding inside a generic subcategory_state_change. Keyed by category-name.
|
||||
private Dictionary<string, EspFailureDetectedEventArgs> _provisioningFailureTerminalArgs;
|
||||
private Dictionary<string, DateTime> _provisioningFailureTerminalUtc;
|
||||
private HashSet<string> _postFailureRetryEmitted;
|
||||
private HashSet<string> _postFailureRecoveredEmitted;
|
||||
// Session 2bc884b6: AppX enrichment scan seams + fire-once guard. The scanner and the
|
||||
// dispatcher are injectable so tests never touch the real event log or the threadpool.
|
||||
private readonly IAppxDeploymentFailureScanner _appxScanner;
|
||||
@@ -285,6 +294,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_lastSubcategoryStates = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
|
||||
_provisioningFailureSettleTimers = new Dictionary<string, Timer>(StringComparer.OrdinalIgnoreCase);
|
||||
_provisioningFailureSettleArgs = new Dictionary<string, EspFailureDetectedEventArgs>(StringComparer.OrdinalIgnoreCase);
|
||||
_provisioningFailureTerminalArgs = new Dictionary<string, EspFailureDetectedEventArgs>(StringComparer.OrdinalIgnoreCase);
|
||||
_provisioningFailureTerminalUtc = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
_postFailureRetryEmitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
_postFailureRecoveredEmitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
_deviceSetupProvisioningCompleteFired = false;
|
||||
_deviceSetupFallbackTimer = null;
|
||||
_accountSetupProvisioningCompleteFired = false;
|
||||
@@ -665,6 +678,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// null-ing here is correctness-neutral on the start-condition side.
|
||||
ResetAccountSetupFallbackTimer();
|
||||
_accountSetupProvisioningCompleteFired = true;
|
||||
MaybeEmitPostFailureRecovered("AccountSetupCategory.Status", "AccountSetup");
|
||||
try { AccountSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Error("AccountSetupProvisioningComplete handler failed (fallback path)", ex); }
|
||||
}
|
||||
@@ -833,6 +847,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// null-ing here is correctness-neutral on the start-condition side.
|
||||
ResetDeviceSetupFallbackTimer();
|
||||
_deviceSetupProvisioningCompleteFired = true;
|
||||
MaybeEmitPostFailureRecovered("DeviceSetupCategory.Status", "DeviceSetup");
|
||||
try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Error("DeviceSetupProvisioningComplete handler failed (fallback path)", ex); }
|
||||
}
|
||||
@@ -946,12 +961,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var severity = categorySucceeded == false ? EventSeverity.Warning : EventSeverity.Info;
|
||||
EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, severity);
|
||||
EmitRawRegistryDump(categoryLabel, jsonValue, $"category_resolved_{(categorySucceeded == true ? "success" : "failed")}");
|
||||
|
||||
// Session 4910a5a5: category resolving to success after a terminally
|
||||
// fired failure = the failure recovered (fallback-path successes call
|
||||
// this from their own timer handlers).
|
||||
if (categorySucceeded == true)
|
||||
{
|
||||
MaybeEmitPostFailureRecovered(categoryName, categoryLabel);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var transitions = DetectSubcategoryTransitions(categoryName, subcategories);
|
||||
StoreSubcategoryStates(categoryName, subcategories);
|
||||
|
||||
// Session 4910a5a5: after a terminally fired failure, the failed
|
||||
// subcategory leaving "failed" is the ESP re-running the step —
|
||||
// consistent with the user pressing "Try again" on the failure page.
|
||||
MaybeEmitPostFailureRetry(categoryName, categoryLabel, transitions);
|
||||
|
||||
var failureTransitions = transitions.Where(t => t.IsFailure).ToList();
|
||||
if (transitions.Count > 0)
|
||||
{
|
||||
@@ -1025,6 +1053,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
$"escalating as {failureTypeName} (errorCode={errorCode ?? "n/a"})");
|
||||
_logger.Warning($"Provisioning failure detected: {failureTypeName}");
|
||||
|
||||
// Session 4910a5a5: name the likely culprit app(s) directly on the failure args so
|
||||
// the DecisionEngine's advisory/terminal events carry them — never-started apps
|
||||
// first (they starve ESP's app gate without ever producing an IME install event),
|
||||
// capped at 3 names.
|
||||
IReadOnlyList<string> likelyCulpritApps = null;
|
||||
if (string.Equals(failedSubcategoryName, "Apps", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
likelyCulpritApps = SnapshotTrackedAppsNotCompleted()
|
||||
.OrderBy(a => a["everStartedInstalling"] is bool started && started ? 1 : 0)
|
||||
.Select(a => a["appName"]?.ToString())
|
||||
.Where(n => !string.IsNullOrEmpty(n))
|
||||
.Take(3)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Caller already holds _stateLock.
|
||||
ArmFailureSettleTimer(
|
||||
categoryName: categoryName,
|
||||
@@ -1032,7 +1075,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
failureType: failureTypeName,
|
||||
errorCode: errorCode,
|
||||
failedSubcategory: failedSubcategoryName,
|
||||
category: categoryLabel));
|
||||
category: categoryLabel,
|
||||
likelyCulpritApps: likelyCulpritApps));
|
||||
|
||||
// Settle-window owns the actual EspFailureDetected fire. The legacy "fire-after-emit"
|
||||
// path in CheckProvisioningStatus is now a no-op for this code-path; it stays in place
|
||||
@@ -1292,6 +1336,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_provisioningCategoriesResolved.Remove(categoryName);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Session 4910a5a5: remember the terminally fired failure so a LATE
|
||||
// recovery (outside the settle window — e.g. user pressing "Try again"
|
||||
// minutes later) can be told as esp_failure_retry_detected /
|
||||
// esp_failure_recovered instead of hiding in a generic state-change.
|
||||
_provisioningFailureTerminalArgs[categoryName] = args;
|
||||
_provisioningFailureTerminalUtc[categoryName] = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
if (recovered)
|
||||
@@ -1382,6 +1435,133 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session 4910a5a5: after a terminally fired failure (settle window expired,
|
||||
/// EspFailureDetected raised), the failed subcategory leaving the <c>failed</c> state
|
||||
/// means the ESP re-ran the step — consistent with the user pressing "Try again" on
|
||||
/// the ESP failure page (the agent cannot observe the click itself). Emits
|
||||
/// <c>esp_failure_retry_detected</c> once per terminally fired category. Caller holds
|
||||
/// <see cref="_stateLock"/>.
|
||||
/// </summary>
|
||||
private void MaybeEmitPostFailureRetry(string categoryName, string categoryLabel, List<SubcategoryTransition> transitions)
|
||||
{
|
||||
if (transitions == null || transitions.Count == 0) return;
|
||||
if (_postFailureRetryEmitted.Contains(categoryName)) return;
|
||||
if (_provisioningFailureTerminalArgs == null
|
||||
|| !_provisioningFailureTerminalArgs.TryGetValue(categoryName, out var terminalArgs))
|
||||
{
|
||||
return; // No terminal fire on record — in-window recovery is settle_recovered's job.
|
||||
}
|
||||
|
||||
SubcategoryTransition? retryTransition = null;
|
||||
foreach (var t in transitions)
|
||||
{
|
||||
if (string.Equals(t.OldState, "failed", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(t.NewState, "failed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
retryTransition = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (retryTransition == null) return;
|
||||
var retry = retryTransition.Value;
|
||||
|
||||
_postFailureRetryEmitted.Add(categoryName);
|
||||
|
||||
var minutesSinceFailure = MinutesSinceTerminalFailure(categoryName);
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "category", categoryLabel },
|
||||
{ "subcategory", retry.SubcategoryName },
|
||||
{ "previousState", retry.OldState },
|
||||
{ "newState", retry.NewState },
|
||||
{ "failureType", terminalArgs.FailureType },
|
||||
{ "minutesSinceFailure", minutesSinceFailure },
|
||||
};
|
||||
if (!string.IsNullOrEmpty(terminalArgs.ErrorCode))
|
||||
data["errorCode"] = terminalArgs.ErrorCode;
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.EspFailureRetryDetected,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"ESP retry detected — {categoryLabel}/{retry.SubcategoryName} left the failed state " +
|
||||
$"({retry.OldState} → {retry.NewState}) {minutesSinceFailure} min after the failure — " +
|
||||
"consistent with the user pressing 'Try again' on the ESP failure page.",
|
||||
Data = data,
|
||||
ImmediateUpload = true
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session 4910a5a5: the category behind a terminally fired failure reached success
|
||||
/// (categorySucceeded=true or the all-subcategories-succeeded fallback) — the failure
|
||||
/// demonstrably recovered. Emits <c>esp_failure_recovered</c> once per terminally
|
||||
/// fired category so the timeline tells "failed → retried → recovered" instead of
|
||||
/// leaving the stale failure as the last word. Caller holds <see cref="_stateLock"/>.
|
||||
/// </summary>
|
||||
private void MaybeEmitPostFailureRecovered(string categoryName, string categoryLabel)
|
||||
{
|
||||
if (_postFailureRecoveredEmitted == null || _postFailureRecoveredEmitted.Contains(categoryName)) return;
|
||||
if (_provisioningFailureTerminalArgs == null
|
||||
|| !_provisioningFailureTerminalArgs.TryGetValue(categoryName, out var terminalArgs))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_postFailureRecoveredEmitted.Add(categoryName);
|
||||
|
||||
var minutesSinceFailure = MinutesSinceTerminalFailure(categoryName);
|
||||
string subcategoryStatus = null;
|
||||
if (terminalArgs.FailedSubcategory != null
|
||||
&& _lastSubcategoryStates != null
|
||||
&& _lastSubcategoryStates.TryGetValue(categoryName, out var subs)
|
||||
&& subs.TryGetValue(terminalArgs.FailedSubcategory, out var currentState))
|
||||
{
|
||||
subcategoryStatus = currentState;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "category", categoryLabel },
|
||||
{ "failedSubcategory", terminalArgs.FailedSubcategory ?? "category-level" },
|
||||
{ "failureType", terminalArgs.FailureType },
|
||||
{ "minutesSinceFailure", minutesSinceFailure },
|
||||
};
|
||||
if (!string.IsNullOrEmpty(terminalArgs.ErrorCode))
|
||||
data["errorCode"] = terminalArgs.ErrorCode;
|
||||
if (subcategoryStatus != null)
|
||||
data["subcategoryState"] = subcategoryStatus;
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.EspFailureRecovered,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"ESP failure recovered — {categoryLabel}/{terminalArgs.FailedSubcategory ?? "category-level"} " +
|
||||
$"completed successfully {minutesSinceFailure} min after the failure; the earlier ESP failure no longer applies.",
|
||||
Data = data,
|
||||
ImmediateUpload = true
|
||||
});
|
||||
}
|
||||
|
||||
private int MinutesSinceTerminalFailure(string categoryName)
|
||||
{
|
||||
if (_provisioningFailureTerminalUtc != null
|
||||
&& _provisioningFailureTerminalUtc.TryGetValue(categoryName, out var firedAt))
|
||||
{
|
||||
return Math.Max(0, (int)Math.Round((DateTime.UtcNow - firedAt).TotalMinutes));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void EmitFailureSettleStarted(EspFailureDetectedEventArgs args)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
@@ -1863,6 +2043,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_provisioningFailureSettleTimers = new Dictionary<string, Timer>(StringComparer.OrdinalIgnoreCase);
|
||||
if (_provisioningFailureSettleArgs == null)
|
||||
_provisioningFailureSettleArgs = new Dictionary<string, EspFailureDetectedEventArgs>(StringComparer.OrdinalIgnoreCase);
|
||||
if (_provisioningFailureTerminalArgs == null)
|
||||
_provisioningFailureTerminalArgs = new Dictionary<string, EspFailureDetectedEventArgs>(StringComparer.OrdinalIgnoreCase);
|
||||
if (_provisioningFailureTerminalUtc == null)
|
||||
_provisioningFailureTerminalUtc = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
if (_postFailureRetryEmitted == null)
|
||||
_postFailureRetryEmitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (_postFailureRecoveredEmitted == null)
|
||||
_postFailureRecoveredEmitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Fallback WiFi reader via <c>netsh wlan show interfaces</c> output parsing, used ONLY
|
||||
/// when <see cref="WifiInfoReader"/> (native WLAN API) yields nothing on a WiFi-connected
|
||||
/// device. Field evidence 2026-07-28: the WLAN API can return nothing in the OOBE/SYSTEM
|
||||
/// context on builds where inbox netsh still reports the connection — until that is fully
|
||||
/// root-caused (suspect: Wi-Fi location gating, 24H2+), netsh stays as the safety net.
|
||||
/// <para>
|
||||
/// netsh labels follow the OS UI language, so the parser matches the label variants of
|
||||
/// every language implied by the enrollment geography seen to date (see the comment on
|
||||
/// the key arrays for the country→language derivation). Unknown-language labels fall
|
||||
/// through harmlessly — the affected field is simply absent, same as before. "SSID"
|
||||
/// itself is untranslated in all known locales.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class NetshWifiFallback
|
||||
{
|
||||
private const int NetshTimeoutMs = 5000;
|
||||
|
||||
// Label variants per field, by OS UI language. Matched case-insensitively against the
|
||||
// full text left of the colon, so acronym-suffixed keys ("AP BSSID") never collide.
|
||||
// Language set derived from enrollment geography (get_geographic_metrics, 365d):
|
||||
// US/GB/AU/CA/IE/SG/IN/ZA/NG/PH en · DE/AT/CH de · BE/NL nl · BE/FR/CH fr · DK da ·
|
||||
// SE sv · PL pl · TR tr · CN zh-CN · HK zh-TW · ES/MX/PE/PY es · CZ cs · SK sk ·
|
||||
// IT/CH it · ID id · MY ms · FI fi · LT lt · LV lv · HU hu · BR/PT pt · JP ja ·
|
||||
// KR ko · TH th · VN vi · RO/MD ro · BG bg · BA/HR hr · AE/SA/EG/JO ar ·
|
||||
// MN/GE/AZ usually en/ru images → ru.
|
||||
private static readonly string[] SignalKeys =
|
||||
{
|
||||
"Signal", // en, de, fr, sv, da, nb, hr
|
||||
"Señal", // es
|
||||
"Sinal", // pt
|
||||
"Segnale", // it
|
||||
"Signaal", // nl
|
||||
"Sygnał", // pl
|
||||
"Signál", // cs, sk
|
||||
"Sinyal", // tr, id
|
||||
"Isyarat", // ms
|
||||
"Signaali", // fi
|
||||
"Signalas", // lt
|
||||
"Signāls", // lv
|
||||
"Jel", // hu
|
||||
"Semnal", // ro
|
||||
"Tín hiệu", // vi
|
||||
"สัญญาณ", // th
|
||||
"الإشارة", // ar
|
||||
"إشارة", // ar (variant without article)
|
||||
"Сигнал", // ru, bg
|
||||
"シグナル", // ja
|
||||
"信号", // zh-CN
|
||||
"訊號", // zh-TW
|
||||
"신호", // ko
|
||||
};
|
||||
|
||||
private static readonly string[] RadioTypeKeys =
|
||||
{
|
||||
"Radio type", // en
|
||||
"Funktyp", // de
|
||||
"Type de radio", // fr
|
||||
"Tipo de radio", // es
|
||||
"Tipo de rádio", // pt
|
||||
"Tipo di radio", // it
|
||||
"Radiotype", // nl, da
|
||||
"Radiotyp", // sv
|
||||
"Typ radia", // pl
|
||||
"Typ rádia", // cs, sk
|
||||
"Radyo türü", // tr
|
||||
"Jenis radio", // id, ms
|
||||
"Radiotyyppi", // fi
|
||||
"Radijo tipas", // lt
|
||||
"Radio tips", // lv
|
||||
"Rádió típusa", // hu
|
||||
"Tip radio", // ro
|
||||
"Vrsta radija", // hr, bs
|
||||
"Loại radio", // vi
|
||||
"ชนิดวิทยุ", // th
|
||||
"نوع الراديو", // ar
|
||||
"Тип радио", // ru
|
||||
"Тип на радиото", // bg
|
||||
"無線の種類", // ja
|
||||
"无线电类型", // zh-CN
|
||||
"無線電類型", // zh-TW
|
||||
"라디오 종류", // ko
|
||||
"무선 종류", // ko (variant)
|
||||
};
|
||||
|
||||
private static readonly string[] ChannelKeys =
|
||||
{
|
||||
"Channel", // en
|
||||
"Kanal", // de, sv, da, nb, tr, hr
|
||||
"Canal", // fr, es, pt, ro
|
||||
"Canale", // it
|
||||
"Kanaal", // nl
|
||||
"Kanał", // pl
|
||||
"Kanál", // cs, sk
|
||||
"Saluran", // id, ms
|
||||
"Kanava", // fi
|
||||
"Kanalas", // lt
|
||||
"Kanāls", // lv
|
||||
"Csatorna", // hu
|
||||
"Kênh", // vi
|
||||
"ช่องสัญญาณ", // th
|
||||
"ช่อง", // th (variant)
|
||||
"القناة", // ar
|
||||
"قناة", // ar (variant without article)
|
||||
"Канал", // ru, bg
|
||||
"チャネル", // ja
|
||||
"信道", // zh-CN
|
||||
"通道", // zh-TW / zh-CN (variant)
|
||||
"채널", // ko
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Runs <c>netsh wlan show interfaces</c> and parses its output. Returns a
|
||||
/// <see cref="WifiConnectionInfo"/> with the fields that could be parsed, or null when
|
||||
/// nothing was found (no WLAN, netsh failure, timeout, or unrecognized localized
|
||||
/// labels). Never throws.
|
||||
/// </summary>
|
||||
internal static WifiConnectionInfo TryRead()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.Netsh,
|
||||
Arguments = "wlan show interfaces",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string output;
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
output = process.StandardOutput.ReadToEnd();
|
||||
if (!process.WaitForExit(NetshTimeoutMs))
|
||||
{
|
||||
try { process.Kill(); } catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return Parse(output);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure parser over the netsh output text. Internal for direct unit-testing via
|
||||
/// InternalsVisibleTo (TryRead itself spawns a process and stays untested).
|
||||
/// </summary>
|
||||
internal static WifiConnectionInfo Parse(string output)
|
||||
{
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return null;
|
||||
|
||||
string ssid = null, radioType = null;
|
||||
int? signal = null, channel = null;
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
|
||||
var key = trimmed.Substring(0, colonIndex).Trim();
|
||||
var value = trimmed.Substring(colonIndex + 1).Trim();
|
||||
|
||||
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ssid = value;
|
||||
}
|
||||
else if (MatchesAny(key, SignalKeys))
|
||||
{
|
||||
// Value formats seen: "94%", "94% " (trailing space), "94 %".
|
||||
if (int.TryParse(value.TrimEnd('%', ' '), out var sig))
|
||||
signal = sig;
|
||||
}
|
||||
else if (MatchesAny(key, RadioTypeKeys))
|
||||
{
|
||||
radioType = value;
|
||||
}
|
||||
else if (MatchesAny(key, ChannelKeys))
|
||||
{
|
||||
if (int.TryParse(value, out var ch))
|
||||
channel = ch;
|
||||
}
|
||||
}
|
||||
|
||||
if (ssid == null && signal == null && radioType == null && channel == null)
|
||||
return null;
|
||||
|
||||
return new WifiConnectionInfo
|
||||
{
|
||||
Ssid = ssid,
|
||||
SignalPercent = signal,
|
||||
RadioType = radioType,
|
||||
Channel = channel,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesAny(string key, string[] candidates)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (key.Equals(candidate, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads WinRT <c>Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState</c>
|
||||
/// (NotStarted=0 / InProgress=1 / Completed=2) via the CLR's built-in WinRT projection —
|
||||
/// no NuGet package, no winmd reference; the type is resolved at runtime with
|
||||
/// <c>ContentType=WindowsRuntime</c>, the same mechanism the bootstrap script uses from
|
||||
/// PowerShell 5.1.
|
||||
/// <para>
|
||||
/// <b>Contract:</b> observational only — never feed this into decision-engine logic.
|
||||
/// Requires Windows 10 1809+ (build 17763, UniversalApiContract 7.0) and is callable
|
||||
/// from the SYSTEM service context (empirically validated, probe session 9c404ae9).
|
||||
/// On older builds or SKUs without the contract the type resolves to null and
|
||||
/// <see cref="Read"/> returns <c>"unavailable"</c>. <see cref="Read"/> never throws.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Empirical timing (Win11 24H2 probe): the state flips InProgress→Completed at desktop
|
||||
/// arrival — it is a corroboration signal, not an early one. WDP reseal and placeholder
|
||||
/// (fooUser) flip semantics are unverified; another reason this must stay observational.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class OobeStateReader
|
||||
{
|
||||
public const string Unavailable = "unavailable";
|
||||
|
||||
// Resolved once per process. Benign race: concurrent first calls resolve the same
|
||||
// PropertyInfo and the assignment is idempotent — no lock needed.
|
||||
private static PropertyInfo _stateProperty;
|
||||
private static bool _initTried;
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>"not_started"</c> / <c>"in_progress"</c> / <c>"completed"</c>,
|
||||
/// <c>"unknown_<n>"</c> for unexpected enum values, or <c>"unavailable"</c>
|
||||
/// when the WinRT contract is absent or any part of the read fails. Never throws.
|
||||
/// </summary>
|
||||
public static string Read()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_initTried)
|
||||
{
|
||||
var type = Type.GetType(
|
||||
"Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType=WindowsRuntime",
|
||||
throwOnError: false);
|
||||
_stateProperty = type?.GetProperty(
|
||||
"OutOfBoxExperienceState",
|
||||
BindingFlags.Public | BindingFlags.Static);
|
||||
_initTried = true;
|
||||
}
|
||||
|
||||
if (_stateProperty == null)
|
||||
return Unavailable;
|
||||
|
||||
var value = Convert.ToInt32(_stateProperty.GetValue(null));
|
||||
switch (value)
|
||||
{
|
||||
case 0: return "not_started";
|
||||
case 1: return "in_progress";
|
||||
case 2: return "completed";
|
||||
default: return "unknown_" + value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Unavailable;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the UEFI Secure Boot signature databases <c>db</c> and <c>KEK</c> directly from
|
||||
/// firmware via <c>GetFirmwareEnvironmentVariableExW</c> and checks which Microsoft
|
||||
/// signing certificates they contain. This is the authoritative source for the 2023
|
||||
/// certificate rollout state — the <c>SecureBoot\Servicing</c> registry key only reflects
|
||||
/// whether the *Windows Update* rollout ran and verified, and lags behind when the
|
||||
/// certificate reached the firmware another way (OEM factory image, WinCS, manual).
|
||||
/// <para>
|
||||
/// The certificate subject CNs are stored as ASCII bytes inside the DER-encoded
|
||||
/// signature lists, so a plain byte-substring search is sufficient — the same check
|
||||
/// Microsoft documents via <c>Get-SecureBootUEFI</c> (which wraps this exact API).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Contract:</b> observational only — never feed this into decision-engine logic.
|
||||
/// <see cref="Read"/> never throws. Reading firmware variables requires
|
||||
/// <c>SeSystemEnvironmentPrivilege</c>, which SYSTEM holds but must be enabled on the
|
||||
/// process token first; the previous privilege state is restored after the read.
|
||||
/// On legacy BIOS the API fails with <c>ERROR_INVALID_FUNCTION</c> → status
|
||||
/// <c>not_uefi</c>; every failure maps to a status string instead of an exception.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class UefiSecureBootCertReader
|
||||
{
|
||||
public const string StatusOk = "ok";
|
||||
public const string StatusNotUefi = "not_uefi";
|
||||
public const string StatusVariableNotFound = "variable_not_found";
|
||||
public const string StatusPrivilegeDenied = "privilege_denied";
|
||||
|
||||
// EFI_IMAGE_SECURITY_DATABASE_GUID — namespace of the "db" variable.
|
||||
private const string DbVariableGuid = "{d719b2cb-3d3a-4596-a3bc-dad00e67656f}";
|
||||
// EFI_GLOBAL_VARIABLE — namespace of the "KEK" variable.
|
||||
private const string KekVariableGuid = "{8be4df61-93ca-11d2-aa0d-00e098032b8c}";
|
||||
|
||||
// Subject CNs searched for in db. 2023 set per Microsoft's June-2026 expiry guidance;
|
||||
// 2011 set kept for context (which legacy CAs are still present).
|
||||
internal const string CertWindowsUefiCa2023 = "Windows UEFI CA 2023";
|
||||
internal const string CertMicrosoftUefiCa2023 = "Microsoft UEFI CA 2023";
|
||||
internal const string CertMicrosoftOptionRomCa2023 = "Microsoft Option ROM UEFI CA 2023";
|
||||
internal const string CertWindowsProductionPca2011 = "Microsoft Windows Production PCA 2011";
|
||||
internal const string CertMicrosoftUefiCa2011 = "Microsoft Corporation UEFI CA 2011";
|
||||
// Subject CNs searched for in KEK.
|
||||
internal const string CertMicrosoftKek2kCa2023 = "Microsoft Corporation KEK 2K CA 2023";
|
||||
internal const string CertMicrosoftKekCa2011 = "Microsoft Corporation KEK CA 2011";
|
||||
|
||||
private const int ErrorInvalidFunction = 1; // legacy BIOS / no UEFI
|
||||
private const int ErrorInsufficientBuffer = 122;
|
||||
private const int ErrorEnvvarNotFound = 203;
|
||||
private const int ErrorPrivilegeNotHeld = 1314;
|
||||
private const int ErrorNotAllAssigned = 1300;
|
||||
|
||||
private const int InitialBufferSize = 64 * 1024;
|
||||
private const int RetryBufferSize = 256 * 1024;
|
||||
|
||||
// SeSystemEnvironmentPrivilege is adjusted on the PROCESS token, so enable → read →
|
||||
// restore is a process-wide side effect and must run as one atomic sequence. Without
|
||||
// this gate a second reader would observe "already enabled", correctly skip its own
|
||||
// restore, and then have the privilege disabled underneath it when the first reader
|
||||
// finishes — a spurious privilege_denied on a machine that actually holds it.
|
||||
private static readonly object FirmwareReadGate = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Reads db and KEK and evaluates the certificate presence flags. Never throws;
|
||||
/// per-variable failures surface as <see cref="UefiSecureBootCertSnapshot.DbStatus"/> /
|
||||
/// <see cref="UefiSecureBootCertSnapshot.KekStatus"/>. Certificate flags are only
|
||||
/// meaningful when the corresponding status is <see cref="StatusOk"/>.
|
||||
/// </summary>
|
||||
public static UefiSecureBootCertSnapshot Read()
|
||||
{
|
||||
var snapshot = new UefiSecureBootCertSnapshot();
|
||||
try
|
||||
{
|
||||
// The critical section covers the whole privileged window, not just the
|
||||
// adjustment — see FirmwareReadGate. Only synchronous kernel calls and byte
|
||||
// scans run inside it, so it cannot block on anything else the agent does.
|
||||
lock (FirmwareReadGate)
|
||||
using (var privilege = SystemEnvironmentPrivilege.TryEnable())
|
||||
{
|
||||
if (!privilege.Enabled)
|
||||
{
|
||||
snapshot.DbStatus = StatusPrivilegeDenied;
|
||||
snapshot.KekStatus = StatusPrivilegeDenied;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
byte[] db;
|
||||
snapshot.DbStatus = TryReadVariable("db", DbVariableGuid, out db);
|
||||
if (snapshot.DbStatus == StatusOk)
|
||||
{
|
||||
snapshot.DbHasWindowsUefiCa2023 = ContainsAscii(db, CertWindowsUefiCa2023);
|
||||
snapshot.DbHasMicrosoftUefiCa2023 = ContainsAscii(db, CertMicrosoftUefiCa2023);
|
||||
snapshot.DbHasMicrosoftOptionRomCa2023 = ContainsAscii(db, CertMicrosoftOptionRomCa2023);
|
||||
snapshot.DbHasWindowsProductionPca2011 = ContainsAscii(db, CertWindowsProductionPca2011);
|
||||
snapshot.DbHasMicrosoftUefiCa2011 = ContainsAscii(db, CertMicrosoftUefiCa2011);
|
||||
}
|
||||
|
||||
byte[] kek;
|
||||
snapshot.KekStatus = TryReadVariable("KEK", KekVariableGuid, out kek);
|
||||
if (snapshot.KekStatus == StatusOk)
|
||||
{
|
||||
snapshot.KekHasMicrosoftKek2kCa2023 = ContainsAscii(kek, CertMicrosoftKek2kCa2023);
|
||||
snapshot.KekHasMicrosoftKekCa2011 = ContainsAscii(kek, CertMicrosoftKekCa2011);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var status = "error_" + ex.GetType().Name;
|
||||
if (snapshot.DbStatus == null) snapshot.DbStatus = status;
|
||||
if (snapshot.KekStatus == null) snapshot.KekStatus = status;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one firmware variable into <paramref name="bytes"/> (trimmed to the actual
|
||||
/// length). Returns a status string; <paramref name="bytes"/> is null unless the
|
||||
/// status is <see cref="StatusOk"/>.
|
||||
/// </summary>
|
||||
private static string TryReadVariable(string name, string guid, out byte[] bytes)
|
||||
{
|
||||
bytes = null;
|
||||
var buffer = new byte[InitialBufferSize];
|
||||
var length = NativeMethods.GetFirmwareEnvironmentVariableExW(name, guid, buffer, (uint)buffer.Length, IntPtr.Zero);
|
||||
if (length == 0)
|
||||
{
|
||||
var error = Marshal.GetLastWin32Error();
|
||||
if (error == ErrorInsufficientBuffer)
|
||||
{
|
||||
buffer = new byte[RetryBufferSize];
|
||||
length = NativeMethods.GetFirmwareEnvironmentVariableExW(name, guid, buffer, (uint)buffer.Length, IntPtr.Zero);
|
||||
if (length == 0)
|
||||
error = Marshal.GetLastWin32Error();
|
||||
}
|
||||
|
||||
if (length == 0)
|
||||
{
|
||||
switch (error)
|
||||
{
|
||||
case ErrorInvalidFunction: return StatusNotUefi;
|
||||
case ErrorEnvvarNotFound: return StatusVariableNotFound;
|
||||
case ErrorPrivilegeNotHeld: return StatusPrivilegeDenied;
|
||||
default: return "error_" + error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bytes = new byte[length];
|
||||
Array.Copy(buffer, bytes, (int)length);
|
||||
return StatusOk;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Byte-level ASCII substring search (ordinal, case-sensitive). The signature lists
|
||||
/// are at most a few hundred KB and read once per agent start — a naive scan is fine.
|
||||
/// </summary>
|
||||
internal static bool ContainsAscii(byte[] haystack, string needle)
|
||||
{
|
||||
if (haystack == null || string.IsNullOrEmpty(needle) || needle.Length > haystack.Length)
|
||||
return false;
|
||||
|
||||
for (var i = 0; i <= haystack.Length - needle.Length; i++)
|
||||
{
|
||||
var j = 0;
|
||||
while (j < needle.Length && haystack[i + j] == (byte)needle[j])
|
||||
j++;
|
||||
if (j == needle.Length)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables <c>SeSystemEnvironmentPrivilege</c> on the process token for the lifetime
|
||||
/// of the instance and restores the previous privilege state on dispose. SYSTEM holds
|
||||
/// the privilege (default-disabled); without elevation <see cref="Enabled"/> stays
|
||||
/// false and the caller reports <see cref="StatusPrivilegeDenied"/>.
|
||||
/// <para>
|
||||
/// <b>Crash-safety.</b> This is the repo's only privilege-adjustment code, so the
|
||||
/// ownership rules are spelled out: the token is held in a <see cref="SafeHandle"/>
|
||||
/// assigned by the P/Invoke marshaler, so the CLR owns it from the moment
|
||||
/// <c>OpenProcessToken</c> returns — it is released by the critical finalizer even if
|
||||
/// this instance never reaches the caller's <c>using</c> (async exception between the
|
||||
/// call and the assignment). <see cref="Dispose"/> is idempotent, never throws, and
|
||||
/// releases the handle in a <c>finally</c> so a failed restore cannot leak it. The
|
||||
/// restore obligation is recorded immediately after the successful adjustment, so no
|
||||
/// throw in between can strand the process with the privilege left enabled.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private sealed class SystemEnvironmentPrivilege : IDisposable
|
||||
{
|
||||
public bool Enabled { get; private set; }
|
||||
|
||||
private SafeTokenHandle _token;
|
||||
private NativeMethods.TOKEN_PRIVILEGES _previousState;
|
||||
private bool _restoreNeeded;
|
||||
private bool _disposed;
|
||||
|
||||
public static SystemEnvironmentPrivilege TryEnable()
|
||||
{
|
||||
var instance = new SystemEnvironmentPrivilege();
|
||||
try
|
||||
{
|
||||
SafeTokenHandle token;
|
||||
if (!NativeMethods.OpenProcessToken(NativeMethods.GetCurrentProcess(),
|
||||
NativeMethods.TOKEN_ADJUST_PRIVILEGES | NativeMethods.TOKEN_QUERY, out token))
|
||||
{
|
||||
// Failed open still yields a (closed/invalid) SafeHandle instance.
|
||||
if (token != null) token.Dispose();
|
||||
return instance;
|
||||
}
|
||||
|
||||
instance._token = token;
|
||||
|
||||
NativeMethods.LUID luid;
|
||||
if (!NativeMethods.LookupPrivilegeValueW(null, "SeSystemEnvironmentPrivilege", out luid))
|
||||
return instance;
|
||||
|
||||
var newState = new NativeMethods.TOKEN_PRIVILEGES
|
||||
{
|
||||
PrivilegeCount = 1,
|
||||
Luid = luid,
|
||||
Attributes = NativeMethods.SE_PRIVILEGE_ENABLED
|
||||
};
|
||||
|
||||
NativeMethods.TOKEN_PRIVILEGES previousState;
|
||||
uint returnLength;
|
||||
if (!NativeMethods.AdjustTokenPrivileges(token, false, ref newState,
|
||||
(uint)Marshal.SizeOf(typeof(NativeMethods.TOKEN_PRIVILEGES)),
|
||||
out previousState, out returnLength))
|
||||
return instance;
|
||||
|
||||
// AdjustTokenPrivileges returns TRUE even when the privilege is not held —
|
||||
// the real verdict is ERROR_NOT_ALL_ASSIGNED in the last error. Nothing was
|
||||
// adjusted in that case, so there is nothing to restore.
|
||||
if (Marshal.GetLastWin32Error() == ErrorNotAllAssigned)
|
||||
return instance;
|
||||
|
||||
// The token is now modified: record the restore obligation before anything
|
||||
// else can throw. Restore only when the privilege was not already enabled.
|
||||
instance._previousState = previousState;
|
||||
instance._restoreNeeded = previousState.PrivilegeCount > 0
|
||||
&& (previousState.Attributes & NativeMethods.SE_PRIVILEGE_ENABLED) == 0;
|
||||
instance.Enabled = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fail-soft: Enabled stays false. Whatever was acquired before the failure
|
||||
// is already tracked on the instance and released by Dispose.
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Enabled = false;
|
||||
|
||||
var token = _token;
|
||||
_token = null;
|
||||
if (token == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (_restoreNeeded)
|
||||
{
|
||||
_restoreNeeded = false;
|
||||
NativeMethods.TOKEN_PRIVILEGES ignored;
|
||||
uint returnLength;
|
||||
NativeMethods.AdjustTokenPrivileges(token, false, ref _previousState,
|
||||
(uint)Marshal.SizeOf(typeof(NativeMethods.TOKEN_PRIVILEGES)),
|
||||
out ignored, out returnLength);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort — the process token privilege state is not load-bearing for
|
||||
// anything else the agent does, and Dispose must never throw.
|
||||
}
|
||||
finally
|
||||
{
|
||||
token.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns a process-token handle. <c>ReleaseHandle</c> runs on the finalizer thread when
|
||||
/// <see cref="SystemEnvironmentPrivilege.Dispose"/> is skipped, so it must not throw —
|
||||
/// a single <c>CloseHandle</c> P/Invoke satisfies that.
|
||||
/// </summary>
|
||||
private sealed class SafeTokenHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
public SafeTokenHandle() : base(true) { }
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
return NativeMethods.CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
|
||||
private static class NativeMethods
|
||||
{
|
||||
public const uint TOKEN_ADJUST_PRIVILEGES = 0x0020;
|
||||
public const uint TOKEN_QUERY = 0x0008;
|
||||
public const uint SE_PRIVILEGE_ENABLED = 0x0002;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct LUID
|
||||
{
|
||||
public uint LowPart;
|
||||
public int HighPart;
|
||||
}
|
||||
|
||||
// Single-privilege variant of the variable-length native struct — sufficient here
|
||||
// because exactly one privilege is adjusted per call.
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct TOKEN_PRIVILEGES
|
||||
{
|
||||
public uint PrivilegeCount;
|
||||
public LUID Luid;
|
||||
public uint Attributes;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern uint GetFirmwareEnvironmentVariableExW(
|
||||
string lpName, string lpGuid, byte[] pBuffer, uint nSize, IntPtr pdwAttributes);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern IntPtr GetCurrentProcess();
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out SafeTokenHandle tokenHandle);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool LookupPrivilegeValueW(string lpSystemName, string lpName, out LUID lpLuid);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool AdjustTokenPrivileges(SafeTokenHandle tokenHandle, bool disableAllPrivileges,
|
||||
ref TOKEN_PRIVILEGES newState, uint bufferLength, out TOKEN_PRIVILEGES previousState, out uint returnLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool CloseHandle(IntPtr handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of one firmware read. Certificate flags are only meaningful when the
|
||||
/// corresponding status equals <see cref="UefiSecureBootCertReader.StatusOk"/>.
|
||||
/// </summary>
|
||||
internal sealed class UefiSecureBootCertSnapshot
|
||||
{
|
||||
public string DbStatus { get; set; }
|
||||
public string KekStatus { get; set; }
|
||||
|
||||
public bool DbHasWindowsUefiCa2023 { get; set; }
|
||||
public bool DbHasMicrosoftUefiCa2023 { get; set; }
|
||||
public bool DbHasMicrosoftOptionRomCa2023 { get; set; }
|
||||
public bool DbHasWindowsProductionPca2011 { get; set; }
|
||||
public bool DbHasMicrosoftUefiCa2011 { get; set; }
|
||||
|
||||
public bool KekHasMicrosoftKek2kCa2023 { get; set; }
|
||||
public bool KekHasMicrosoftKekCa2011 { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Current WiFi connection details as read from the native WLAN API.
|
||||
/// </summary>
|
||||
public sealed class WifiConnectionInfo
|
||||
{
|
||||
/// <summary>SSID of the connected network; null when the AP broadcasts an empty SSID.</summary>
|
||||
public string Ssid { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal quality 0–100 (same semantics as the netsh "Signal" percentage). Always set
|
||||
/// on the native WLAN API path; null only when the netsh fallback could not parse it.
|
||||
/// </summary>
|
||||
public int? SignalPercent { get; internal set; }
|
||||
|
||||
/// <summary>Human-readable PHY generation ("802.11ax" …); null for exotic/unknown PHYs.</summary>
|
||||
public string RadioType { get; internal set; }
|
||||
|
||||
/// <summary>Current 802.11 channel number; null when the channel query fails.</summary>
|
||||
public int? Channel { get; internal set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-soft reader over <see cref="WlanNativeMethods"/>: returns the current WiFi
|
||||
/// connection or null — never throws, never blocks on a child process. Machines without
|
||||
/// the WLAN AutoConfig service (VMs, wired-only devices) simply return null.
|
||||
/// </summary>
|
||||
public static class WifiInfoReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the current WiFi connection. When <paramref name="preferredInterfaceId"/> is
|
||||
/// given (the adapter GUID from <c>NetworkInterface.Id</c>), that interface is queried
|
||||
/// first; otherwise — or when it is not connected — the first connected WLAN interface
|
||||
/// wins. Returns null when no interface has an active connection.
|
||||
/// </summary>
|
||||
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId = null)
|
||||
=> TryGetCurrentConnection(preferredInterfaceId, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="TryGetCurrentConnection(Guid?)"/> but reports WHY the read
|
||||
/// yielded nothing via <paramref name="diagnostics"/> (native return codes per step).
|
||||
/// The WLAN API can fail in ways that are invisible from the outside (e.g. per-process
|
||||
/// access gating on newer Windows builds) — callers log this at Debug so field issues
|
||||
/// are explainable from the agent log instead of a silent missing event.
|
||||
/// </summary>
|
||||
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId, out string diagnostics)
|
||||
{
|
||||
var diag = new StringBuilder();
|
||||
var clientHandle = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
var rc = WlanOpenHandle(WLAN_CLIENT_VERSION, IntPtr.Zero, out _, out clientHandle);
|
||||
if (rc != ERROR_SUCCESS)
|
||||
{
|
||||
diagnostics = $"WlanOpenHandle rc={rc}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var interfaceList = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
rc = WlanEnumInterfaces(clientHandle, IntPtr.Zero, out interfaceList);
|
||||
if (rc != ERROR_SUCCESS)
|
||||
{
|
||||
diagnostics = $"WlanEnumInterfaces rc={rc}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var count = (uint)Marshal.ReadInt32(interfaceList);
|
||||
if (count == 0)
|
||||
{
|
||||
diagnostics = "no WLAN interfaces";
|
||||
return null;
|
||||
}
|
||||
|
||||
var infoSize = Marshal.SizeOf(typeof(WLAN_INTERFACE_INFO));
|
||||
// List header: DWORD dwNumberOfItems + DWORD dwIndex, then the info array.
|
||||
var firstEntry = new IntPtr(interfaceList.ToInt64() + 8);
|
||||
|
||||
WifiConnectionInfo fallback = null;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var entryPtr = new IntPtr(firstEntry.ToInt64() + i * infoSize);
|
||||
var info = (WLAN_INTERFACE_INFO)Marshal.PtrToStructure(entryPtr, typeof(WLAN_INTERFACE_INFO));
|
||||
|
||||
var connection = QueryCurrentConnection(clientHandle, info.InterfaceGuid, diag);
|
||||
if (connection == null)
|
||||
continue;
|
||||
|
||||
if (preferredInterfaceId.HasValue && info.InterfaceGuid == preferredInterfaceId.Value)
|
||||
{
|
||||
diagnostics = null;
|
||||
return connection;
|
||||
}
|
||||
|
||||
if (fallback == null)
|
||||
fallback = connection;
|
||||
}
|
||||
|
||||
diagnostics = fallback == null ? diag.ToString() : null;
|
||||
return fallback;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (interfaceList != IntPtr.Zero)
|
||||
WlanFreeMemory(interfaceList);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
diagnostics = $"exception {ex.GetType().Name}: {ex.Message}";
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (clientHandle != IntPtr.Zero)
|
||||
WlanCloseHandle(clientHandle, IntPtr.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
private static WifiConnectionInfo QueryCurrentConnection(IntPtr clientHandle, Guid interfaceGuid, StringBuilder diag)
|
||||
{
|
||||
var dataPtr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
var rc = WlanQueryInterface(clientHandle, ref interfaceGuid,
|
||||
WLAN_INTF_OPCODE.wlan_intf_opcode_current_connection,
|
||||
IntPtr.Zero, out _, out dataPtr, IntPtr.Zero);
|
||||
if (rc != ERROR_SUCCESS)
|
||||
{
|
||||
diag.Append($"[{interfaceGuid:D}] query(current_connection) rc={rc}; ");
|
||||
return null;
|
||||
}
|
||||
|
||||
var attributes = (WLAN_CONNECTION_ATTRIBUTES)Marshal.PtrToStructure(
|
||||
dataPtr, typeof(WLAN_CONNECTION_ATTRIBUTES));
|
||||
|
||||
if (attributes.isState != WLAN_INTERFACE_STATE.wlan_interface_state_connected)
|
||||
{
|
||||
diag.Append($"[{interfaceGuid:D}] state={attributes.isState}; ");
|
||||
return null;
|
||||
}
|
||||
|
||||
var assoc = attributes.wlanAssociationAttributes;
|
||||
return new WifiConnectionInfo
|
||||
{
|
||||
Ssid = DecodeSsid(assoc.dot11Ssid.ucSSID, assoc.dot11Ssid.uSSIDLength),
|
||||
SignalPercent = (int)Math.Min(assoc.wlanSignalQuality, 100),
|
||||
RadioType = PhyTypeToRadioType(assoc.dot11PhyType),
|
||||
Channel = QueryChannel(clientHandle, interfaceGuid),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (dataPtr != IntPtr.Zero)
|
||||
WlanFreeMemory(dataPtr);
|
||||
}
|
||||
}
|
||||
|
||||
private static int? QueryChannel(IntPtr clientHandle, Guid interfaceGuid)
|
||||
{
|
||||
var dataPtr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (WlanQueryInterface(clientHandle, ref interfaceGuid,
|
||||
WLAN_INTF_OPCODE.wlan_intf_opcode_channel_number,
|
||||
IntPtr.Zero, out var dataSize, out dataPtr, IntPtr.Zero) != ERROR_SUCCESS)
|
||||
return null;
|
||||
|
||||
if (dataSize < sizeof(uint) || dataPtr == IntPtr.Zero)
|
||||
return null;
|
||||
|
||||
return Marshal.ReadInt32(dataPtr);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (dataPtr != IntPtr.Zero)
|
||||
WlanFreeMemory(dataPtr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a raw DOT11_SSID (UTF-8 by convention) into a string; null for empty SSIDs.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static string DecodeSsid(byte[] raw, uint length)
|
||||
{
|
||||
if (raw == null || length == 0)
|
||||
return null;
|
||||
|
||||
var byteCount = (int)Math.Min(length, (uint)raw.Length);
|
||||
try
|
||||
{
|
||||
var ssid = Encoding.UTF8.GetString(raw, 0, byteCount);
|
||||
return string.IsNullOrEmpty(ssid) ? null : ssid;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a DOT11 PHY type to the same strings netsh historically reported as
|
||||
/// "Radio type" — keeps event payloads consistent across the netsh→WLAN-API switch.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static string PhyTypeToRadioType(DOT11_PHY_TYPE phyType)
|
||||
{
|
||||
switch (phyType)
|
||||
{
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_ofdm: return "802.11a";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_hrdsss: return "802.11b";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_erp: return "802.11g";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_ht: return "802.11n";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_vht: return "802.11ac";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_dmg: return "802.11ad";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_he: return "802.11ax";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_eht: return "802.11be";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Native WLAN API interop (<c>wlanapi.dll</c>) used to read the current WiFi connection
|
||||
/// (SSID, signal quality, PHY type, channel) without spawning <c>netsh</c>.
|
||||
/// <para>
|
||||
/// The WLAN API is language-neutral — unlike parsing <c>netsh wlan show interfaces</c>
|
||||
/// output, whose labels follow the OS UI language and silently drop fields on
|
||||
/// non-English images. On machines without the WLAN AutoConfig service (VMs, servers,
|
||||
/// wired-only devices) <c>WlanOpenHandle</c>/<c>WlanEnumInterfaces</c> fail with a
|
||||
/// non-zero error code; callers treat that as "no WiFi" (see <see cref="WifiInfoReader"/>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class WlanNativeMethods
|
||||
{
|
||||
internal const uint ERROR_SUCCESS = 0;
|
||||
|
||||
/// <summary>Client version 2 = Vista and later (the only relevant range).</summary>
|
||||
internal const uint WLAN_CLIENT_VERSION = 2;
|
||||
|
||||
internal enum WLAN_INTF_OPCODE : uint
|
||||
{
|
||||
wlan_intf_opcode_current_connection = 7,
|
||||
wlan_intf_opcode_channel_number = 8,
|
||||
}
|
||||
|
||||
internal enum WLAN_INTERFACE_STATE
|
||||
{
|
||||
wlan_interface_state_not_ready = 0,
|
||||
wlan_interface_state_connected = 1,
|
||||
wlan_interface_state_ad_hoc_network_formed = 2,
|
||||
wlan_interface_state_disconnecting = 3,
|
||||
wlan_interface_state_disconnected = 4,
|
||||
wlan_interface_state_associating = 5,
|
||||
wlan_interface_state_discovering = 6,
|
||||
wlan_interface_state_authenticating = 7,
|
||||
}
|
||||
|
||||
internal enum DOT11_PHY_TYPE : uint
|
||||
{
|
||||
dot11_phy_type_unknown = 0,
|
||||
dot11_phy_type_fhss = 1,
|
||||
dot11_phy_type_dsss = 2,
|
||||
dot11_phy_type_irbaseband = 3,
|
||||
dot11_phy_type_ofdm = 4, // 802.11a
|
||||
dot11_phy_type_hrdsss = 5, // 802.11b
|
||||
dot11_phy_type_erp = 6, // 802.11g
|
||||
dot11_phy_type_ht = 7, // 802.11n
|
||||
dot11_phy_type_vht = 8, // 802.11ac
|
||||
dot11_phy_type_dmg = 9, // 802.11ad
|
||||
dot11_phy_type_he = 10, // 802.11ax
|
||||
dot11_phy_type_eht = 11, // 802.11be
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
internal struct WLAN_INTERFACE_INFO
|
||||
{
|
||||
public Guid InterfaceGuid;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string strInterfaceDescription;
|
||||
public WLAN_INTERFACE_STATE isState;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct DOT11_SSID
|
||||
{
|
||||
public uint uSSIDLength;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
|
||||
public byte[] ucSSID;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct WLAN_ASSOCIATION_ATTRIBUTES
|
||||
{
|
||||
public DOT11_SSID dot11Ssid;
|
||||
public int dot11BssType;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)]
|
||||
public byte[] dot11Bssid;
|
||||
public DOT11_PHY_TYPE dot11PhyType;
|
||||
public uint uDot11PhyIndex;
|
||||
public uint wlanSignalQuality; // 0..100, same semantics as netsh "Signal"
|
||||
public uint ulRxRate;
|
||||
public uint ulTxRate;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct WLAN_SECURITY_ATTRIBUTES
|
||||
{
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool bSecurityEnabled;
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool bOneXEnabled;
|
||||
public int dot11AuthAlgorithm;
|
||||
public int dot11CipherAlgorithm;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
internal struct WLAN_CONNECTION_ATTRIBUTES
|
||||
{
|
||||
public WLAN_INTERFACE_STATE isState;
|
||||
public int wlanConnectionMode;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string strProfileName;
|
||||
public WLAN_ASSOCIATION_ATTRIBUTES wlanAssociationAttributes;
|
||||
public WLAN_SECURITY_ATTRIBUTES wlanSecurityAttributes;
|
||||
}
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanOpenHandle(
|
||||
uint dwClientVersion,
|
||||
IntPtr pReserved,
|
||||
out uint pdwNegotiatedVersion,
|
||||
out IntPtr phClientHandle);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanCloseHandle(
|
||||
IntPtr hClientHandle,
|
||||
IntPtr pReserved);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanEnumInterfaces(
|
||||
IntPtr hClientHandle,
|
||||
IntPtr pReserved,
|
||||
out IntPtr ppInterfaceList);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanQueryInterface(
|
||||
IntPtr hClientHandle,
|
||||
[In] ref Guid pInterfaceGuid,
|
||||
WLAN_INTF_OPCODE OpCode,
|
||||
IntPtr pReserved,
|
||||
out uint pdwDataSize,
|
||||
out IntPtr ppData,
|
||||
IntPtr pWlanOpcodeValueType);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern void WlanFreeMemory(IntPtr pMemory);
|
||||
}
|
||||
}
|
||||
+13
-8
@@ -218,15 +218,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// This method is non-fatal: all exceptions are caught and logged.
|
||||
/// </summary>
|
||||
/// <param name="enrollmentSucceeded">
|
||||
/// True for a successful enrollment (affects OnFailure mode check and sessioninfo.txt content).
|
||||
/// Pass true for WhiteGlove pre-provisioning (it succeeded up to this point).
|
||||
/// True for a successful enrollment, false for a failed one (drives the OnFailure mode
|
||||
/// check and sessioninfo.txt content). Pass true for WhiteGlove pre-provisioning (it
|
||||
/// succeeded up to this point). Pass <b>null</b> when the session has no outcome yet
|
||||
/// (on-demand server-requested collection mid-enrollment): the OnFailure gate does not
|
||||
/// apply — there is no success to skip on — and sessioninfo.txt records "In Progress".
|
||||
/// </param>
|
||||
/// <param name="fileNameSuffix">
|
||||
/// Optional suffix inserted before the .zip extension.
|
||||
/// Example: "preprov" → AgentDiagnostics-{sessionId}-{timestamp}-preprov.zip
|
||||
/// Null (default) → AgentDiagnostics-{sessionId}-{timestamp}.zip
|
||||
/// </param>
|
||||
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool enrollmentSucceeded, string fileNameSuffix = null)
|
||||
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool? enrollmentSucceeded, string fileNameSuffix = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -244,13 +247,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
|
||||
// Null outcome (on-demand mid-session) passes through: the OnFailure gate only
|
||||
// skips a KNOWN success — an in-flight session has nothing to skip on.
|
||||
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded == true)
|
||||
{
|
||||
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={enrollmentSucceeded}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
|
||||
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={(enrollmentSucceeded.HasValue ? enrollmentSucceeded.ToString() : "n/a")}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
|
||||
|
||||
// Build ZIP in memory
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
|
||||
@@ -343,7 +348,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
// Builds the diagnostics ZIP body in-memory. Extracted from CreateAndUploadAsync so
|
||||
// tests can assert archive contents without going through the upload path.
|
||||
internal virtual byte[] BuildArchiveBytes(bool enrollmentSucceeded)
|
||||
internal virtual byte[] BuildArchiveBytes(bool? enrollmentSucceeded)
|
||||
{
|
||||
var tracker = new BudgetTracker(Budget);
|
||||
using (var ms = new MemoryStream())
|
||||
@@ -414,14 +419,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded)
|
||||
private void AddSessionInfo(ZipArchive archive, bool? enrollmentSucceeded)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Session ID: {_configuration.SessionId}");
|
||||
sb.AppendLine($"Tenant ID: {_configuration.TenantId}");
|
||||
sb.AppendLine($"Device Name: {Environment.MachineName}");
|
||||
sb.AppendLine($"Timestamp: {DateTime.UtcNow:O}");
|
||||
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded ? "Succeeded" : "Failed")}");
|
||||
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded == null ? "In Progress" : enrollmentSucceeded.Value ? "Succeeded" : "Failed")}");
|
||||
|
||||
// Hardware info via existing DeviceInfoProvider (WMI)
|
||||
sb.AppendLine($"Manufacturer: {DeviceInfoProvider.GetManufacturer()}");
|
||||
|
||||
+100
-65
@@ -8,6 +8,7 @@ using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
@@ -493,7 +494,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
}
|
||||
}
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.SecureBootStatus, $"SecureBoot: {data["uefiSecureBootEnabled"]}, CA2023: {data["uefiCA2023Status"]}", data);
|
||||
// Direct firmware evidence (UEFI db/KEK variables) — authoritative for the
|
||||
// CA-2023 rollout state. The Servicing key above only mirrors whether the
|
||||
// Windows Update rollout ran; it lags behind when the certificate reached
|
||||
// the firmware another way (OEM factory image, WinCS, manual deployment).
|
||||
var firmware = UefiSecureBootCertReader.Read();
|
||||
AppendFirmwareCertFields(data, firmware);
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.SecureBootStatus,
|
||||
$"SecureBoot: {data["uefiSecureBootEnabled"]}, CA2023: {data["uefiCA2023Status"]}, firmware: {DescribeFirmwareVerdict(firmware)}",
|
||||
data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -501,6 +511,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="UefiSecureBootCertSnapshot"/> onto the <c>secureboot_status</c>
|
||||
/// payload. Certificate booleans are only emitted when the respective variable read
|
||||
/// succeeded — an absent boolean means "firmware state unknown", never "missing".
|
||||
/// </summary>
|
||||
internal static void AppendFirmwareCertFields(Dictionary<string, object> data, UefiSecureBootCertSnapshot firmware)
|
||||
{
|
||||
if (firmware == null) return;
|
||||
|
||||
data["uefiFirmwareReadStatus"] = firmware.DbStatus != UefiSecureBootCertReader.StatusOk
|
||||
? firmware.DbStatus
|
||||
: firmware.KekStatus;
|
||||
|
||||
if (firmware.DbStatus == UefiSecureBootCertReader.StatusOk)
|
||||
{
|
||||
data["uefiDbHasWindowsUefiCa2023"] = firmware.DbHasWindowsUefiCa2023;
|
||||
data["uefiDbHasMicrosoftUefiCa2023"] = firmware.DbHasMicrosoftUefiCa2023;
|
||||
data["uefiDbHasMicrosoftOptionRomCa2023"] = firmware.DbHasMicrosoftOptionRomCa2023;
|
||||
data["uefiDbHasWindowsProductionPca2011"] = firmware.DbHasWindowsProductionPca2011;
|
||||
data["uefiDbHasMicrosoftUefiCa2011"] = firmware.DbHasMicrosoftUefiCa2011;
|
||||
|
||||
// One-sided marker consumed by ANALYZE-SEC-001's not_exists precondition.
|
||||
// The engine's not_exists passes only while the field is absent/empty, so the
|
||||
// marker must be OMITTED (never emitted as false) unless the firmware db
|
||||
// positively confirms the 2023 certificate — old agents and unconfirmed
|
||||
// devices then keep evaluating the rule.
|
||||
if (firmware.DbHasWindowsUefiCa2023)
|
||||
data["uefiCa2023FirmwareConfirmed"] = true;
|
||||
}
|
||||
|
||||
if (firmware.KekStatus == UefiSecureBootCertReader.StatusOk)
|
||||
{
|
||||
data["uefiKekHasMicrosoftKek2kCa2023"] = firmware.KekHasMicrosoftKek2kCa2023;
|
||||
data["uefiKekHasMicrosoftKekCa2011"] = firmware.KekHasMicrosoftKekCa2011;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string DescribeFirmwareVerdict(UefiSecureBootCertSnapshot firmware)
|
||||
{
|
||||
if (firmware == null) return "unavailable";
|
||||
if (firmware.DbStatus != UefiSecureBootCertReader.StatusOk) return firmware.DbStatus ?? "unavailable";
|
||||
return firmware.DbHasWindowsUefiCa2023 ? "CA2023 confirmed" : "CA2023 missing";
|
||||
}
|
||||
|
||||
private void CollectBitLockerStatus()
|
||||
{
|
||||
try
|
||||
@@ -652,10 +706,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
/// the desktop via "Continue anyway".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// Serializes the ESP-config read+emit across ALL callers (CollectAll on the startup
|
||||
// ThreadPool task, CollectAtEnrollmentStart and RefreshEspConfiguration on their own
|
||||
// Task.Run) — without it, two concurrent collects can interleave read-before-emit and
|
||||
// the LATER emission can carry the OLDER (smaller) registry snapshot. The backend keeps
|
||||
// the latest list-bearing emission as positive blocking evidence, so an out-of-order
|
||||
// emission would silently shrink it (Codex review). Inside the lock each collect is
|
||||
// read→emit atomic: a later emission always carries an equal-or-newer snapshot.
|
||||
private readonly object _espConfigCollectLock = new object();
|
||||
|
||||
// Internal (not private) as a test seam: unlike CollectAll/CollectAtEnrollmentStart this
|
||||
// method touches no live system state once both probes are overridden, so tests can
|
||||
// drive the esp_config_detected event surface in isolation (InternalsVisibleTo).
|
||||
internal (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration()
|
||||
{
|
||||
lock (_espConfigCollectLock)
|
||||
{
|
||||
return CollectEspConfigurationLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfigurationLocked()
|
||||
{
|
||||
bool? skipUser = null;
|
||||
bool? skipDevice = null;
|
||||
@@ -744,7 +815,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
/// Collects active network interface details (name, type, link speed, MAC) using
|
||||
/// System.Net.NetworkInformation — instant, no process spawn.
|
||||
/// If the active interface is WiFi, fires off a separate async task to collect
|
||||
/// WiFi signal info via netsh (fire-and-forget, never blocks).
|
||||
/// WiFi signal info via the native WLAN API (fire-and-forget, never blocks).
|
||||
/// </summary>
|
||||
private void CollectActiveNetworkInterfaceInfo()
|
||||
{
|
||||
@@ -787,7 +858,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// Fire-and-forget WiFi signal collection — separate event, never blocks
|
||||
if (isWifi)
|
||||
{
|
||||
Task.Run(() => CollectWiFiSignalInfo());
|
||||
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
|
||||
Task.Run(() => CollectWiFiSignalInfo(nicGuid));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -828,78 +900,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects WiFi signal info via netsh wlan show interfaces and emits a separate
|
||||
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run —
|
||||
/// never blocks other collection. VMs without WiFi service simply get no event.
|
||||
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
|
||||
/// language-neutral, no process spawn) and emits a separate wifi_signal_info event.
|
||||
/// Designed to run as fire-and-forget via Task.Run — never blocks other collection.
|
||||
/// VMs without WiFi service simply get no event.
|
||||
/// </summary>
|
||||
private void CollectWiFiSignalInfo()
|
||||
private void CollectWiFiSignalInfo(Guid? interfaceGuid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
|
||||
if (wifi == null)
|
||||
{
|
||||
FileName = SystemPaths.Netsh,
|
||||
Arguments = "wlan show interfaces",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string output;
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
_logger.Debug($"EnrollmentTracker: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
|
||||
wifi = NetshWifiFallback.TryRead();
|
||||
if (wifi == null)
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
if (wifi.Ssid != null)
|
||||
data["wifiSsid"] = wifi.Ssid;
|
||||
if (wifi.SignalPercent.HasValue)
|
||||
data["wifiSignalPercent"] = wifi.SignalPercent.Value;
|
||||
if (wifi.RadioType != null)
|
||||
data["wifiRadioType"] = wifi.RadioType;
|
||||
if (wifi.Channel.HasValue)
|
||||
data["wifiChannel"] = wifi.Channel.Value;
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
var message = wifi.Ssid != null ? $"WiFi: {wifi.Ssid}" : "WiFi signal info";
|
||||
if (wifi.SignalPercent.HasValue)
|
||||
message += $", Signal: {wifi.SignalPercent.Value}%";
|
||||
if (wifi.RadioType != null)
|
||||
message += $" ({wifi.RadioType})";
|
||||
|
||||
var key = trimmed.Substring(0, colonIndex).Trim();
|
||||
var value = trimmed.Substring(colonIndex + 1).Trim();
|
||||
|
||||
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
|
||||
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
data["wifiSsid"] = value;
|
||||
}
|
||||
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value.TrimEnd('%'), out var signal))
|
||||
data["wifiSignalPercent"] = signal;
|
||||
}
|
||||
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
data["wifiRadioType"] = value;
|
||||
}
|
||||
else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value, out var channel))
|
||||
data["wifiChannel"] = channel;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.Count > 0)
|
||||
{
|
||||
var message = data.ContainsKey("wifiSsid")
|
||||
? $"WiFi: {data["wifiSsid"]}"
|
||||
: "WiFi signal info";
|
||||
if (data.ContainsKey("wifiSignalPercent"))
|
||||
message += $", Signal: {data["wifiSignalPercent"]}%";
|
||||
if (data.ContainsKey("wifiRadioType"))
|
||||
message += $" ({data["wifiRadioType"]})";
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
|
||||
}
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+17
@@ -114,6 +114,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
CollectTpmStatus();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the ESP configuration (skip flags + ESPTrackingInfo blocking lists) and
|
||||
/// re-emits <c>esp_config_detected</c> when the payload changed. Audit Q2: the earlier
|
||||
/// emissions (agent start, DeviceSetup detection) fire before the registry lists are
|
||||
/// complete — the ESP writes one timestamped subkey per CSP status write, so the
|
||||
/// device-scope list grows progressively and the user-scope <c>S-<SID></c> lists
|
||||
/// appear only after sign-in. Called by <see cref="Orchestration.DeviceInfoHost"/> as
|
||||
/// phase-driven one-shots (apps sub-phase opened, AccountSetup detected) — never on a
|
||||
/// timer. The StartupEventGate keeps an unchanged payload silent, so a re-collect that
|
||||
/// found nothing new emits nothing.
|
||||
/// </summary>
|
||||
public void RefreshEspConfiguration(string trigger)
|
||||
{
|
||||
_logger.Info($"EnrollmentTracker: re-collecting ESP configuration (trigger={trigger})");
|
||||
CollectEspConfiguration();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-collects device info that may change during enrollment (e.g. BitLocker enabled via policy).
|
||||
/// Called at enrollment complete / FinalizingSetup transition to capture final state.
|
||||
|
||||
+44
-1
@@ -25,14 +25,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
var filePath = rule.Target;
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, "empty target — nothing to parse");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
if (filePath == null)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
"target contains %LOGGED_ON_USER_PROFILE% but no user is logged on — skipped this run");
|
||||
return null; // Token present but no user logged on — skip silently
|
||||
}
|
||||
|
||||
// Apply IME log path override if set. The override comes from the local
|
||||
// --ime-log-path flag only (never from remote config), so the operator
|
||||
@@ -66,6 +73,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
string.IsNullOrEmpty(patternStr))
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
|
||||
"missing 'pattern' parameter — rule can never match");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -93,6 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
|
||||
$"invalid regex pattern — rule can never match: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -101,6 +112,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
if (resolvedPaths.Count == 0)
|
||||
{
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"no files matched target {filePath}" + (HasWildcard(Path.GetFileName(filePath)) ? " (wildcard)" : "") + " — nothing to parse");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -144,7 +157,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
try
|
||||
{
|
||||
// Return matched files sorted by last write time (newest first), capped at 20
|
||||
return Directory.GetFiles(directory, fileNamePart)
|
||||
var allMatches = Directory.GetFiles(directory, fileNamePart);
|
||||
if (allMatches.Length > 20)
|
||||
context.DebugLog(ruleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"wildcard matched {allMatches.Length} files — capped to the 20 newest");
|
||||
return allMatches
|
||||
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
@@ -152,6 +169,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {ruleId}: wildcard expansion failed for {filePath}: {ex.Message}");
|
||||
context.DebugLog(ruleId, GatherRuleDebugLog.StageError, $"wildcard expansion failed for {filePath}: {ex.Message}");
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
@@ -168,10 +186,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
// Nothing new to read
|
||||
if (startPosition >= fileInfo.Length)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{Path.GetFileName(filePath)}: no new content (position {startPosition} >= length {fileInfo.Length}) — trackPosition=true, nothing re-read");
|
||||
return;
|
||||
}
|
||||
|
||||
int matchCount = 0;
|
||||
int linesRead = 0;
|
||||
int parseFailCount = 0;
|
||||
int timeoutCount = 0;
|
||||
long endPosition = startPosition;
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
@@ -195,6 +219,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
@@ -239,7 +264,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// CMTrace mode: parse line as CMTrace format, match regex against message
|
||||
CmTraceLogEntry entry;
|
||||
if (!CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
parseFailCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Match match;
|
||||
try
|
||||
@@ -248,6 +276,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
@@ -301,10 +330,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
if (matchCount > 0)
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
|
||||
|
||||
// Per-file outcome — always written so a "matched 0" run is visible too.
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{Path.GetFileName(filePath)}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
|
||||
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}");
|
||||
|
||||
if (!isTextMode && linesRead > 0 && parseFailCount == linesRead)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
"every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
|
||||
|
||||
if (linesRead == maxLines)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"stopped at maxLines={maxLines} — remaining content is deferred to the next run");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"failed reading {filePath}: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -113,7 +113,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
// Suppress the "not found" event when the rule only wants a positive signal.
|
||||
if (emitOnlyIfExists)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
|
||||
$"registry key/value not found: {path} — emitOnlyIfExists=true, empty result");
|
||||
return new Dictionary<string, object>(); // empty → executor emits nothing
|
||||
}
|
||||
|
||||
data["exists"] = false;
|
||||
data["path"] = path;
|
||||
|
||||
+16
-1
@@ -22,13 +22,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public LogFilePositionTracker FilePositionTracker { get; }
|
||||
public bool UnrestrictedMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional gather-rule debug trace writer (EnableGatherRuleDebugLog). Null when
|
||||
/// tracing is disabled — use <see cref="DebugLog"/> for null-safe writes.
|
||||
/// </summary>
|
||||
public GatherRuleDebugLog DebugWriter { get; }
|
||||
|
||||
public GatherRuleContext(
|
||||
AgentLogger logger,
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
string imeLogPathOverride,
|
||||
LogFilePositionTracker filePositionTracker)
|
||||
LogFilePositionTracker filePositionTracker,
|
||||
GatherRuleDebugLog debugLog = null)
|
||||
{
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
@@ -36,8 +43,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
ImeLogPathOverride = imeLogPathOverride;
|
||||
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
|
||||
DebugWriter = debugLog;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null-safe write to the gather-rule debug trace. No-op when tracing is disabled.
|
||||
/// </summary>
|
||||
public void DebugLog(string ruleId, string stage, string message) => DebugWriter?.Write(ruleId, stage, message);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a security_warning event and returns an empty result dictionary.
|
||||
/// Called when a gather rule targets a path/query not on the allowlist.
|
||||
@@ -45,6 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public Dictionary<string, object> EmitSecurityWarning(GatherRule rule, string collectorType, string target)
|
||||
{
|
||||
Logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageGuard,
|
||||
$"{collectorType} target blocked by allowlist guard: {target} — security_warning emitted, rule produced no data");
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Append-only trace file for gather-rule evaluation, enabled per tenant via
|
||||
/// EnableGatherRuleDebugLog (or --gather-debug-log). Explains every evaluation
|
||||
/// decision — including the outcomes that produce no event — so customers can
|
||||
/// diagnose rules that never show up in the timeline. Every write failure is
|
||||
/// swallowed: tracing must never break gathering.
|
||||
/// </summary>
|
||||
public sealed class GatherRuleDebugLog
|
||||
{
|
||||
public const string StageConfig = "config";
|
||||
public const string StageTrigger = "trigger";
|
||||
public const string StageScope = "scope";
|
||||
public const string StageExec = "exec";
|
||||
public const string StageCollector = "collector";
|
||||
public const string StageGuard = "guard";
|
||||
public const string StageEmit = "emit";
|
||||
public const string StageSuppress = "suppress";
|
||||
public const string StageLogParser = "logparser";
|
||||
public const string StageError = "error";
|
||||
|
||||
private const long DefaultMaxBytes = 10 * 1024 * 1024;
|
||||
|
||||
private readonly string _path;
|
||||
private readonly long _maxBytes;
|
||||
private readonly Action<string> _echo;
|
||||
private readonly object _lock = new object();
|
||||
private bool _headerWritten;
|
||||
|
||||
public GatherRuleDebugLog(string path, AgentLogger logger, Action<string> echo = null, long maxBytes = DefaultMaxBytes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path));
|
||||
_path = path;
|
||||
_maxBytes = maxBytes;
|
||||
_echo = echo;
|
||||
logger?.Info($"GatherRuleDebugLog: enabled -> {path}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes one trace line: <c>{utc} | {ruleId} | {stage} | {message}</c>.
|
||||
/// Newlines in <paramref name="message"/> are flattened except for the error
|
||||
/// stage, where exception stack traces stay multi-line.
|
||||
/// </summary>
|
||||
public void Write(string ruleId, string stage, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (stage != StageError && message != null && message.IndexOf('\n') >= 0)
|
||||
message = message.Replace("\r", "").Replace('\n', ' ');
|
||||
|
||||
var line = $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | {ruleId ?? "-"} | {stage} | {message}";
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureFileReadyLocked();
|
||||
File.AppendAllText(_path, line + Environment.NewLine);
|
||||
}
|
||||
|
||||
_echo?.Invoke(line);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-shot helper for callers that have no executor (e.g. the flag is on but the
|
||||
/// backend delivered zero gather rules) — the customer's trace file should explain
|
||||
/// that too instead of silently not existing.
|
||||
/// </summary>
|
||||
public static void WriteStandalone(string path, string message, AgentLogger logger)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
try
|
||||
{
|
||||
new GatherRuleDebugLog(path, logger).Write(null, StageConfig, message);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Lazy on first write: create the directory and emit a header line. Before every
|
||||
// append, rotate when the file exceeds maxBytes — keeping exactly one .old
|
||||
// generation (worst case ~2x maxBytes on disk).
|
||||
private void EnsureFileReadyLocked()
|
||||
{
|
||||
if (!_headerWritten)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var info = new FileInfo(_path);
|
||||
if (info.Exists && info.Length > _maxBytes)
|
||||
{
|
||||
var oldPath = Path.ChangeExtension(_path, null) + ".old.log";
|
||||
if (File.Exists(oldPath))
|
||||
File.Delete(oldPath);
|
||||
File.Move(_path, oldPath);
|
||||
File.AppendAllText(_path,
|
||||
$"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | - | {StageConfig} | -- rotated (previous file exceeded {_maxBytes} bytes) --" + Environment.NewLine);
|
||||
}
|
||||
|
||||
if (!_headerWritten)
|
||||
{
|
||||
File.AppendAllText(_path,
|
||||
$"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | - | {StageConfig} | -- gather rule debug log started --" + Environment.NewLine);
|
||||
_headerWritten = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
-11
@@ -22,6 +22,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public class GatherRuleExecutor : IDisposable
|
||||
{
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
|
||||
private readonly GatherRuleContext _context;
|
||||
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
|
||||
|
||||
@@ -61,7 +62,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
|
||||
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger, string imeLogPathOverride = null)
|
||||
AgentLogger logger, string imeLogPathOverride = null, string debugLogPath = null,
|
||||
Action<string> debugEcho = null)
|
||||
{
|
||||
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
|
||||
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -69,9 +71,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
if (logger == null) throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_logger = logger;
|
||||
_debug = string.IsNullOrEmpty(debugLogPath) ? null : new GatherRuleDebugLog(debugLogPath, logger, debugEcho);
|
||||
|
||||
var filePositionTracker = new LogFilePositionTracker();
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker);
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker, _debug);
|
||||
|
||||
// Register all collector strategies
|
||||
var collectorList = new IGatherRuleCollector[]
|
||||
@@ -96,6 +99,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
_collectors["command"] = _collectors["command_allowlisted"];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null-safe write to the gather-rule debug trace. Never call while holding
|
||||
/// <see cref="_scopeLock"/> — capture the message under the lock, write after release.
|
||||
/// </summary>
|
||||
private void DebugLog(string ruleId, string stage, string message) => _debug?.Write(ruleId, stage, message);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the active rules and starts/stops execution accordingly
|
||||
/// </summary>
|
||||
@@ -111,11 +120,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
_activeRules = rules.Where(r => r.Enabled).ToList();
|
||||
|
||||
// Registration summary for the debug trace: one line per delivered rule so the
|
||||
// customer can see exactly what the agent received and how it will be triggered.
|
||||
if (_debug != null)
|
||||
{
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (!rule.Enabled)
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageConfig, "rule disabled — will never run");
|
||||
continue;
|
||||
}
|
||||
|
||||
var scope = rule.ActivePhases != null && rule.ActivePhases.Count > 0
|
||||
? $"activePhases=[{string.Join(",", rule.ActivePhases)}]"
|
||||
: !string.IsNullOrEmpty(rule.ActiveFromPhase)
|
||||
? $"activeFromPhase={rule.ActiveFromPhase}"
|
||||
: "unscoped";
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageConfig,
|
||||
$"registered: trigger={rule.Trigger}" +
|
||||
(string.IsNullOrEmpty(rule.TriggerPhase) ? "" : $" triggerPhase={rule.TriggerPhase}") +
|
||||
(string.IsNullOrEmpty(rule.TriggerEventType) ? "" : $" triggerEventType={rule.TriggerEventType}") +
|
||||
$", collector={rule.CollectorType}, target={rule.Target}, {scope}" +
|
||||
$", emitMode={(string.IsNullOrEmpty(rule.EmitMode) ? "always" : rule.EmitMode)}" +
|
||||
(rule.IntervalSeconds.HasValue ? $", interval={rule.IntervalSeconds}s" : ""));
|
||||
|
||||
if (rule.Trigger == "interval" && !rule.IntervalSeconds.HasValue)
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
|
||||
"trigger=interval but intervalSeconds is missing — rule will NEVER run");
|
||||
}
|
||||
}
|
||||
|
||||
// Execute startup rules — track completion via CountdownEvent so callers can wait.
|
||||
// Scoped startup rules that are not yet in scope are deferred: they run once from
|
||||
// OnPhaseChanged when their scope activates (same _startupRulesExecuted dedup — a
|
||||
// rule runs either here or there, never both). Unscoped rules behave as before.
|
||||
List<GatherRule> pendingStartup;
|
||||
var deferredTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
// A config refresh may deliver new from-phase rules after their phase was
|
||||
@@ -124,7 +165,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
pendingStartup = _activeRules
|
||||
.Where(r => r.Trigger == "startup" && !_startupRulesExecuted.Contains(r.RuleId))
|
||||
.Where(r => IsRuleInScopeLocked(r))
|
||||
.Where(r =>
|
||||
{
|
||||
string reason;
|
||||
var inScope = IsRuleInScopeLocked(r, out reason);
|
||||
if (!inScope && deferredTraces != null)
|
||||
deferredTraces.Add(new KeyValuePair<string, string>(r.RuleId,
|
||||
$"startup rule deferred — {reason}; runs once when its scope activates"));
|
||||
return inScope;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
foreach (var rule in pendingStartup)
|
||||
@@ -133,6 +182,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
}
|
||||
|
||||
if (deferredTraces != null)
|
||||
{
|
||||
foreach (var trace in deferredTraces)
|
||||
DebugLog(trace.Key, GatherRuleDebugLog.StageScope, trace.Value);
|
||||
}
|
||||
|
||||
if (pendingStartup.Count > 0)
|
||||
{
|
||||
_startupRulesLatch?.Dispose();
|
||||
@@ -158,8 +213,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
var timer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
if (!IsRuleInScope(intervalRule))
|
||||
string skipReason;
|
||||
if (!IsRuleInScope(intervalRule, out skipReason))
|
||||
{
|
||||
DebugLog(intervalRule.RuleId, GatherRuleDebugLog.StageScope, $"interval tick skipped: {skipReason}");
|
||||
return;
|
||||
}
|
||||
ExecuteRule(intervalRule);
|
||||
},
|
||||
null,
|
||||
@@ -168,6 +227,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
);
|
||||
_intervalTimers[intervalRule.RuleId] = timer;
|
||||
_logger.Info($" Interval rule {intervalRule.RuleId} scheduled every {intervalRule.IntervalSeconds}s");
|
||||
DebugLog(intervalRule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"interval timer scheduled every {intervalRule.IntervalSeconds}s (first run after one full interval — no immediate execution)");
|
||||
}
|
||||
|
||||
_logger.Info($"GatherRuleExecutor: {_activeRules.Count(r => r.Trigger == "startup")} startup, " +
|
||||
@@ -187,6 +248,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
|
||||
// phase that failed — that snapshot is the whole point at a failure boundary.
|
||||
List<GatherRule> exitRules = null;
|
||||
var exitTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
var previousPhase = _currentPhase;
|
||||
@@ -204,12 +266,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Scope gate before the dedup add — an out-of-scope pass must not consume
|
||||
// the once-per-(rule, phase) slot. IsRuleInScopeLocked still sees the old phase.
|
||||
if (!IsRuleInScopeLocked(rule)) continue;
|
||||
string scopeReason;
|
||||
if (!IsRuleInScopeLocked(rule, out scopeReason))
|
||||
{
|
||||
if (exitTraces != null)
|
||||
exitTraces.Add(new KeyValuePair<string, string>(rule.RuleId,
|
||||
$"phase_exit of {previousPhaseName} skipped: {scopeReason}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// "exit:" prefix keeps this key space disjoint from the phase_change keys.
|
||||
if (!_phaseRulesExecuted.Add($"exit:{rule.RuleId}|{previousPhaseName}"))
|
||||
{
|
||||
_logger.Debug($"Phase-exit rule {rule.RuleId} already executed for exit of {previousPhaseName}, skipping");
|
||||
if (exitTraces != null)
|
||||
exitTraces.Add(new KeyValuePair<string, string>(rule.RuleId,
|
||||
$"phase_exit already executed for exit of {previousPhaseName} — once per (rule, phase)"));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -219,6 +291,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
}
|
||||
|
||||
if (exitTraces != null)
|
||||
{
|
||||
foreach (var trace in exitTraces)
|
||||
DebugLog(trace.Key, GatherRuleDebugLog.StageScope, trace.Value);
|
||||
}
|
||||
|
||||
if (exitRules != null)
|
||||
{
|
||||
foreach (var rule in exitRules)
|
||||
@@ -252,6 +330,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
var deferredRule = rule;
|
||||
_logger.Info($"Phase {phaseName} activated deferred startup rule {deferredRule.RuleId}");
|
||||
DebugLog(deferredRule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"phase {phaseName} activated deferred startup rule");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(deferredRule));
|
||||
}
|
||||
|
||||
@@ -262,18 +342,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
// Scope gate BEFORE the dedup add — an out-of-scope pass must not consume
|
||||
// the once-per-(rule, phase) execution slot.
|
||||
if (!IsRuleInScope(rule))
|
||||
string scopeReason;
|
||||
if (!IsRuleInScope(rule, out scopeReason))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageScope,
|
||||
$"phase_change to {phaseName} skipped: {scopeReason}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deduplicate: only fire once per (ruleId, phase) combination
|
||||
var deduplicationKey = $"{rule.RuleId}|{phaseName}";
|
||||
if (!_phaseRulesExecuted.Add(deduplicationKey))
|
||||
{
|
||||
_logger.Debug($"Phase rule {rule.RuleId} already executed for phase {phaseName}, skipping");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"phase_change already executed for phase {phaseName} — once per (rule, phase)");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Phase change triggered rule {rule.RuleId} (phase: {phaseName})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"phase_change to {phaseName} fired");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
}
|
||||
}
|
||||
@@ -288,10 +376,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
if (string.Equals(rule.TriggerEventType, eventType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!IsRuleInScope(rule))
|
||||
string scopeReason;
|
||||
if (!IsRuleInScope(rule, out scopeReason))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageScope,
|
||||
$"on_event {eventType} skipped: {scopeReason}");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"on_event {eventType} fired");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
}
|
||||
}
|
||||
@@ -302,6 +396,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
try
|
||||
{
|
||||
_logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageExec, $"executing: collector={rule.CollectorType}, target={rule.Target}");
|
||||
|
||||
var collectorType = rule.CollectorType?.ToLower();
|
||||
|
||||
@@ -309,6 +404,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
if (collectorType == null || !_collectors.TryGetValue(collectorType, out collector))
|
||||
{
|
||||
_logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"unknown collector type '{rule.CollectorType}' — nothing emitted");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -316,7 +412,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// LogParser (and potentially others) emit events directly and return null
|
||||
if (result == null)
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
|
||||
collectorType == "logparser"
|
||||
? "logparser emits per-match events directly — see logparser lines above for the per-file outcome"
|
||||
: "collector returned null — nothing emitted");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Count == 0)
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
|
||||
"collector returned EMPTY result — nothing emitted (typical causes: target not found with emitOnlyIfExists, or guard block — see guard line if present)");
|
||||
}
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
@@ -325,8 +433,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// empty result never reaches this point, so an emitOnlyIfExists miss neither
|
||||
// emits nor updates the hash — composing to "one event on appearance, then
|
||||
// only on change".
|
||||
if (IsOnChangeMode(rule) && !ShouldEmitOnChange(rule, result))
|
||||
return;
|
||||
if (IsOnChangeMode(rule))
|
||||
{
|
||||
string hashPrefix;
|
||||
int suppressedStreak;
|
||||
if (!ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageSuppress,
|
||||
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}");
|
||||
return;
|
||||
}
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
$"result changed (hash {hashPrefix})" + (suppressedStreak > 0 ? $" after {suppressedStreak} suppressed polls" : ""));
|
||||
}
|
||||
|
||||
result["ruleId"] = rule.RuleId;
|
||||
result["ruleTitle"] = rule.Title;
|
||||
@@ -357,11 +476,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = result
|
||||
});
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
$"emitted {eventType} (severity={severity}, {result.Count} fields)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Gather rule {rule.RuleId} failed: {ex.Message}");
|
||||
// Full exception incl. stack trace only in the debug trace — the agent log stays terse.
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"rule execution failed: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,15 +498,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Internal so the scope matrix is testable synchronously (no timer/ThreadPool races).
|
||||
internal bool IsRuleInScope(GatherRule rule)
|
||||
{
|
||||
string reason;
|
||||
return IsRuleInScope(rule, out reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope gate with a human-readable skip reason for the debug trace. The reason is
|
||||
/// captured under the lock but must be WRITTEN outside it (never trace under _scopeLock).
|
||||
/// </summary>
|
||||
internal bool IsRuleInScope(GatherRule rule, out string reason)
|
||||
{
|
||||
lock (_scopeLock)
|
||||
{
|
||||
return IsRuleInScopeLocked(rule);
|
||||
return IsRuleInScopeLocked(rule, out reason);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsRuleInScopeLocked(GatherRule rule)
|
||||
{
|
||||
string reason;
|
||||
return IsRuleInScopeLocked(rule, out reason);
|
||||
}
|
||||
|
||||
private bool IsRuleInScopeLocked(GatherRule rule, out string reason)
|
||||
{
|
||||
reason = null;
|
||||
|
||||
if (!HasPhaseScope(rule))
|
||||
return true; // unscoped — legacy behavior
|
||||
|
||||
@@ -400,7 +541,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// No phase signal yet (or scope tokens that never match, e.g. "Unknown"):
|
||||
// scoped rules stay inactive.
|
||||
if (_currentPhase == EnrollmentPhase.Unknown)
|
||||
{
|
||||
reason = "currentPhase=Unknown — scoped rules stay inactive until the first phase signal";
|
||||
return false;
|
||||
}
|
||||
|
||||
var phaseName = _currentPhase.ToString();
|
||||
foreach (var phase in rule.ActivePhases)
|
||||
@@ -408,10 +552,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
if (string.Equals(phase, phaseName, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
reason = $"out of scope (currentPhase={phaseName}, activePhases=[{string.Join(",", rule.ActivePhases)}])";
|
||||
return false;
|
||||
}
|
||||
|
||||
return _fromPhaseLatched.Contains(rule.RuleId);
|
||||
if (_fromPhaseLatched.Contains(rule.RuleId))
|
||||
return true;
|
||||
|
||||
reason = $"activeFromPhase={rule.ActiveFromPhase} not latched yet (currentPhase={_currentPhase})";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -457,8 +606,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
/// Internal so the state machine is testable synchronously (no timer/ThreadPool races).
|
||||
/// </summary>
|
||||
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result)
|
||||
{
|
||||
string hashPrefix;
|
||||
int suppressedStreak;
|
||||
return ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overload exposing the hash prefix and suppression streak for the debug trace:
|
||||
/// on suppress, <paramref name="suppressedStreak"/> is the updated streak count;
|
||||
/// on emit, it is the streak that just ended (0 when none).
|
||||
/// </summary>
|
||||
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
|
||||
out string hashPrefix, out int suppressedStreak)
|
||||
{
|
||||
var hash = ComputeCanonicalHash(result);
|
||||
hashPrefix = hash.Length > 8 ? hash.Substring(0, 8) : hash;
|
||||
suppressedStreak = 0;
|
||||
bool emit;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
@@ -469,6 +633,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
_suppressed[rule.RuleId] = _suppressed.TryGetValue(rule.RuleId, out streak)
|
||||
? (streak.Count + 1, streak.SinceUtc)
|
||||
: (1, DateTime.UtcNow);
|
||||
suppressedStreak = _suppressed[rule.RuleId].Count;
|
||||
emit = false;
|
||||
}
|
||||
else
|
||||
@@ -479,6 +644,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
result["suppressedPolls"] = streak.Count;
|
||||
result["suppressedSinceUtc"] = streak.SinceUtc.ToString("o", CultureInfo.InvariantCulture);
|
||||
suppressedStreak = streak.Count;
|
||||
}
|
||||
_suppressed.Remove(rule.RuleId);
|
||||
emit = true;
|
||||
|
||||
+9
-5
@@ -77,11 +77,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
// Full path writes 18 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (4) + network delta (8: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests). cap=18 → HashHelpers.GetPrime(18)=23
|
||||
// buckets → no resize on the 18th key (cap=16 would land at 17 buckets and resize).
|
||||
var data = new Dictionary<string, object>(capacity: 18, StringComparer.Ordinal)
|
||||
// Full path writes 19 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (4) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=19 →
|
||||
// HashHelpers.GetPrime(19)=23 buckets → no resize on the 19th key.
|
||||
var data = new Dictionary<string, object>(capacity: 19, StringComparer.Ordinal)
|
||||
{
|
||||
{ "agent_version", _agentVersion }
|
||||
};
|
||||
@@ -164,6 +164,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
data["net_total_bytes_up"] = currentNet.TotalBytesUp;
|
||||
data["net_total_bytes_down"] = currentNet.TotalBytesDown;
|
||||
data["net_total_requests"] = currentNet.RequestCount;
|
||||
// Cumulative latency sum: total/requests = session-wide average HTTP
|
||||
// round-trip. The backend projects that average onto the Session row
|
||||
// (AvgApiLatencyMs) — cumulative counters make the write idempotent.
|
||||
data["net_total_latency_ms"] = currentNet.TotalLatencyMs;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+14
-57
@@ -8,8 +8,8 @@ using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
@@ -37,7 +37,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
private const int DebounceDelayMs = 3000;
|
||||
private const int ConnectivityCheckDelayMs = 5000;
|
||||
private const int ConnectivityCheckTimeoutMs = 8000;
|
||||
private const int WifiCollectTimeoutMs = 3000;
|
||||
private const string Source = "NetworkChangeDetector";
|
||||
|
||||
/// <summary>
|
||||
@@ -374,7 +373,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// Collect WiFi SSID synchronously (needed for before/after comparison)
|
||||
if (isWifi)
|
||||
{
|
||||
var wifiInfo = CollectWiFiInfo();
|
||||
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
|
||||
var wifiInfo = CollectWiFiInfo(nicGuid);
|
||||
snapshot.WifiSsid = wifiInfo.ssid;
|
||||
snapshot.WifiSignalPercent = wifiInfo.signal;
|
||||
snapshot.WifiRadioType = wifiInfo.radioType;
|
||||
@@ -390,73 +390,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces.
|
||||
/// Synchronous with timeout — needed before emitting the event.
|
||||
/// Collects WiFi SSID, signal, and radio type via the native WLAN API
|
||||
/// (<see cref="WifiInfoReader"/> — language-neutral, no process spawn).
|
||||
/// Synchronous — needed before emitting the event.
|
||||
/// </summary>
|
||||
private (string ssid, int? signal, string radioType) CollectWiFiInfo()
|
||||
private (string ssid, int? signal, string radioType) CollectWiFiInfo(Guid? interfaceGuid)
|
||||
{
|
||||
string ssid = null;
|
||||
int? signal = null;
|
||||
string radioType = null;
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
|
||||
if (wifi == null)
|
||||
{
|
||||
FileName = SystemPaths.Netsh,
|
||||
Arguments = "wlan show interfaces",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string output;
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
output = process.StandardOutput.ReadToEnd();
|
||||
if (!process.WaitForExit(WifiCollectTimeoutMs))
|
||||
{
|
||||
try { process.Kill(); } catch { }
|
||||
_logger.Debug("NetworkChangeDetector: netsh timed out");
|
||||
return (null, null, null);
|
||||
}
|
||||
_logger.Debug($"NetworkChangeDetector: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
|
||||
wifi = NetshWifiFallback.TryRead();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return (null, null, null);
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
|
||||
var key = trimmed.Substring(0, colonIndex).Trim();
|
||||
var value = trimmed.Substring(colonIndex + 1).Trim();
|
||||
|
||||
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
|
||||
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ssid = value;
|
||||
}
|
||||
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value.TrimEnd('%'), out var sig))
|
||||
signal = sig;
|
||||
}
|
||||
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
radioType = value;
|
||||
}
|
||||
}
|
||||
if (wifi != null)
|
||||
return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"NetworkChangeDetector: WiFi info collection failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return (ssid, signal, radioType);
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -36,6 +36,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <item><b>StallProbeHost</b> — <see cref="StallProbeCollector"/> +
|
||||
/// <see cref="StallProbeCollectorAdapter"/>. Owns its 60-s idle-check timer (the
|
||||
/// collector itself has no timer — it's a pure probe invoked from outside).</item>
|
||||
/// <item><b>EspPolicyProviderStallHost</b> — always-on 60-s wall-clock tick for the
|
||||
/// two-arm <c>esp_policy_provider_stalled</c> tripwire (EnrollmentStatusTracking CSP
|
||||
/// provider continuously incomplete ≥ 15 min, OR Setup\Apps providers registered
|
||||
/// without the expected <c>Sidecar</c>/IME provider — the no-timeout ESP wait).</item>
|
||||
/// </list>
|
||||
/// Optional peripheral hosts (driven by <see cref="CollectorConfiguration"/> toggles):
|
||||
/// <list type="bullet">
|
||||
@@ -303,6 +307,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds));
|
||||
}
|
||||
|
||||
// ESP policy-provider stall tripwire — always-on kernel host (no config gate, like
|
||||
// disk_space_low): a provider that never completes, or co-management "ConfigMgr"
|
||||
// registered without the expected "Sidecar"/IME provider (issue #106 — even with
|
||||
// TrackingPoliciesCreated=1 the ESP ignores foreign provider names), parks the user
|
||||
// ESP at "Apps (Identifying)" WITHOUT any OS timeout. Wall-clock dwell, deliberately
|
||||
// outside the StallProbe gate: the idle clock there resets on any session activity,
|
||||
// and the stall must accrue regardless of it.
|
||||
hosts.Add(new EspPolicyProviderStallHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
startupGate: _startupEventGate));
|
||||
|
||||
// Deterministic update corroboration — compares the persisted OS build (CurrentBuild.UBR)
|
||||
// across agent restarts and emits os_build_changed when it differs (session 7443317c:
|
||||
// build jump proved an OOBE quality update the WU channel never showed). Must be added
|
||||
@@ -464,7 +483,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
logger: logger,
|
||||
rules: _remoteConfig.GatherRules,
|
||||
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
|
||||
unrestrictedMode: _agentConfig.UnrestrictedMode));
|
||||
unrestrictedMode: _agentConfig.UnrestrictedMode,
|
||||
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_agentConfig.GatherRuleDebugLogPath))
|
||||
{
|
||||
// Debug trace is on but the backend delivered zero gather rules — the trace
|
||||
// file must say so instead of silently not existing.
|
||||
Monitoring.Telemetry.Gather.GatherRuleDebugLog.WriteStandalone(
|
||||
_agentConfig.GatherRuleDebugLogPath,
|
||||
"no gather rules delivered by backend — nothing to execute", logger);
|
||||
}
|
||||
|
||||
return new CollectorSurfaces(hosts, imeLogHost, espAndHelloHost, aadJoinHost);
|
||||
|
||||
@@ -36,6 +36,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <item><c>CollectAtEnd</c> — once, on <c>FinalizingSetup</c> or desktop arrival
|
||||
/// (whichever comes first; desktop arrival also covers no-ESP / WDP v2). Re-fetches
|
||||
/// BitLocker (commonly enabled via policy DURING enrollment) + the active NIC.</item>
|
||||
/// <item><c>RefreshEspConfiguration</c> — audit Q2, one-shot per trigger: the ESP
|
||||
/// blocking lists in the registry grow progressively (device scope) and the user-scope
|
||||
/// lists appear only after sign-in, so the early emissions are structurally partial.
|
||||
/// Re-collected when the apps sub-phase opens (first app activity in DeviceSetup /
|
||||
/// AccountSetup — the adapter's <c>phase_transition</c> declaration) and when the
|
||||
/// AccountSetup ESP phase itself is detected. Phase-driven one-shots, never a timer
|
||||
/// (no-heartbeat policy).</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Duplicate-event cost is zero: every re-collected event runs through the collector's
|
||||
@@ -46,6 +53,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
public string Name => "DeviceInfoCollector";
|
||||
|
||||
// ESP-config refresh trigger labels (audit Q2) — also the values passed to
|
||||
// DeviceInfoCollector.RefreshEspConfiguration for the agent-log line.
|
||||
internal const string EspConfigTriggerAppsDevice = "apps_phase_device";
|
||||
internal const string EspConfigTriggerAppsUser = "apps_phase_user";
|
||||
internal const string EspConfigTriggerAccountSetup = "account_setup_detected";
|
||||
|
||||
private readonly Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector _collector;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
@@ -58,6 +71,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private int _endCollected;
|
||||
private int _disposed;
|
||||
|
||||
// Audit Q2 — fire-once latches per ESP-config refresh trigger. Guarded by _espConfigLock
|
||||
// instead of three Interlocked ints so the membership test + add stays one atomic step.
|
||||
private readonly object _espConfigLock = new object();
|
||||
private readonly HashSet<string> _espConfigRefreshesFired = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
public DeviceInfoHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
@@ -100,6 +118,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void OnSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
HandleEspConfigRefreshTrigger(kind, payload);
|
||||
|
||||
var startTrigger = IsEnrollmentStartTrigger(kind, payload);
|
||||
var endTrigger = IsEndTrigger(kind, payload);
|
||||
if (!startTrigger && !endTrigger) return;
|
||||
@@ -111,10 +131,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var runEndCollect = endTrigger && Interlocked.Exchange(ref _endCollected, 1) == 0;
|
||||
if (!runStartRefresh && !runEndCollect) return;
|
||||
|
||||
// Both one-shots done → nothing left to observe.
|
||||
if (Volatile.Read(ref _enrollmentStartCollected) == 1 && Volatile.Read(ref _endCollected) == 1)
|
||||
Unsubscribe();
|
||||
|
||||
var trigger = kind == DecisionSignalKind.DesktopArrived ? "desktop_arrived" : "esp_phase_changed";
|
||||
_logger.Info($"DeviceInfoHost: trigger '{trigger}' — scheduling re-collect (enrollmentStart={runStartRefresh}, end={runEndCollect}).");
|
||||
|
||||
@@ -134,6 +150,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Q2 — ESP-config refresh one-shots. Each trigger label fires at most once per
|
||||
/// agent run; the collector's StartupEventGate keeps an unchanged payload silent, so
|
||||
/// the worst case is one silent registry re-read per trigger. The subscription stays
|
||||
/// armed for the host's lifetime (unlike the start/end pair there is no "all triggers
|
||||
/// seen" point that is guaranteed to arrive — SkipUser enrollments never enter
|
||||
/// AccountSetup); the per-signal cost is a few dictionary lookups.
|
||||
/// </summary>
|
||||
private void HandleEspConfigRefreshTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
var trigger = ClassifyEspConfigRefreshTrigger(kind, payload);
|
||||
if (trigger == null) return;
|
||||
|
||||
lock (_espConfigLock)
|
||||
{
|
||||
if (!_espConfigRefreshesFired.Add(trigger)) return;
|
||||
}
|
||||
|
||||
_logger.Info($"DeviceInfoHost: ESP-config refresh trigger '{trigger}' — scheduling re-collect.");
|
||||
Task.Run(() =>
|
||||
{
|
||||
try { _collector.RefreshEspConfiguration(trigger); }
|
||||
catch (Exception ex) { _logger.Warning($"DeviceInfoHost: ESP-config refresh threw: {ex.Message}"); }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>First DeviceSetup phase signal — MDM enrollment has populated the registry surface.</summary>
|
||||
internal static bool IsEnrollmentStartTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
@@ -156,6 +198,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
&& string.Equals(phase, nameof(EnrollmentPhase.FinalizingSetup), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Q2 — maps a posted signal to an ESP-config refresh trigger label, or
|
||||
/// <c>null</c> when the signal is not one. Two trigger families:
|
||||
/// <list type="bullet">
|
||||
/// <item>The adapter's apps sub-phase declaration (<c>phase_transition</c>
|
||||
/// InformationalEvent with phase AppsDevice/AppsUser — fired on the first app
|
||||
/// activity in an ESP phase): by then IME is actively processing the blocking set,
|
||||
/// so the device-scope ESPTrackingInfo lists are as complete as they get.</item>
|
||||
/// <item>The AccountSetup ESP phase (UserSetup detection): sign-in has happened, so
|
||||
/// the user-scope <c>S-<SID></c> lists exist now — production showed 88 of 89
|
||||
/// sessions with an EMPTY user list at the last pre-fix emission.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static string? ClassifyEspConfigRefreshTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
if (payload == null) return null;
|
||||
|
||||
if (kind == DecisionSignalKind.InformationalEvent
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|
||||
&& string.Equals(eventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition, StringComparison.Ordinal)
|
||||
&& payload.TryGetValue(Telemetry.Events.EventTimelineEmitter.PhaseParamKey, out var declaredPhase))
|
||||
{
|
||||
if (string.Equals(declaredPhase, nameof(EnrollmentPhase.AppsDevice), StringComparison.Ordinal))
|
||||
return EspConfigTriggerAppsDevice;
|
||||
if (string.Equals(declaredPhase, nameof(EnrollmentPhase.AppsUser), StringComparison.Ordinal))
|
||||
return EspConfigTriggerAppsUser;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kind == DecisionSignalKind.EspPhaseChanged
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EspPhase, out var espPhase)
|
||||
&& string.Equals(espPhase, nameof(EnrollmentPhase.AccountSetup), StringComparison.Ordinal))
|
||||
{
|
||||
return EspConfigTriggerAccountSetup;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Stop() => Unsubscribe();
|
||||
|
||||
public void Dispose()
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives the <see cref="EspPolicyProviderStallDetector"/> from a 60-s wall-clock tick.
|
||||
/// Deliberately NOT idle-coupled (a policy-provider stall coexists with a chatty session —
|
||||
/// <see cref="StallProbeHost"/>'s idle clock would keep resetting) and NOT idle-stopped
|
||||
/// (<see cref="PeriodicCollectorLifecycleHost"/> would sleep through exactly the dormant
|
||||
/// window the dwell must observe). Always-on kernel host, no config gate — precedent:
|
||||
/// the <c>disk_space_low</c> tripwire.
|
||||
/// <para>
|
||||
/// First tick fires after 60 s, not at Start: the EnrollmentStatusTracking key legitimately
|
||||
/// doesn't exist at agent start, and nothing can be 15 min stale at t=0.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class EspPolicyProviderStallHost : ICollectorHost
|
||||
{
|
||||
public string Name => "EspPolicyProviderStallDetector";
|
||||
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly EspPolicyProviderStallDetector _detector;
|
||||
private readonly AgentLogger _logger;
|
||||
private Timer? _tickTimer;
|
||||
private int _disposed;
|
||||
|
||||
public EspPolicyProviderStallHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
StartupEventGate? startupGate)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger;
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
_detector = new EspPolicyProviderStallDetector(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: post,
|
||||
logger: logger,
|
||||
clock: clock,
|
||||
startupGate: startupGate);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_tickTimer = new Timer(
|
||||
_ => SafeTick(),
|
||||
state: null,
|
||||
dueTime: PollInterval,
|
||||
period: PollInterval);
|
||||
_logger.Info($"EspPolicyProviderStallHost: started (tick every {PollInterval.TotalSeconds}s).");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try
|
||||
{
|
||||
_tickTimer?.Dispose();
|
||||
_tickTimer = null;
|
||||
}
|
||||
catch (Exception ex) { _logger.Warning($"EspPolicyProviderStallHost: timer dispose failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
Stop();
|
||||
}
|
||||
|
||||
private void SafeTick()
|
||||
{
|
||||
try
|
||||
{
|
||||
_detector.Tick();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("EspPolicyProviderStallHost: tick failed.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -39,7 +39,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
AgentLogger logger,
|
||||
List<GatherRule> rules,
|
||||
string? imeLogPathOverride,
|
||||
bool unrestrictedMode = false)
|
||||
bool unrestrictedMode = false,
|
||||
string? gatherDebugLogPath = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -55,9 +56,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// session mode we wrap post.Emit so every session-mode gather event still
|
||||
// flows through the InformationalEvent ingress pipe before hitting the
|
||||
// telemetry spool — Rail-A semantics for ordering / replay determinism.
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
|
||||
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride);
|
||||
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
logger: logger,
|
||||
matchLogPath: expandedMatchLogPath,
|
||||
stateDirectory: stateDirectory);
|
||||
// Historic-replay guard runs against the agent clock, not the raw system clock —
|
||||
// keeps the tracker's staleness verdicts consistent with the adapter's clamp.
|
||||
_tracker.UtcNowProvider = () => clock.UtcNow;
|
||||
|
||||
if (simulationMode)
|
||||
{
|
||||
|
||||
@@ -93,7 +93,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
using (obj)
|
||||
{
|
||||
manufacturer = obj["Manufacturer"]?.ToString() ?? Unknown;
|
||||
manufacturer = NormalizeWmiValue(obj["Manufacturer"]?.ToString());
|
||||
if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
using (var lenovoSearcher = new ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct"))
|
||||
@@ -103,7 +103,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
using (lenovoObj)
|
||||
{
|
||||
model = lenovoObj["Version"]?.ToString() ?? Unknown;
|
||||
model = NormalizeWmiValue(lenovoObj["Version"]?.ToString());
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -111,7 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
else
|
||||
{
|
||||
model = obj["Model"]?.ToString() ?? Unknown;
|
||||
model = NormalizeWmiValue(obj["Model"]?.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
using (obj)
|
||||
{
|
||||
serialNumber = obj["SerialNumber"]?.ToString() ?? Unknown;
|
||||
serialNumber = NormalizeWmiValue(obj["SerialNumber"]?.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,5 +154,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
private static bool IsResolved(string value)
|
||||
=> !string.IsNullOrWhiteSpace(value)
|
||||
&& !string.Equals(value, Unknown, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Trims a raw WMI string value at the source (audit Q5: BIOS serials are used as device
|
||||
/// identity keys downstream — F2 device history joins on them — so padding must never
|
||||
/// leave the agent). Whitespace-only and empty values collapse to the "Unknown" sentinel,
|
||||
/// matching the null fallback so the retry logic treats them as unresolved.
|
||||
/// </summary>
|
||||
internal static string NormalizeWmiValue(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return Unknown;
|
||||
return raw.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
@@ -267,6 +268,14 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
payload["failedSubcategory"] = failedSubcategory!;
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
payload["category"] = category!;
|
||||
// Session 4910a5a5: culprit-app names ride the signal so the DecisionEngine's
|
||||
// esp_failure_advisory / enrollment_failed events can name them directly.
|
||||
var culprits = args?.LikelyCulpritApps;
|
||||
if (culprits != null && culprits.Count > 0)
|
||||
{
|
||||
payload["likelyCulpritApps"] = string.Join(", ", culprits);
|
||||
payload["likelyCulpritAppCount"] = culprits.Count.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
_ingress.Post(
|
||||
kind: DecisionSignalKind.EspTerminalFailure,
|
||||
|
||||
@@ -97,12 +97,24 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
// bootstrap marker line. Lets MCP report "which device runs which bootstrap version".
|
||||
private bool _bootstrapDetectedPosted;
|
||||
|
||||
// One-shot: historic script replay detected (a previous enrollment's IME log content
|
||||
// surviving on disk) — its script events are suppressed, and a single
|
||||
// historic_ime_replay_detected Info event explains the gap in the timeline.
|
||||
private bool _historicReplayNoted;
|
||||
private int _suppressedHistoricReplayEvents; // forensic count for the log line
|
||||
|
||||
// A platform script whose observed run duration reaches this is treated as having hit the
|
||||
// IME script-execution timeout (default ~30 min). 25 min leaves headroom below 30 so clock
|
||||
// jitter / the agent's late replay window can't miss it, while staying well above any
|
||||
// legitimately long install wrapper that completes on its own.
|
||||
private static readonly TimeSpan ScriptTimeoutSuspectedThreshold = TimeSpan.FromMinutes(25);
|
||||
|
||||
// Source log lines older than this relative to the clock are pathological — shared by
|
||||
// ResolveOccurredAt's staleness clamp, the historic-replay suppression, the duration
|
||||
// plausibility backstop AND the tracker's app-mutation guard (single source of truth
|
||||
// in ImeLogTracker so the layers can never drift apart).
|
||||
private static readonly TimeSpan StaleSourceThreshold = ImeLogTracker.HistoricReplayThreshold;
|
||||
|
||||
// Matches the deterministic marker line the bootstrap script writes via Write-Log,
|
||||
// e.g. "Bootstrap script version: v2.0" — see scripts/Bootstrap/Install-AutopilotMonitor.ps1.
|
||||
// Same shape as the web-side regex in utils/bootstrapVersion.ts; kept in sync intentionally.
|
||||
@@ -120,7 +132,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
|
||||
// Plan §5 Fix 4c — per-app install-lifecycle timing captured from state transitions.
|
||||
// StartedAtUtc on first Downloading/Installing/InProgress transition (set-once);
|
||||
// CompletedAtUtc on first terminal transition (Installed/Skipped/Postponed/Error).
|
||||
// CompletedAtUtc on terminal transition (Installed/Skipped/Postponed/Error), re-armed
|
||||
// to null on a terminal→active transition (IME retry, audit Q1) so the final terminal
|
||||
// stamp reflects the last outcome instead of freezing at the first one.
|
||||
// Surfaced via <see cref="AppTimings"/> to peripheral consumers (FinalStatusBuilder,
|
||||
// app_tracking_summary emission in EnrollmentTerminationHandler).
|
||||
private readonly Dictionary<string, (DateTime? Started, DateTime? Completed)> _appTimings =
|
||||
@@ -289,17 +303,11 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
rawSourceTimestamp = sourceTsOverride ?? _tracker.LastMatchedLogTimestamp;
|
||||
if (rawSourceTimestamp.HasValue)
|
||||
{
|
||||
// CMTrace log timestamps from IME are emitted in UTC, but DateTimeKind may
|
||||
// arrive as Unspecified depending on the parser path. Normalize via
|
||||
// SpecifyKind=Utc rather than ToUniversalTime() to avoid double-conversion
|
||||
// when Kind is already UTC.
|
||||
var asUtc = rawSourceTimestamp.Value.Kind == DateTimeKind.Utc
|
||||
? rawSourceTimestamp.Value
|
||||
: DateTime.SpecifyKind(rawSourceTimestamp.Value, DateTimeKind.Utc);
|
||||
var asUtc = NormalizeUtc(rawSourceTimestamp.Value);
|
||||
|
||||
var clockNow = _clock.UtcNow;
|
||||
var ageHours = (clockNow - asUtc).TotalHours;
|
||||
if (ageHours > 24.0 || ageHours < -1.0)
|
||||
if (ageHours > StaleSourceThreshold.TotalHours || ageHours < -1.0)
|
||||
{
|
||||
// Pathologically stale (>24h old) or in the future (>1h skew) — log,
|
||||
// fall back, and let the caller flag the event.
|
||||
@@ -325,6 +333,67 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
data["rejectedSourceTimestamp"] = rawSourceTs.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// CMTrace Kind normalization — shared implementation lives on the tracker (used by its
|
||||
// historic-replay guard too).
|
||||
private static DateTime NormalizeUtc(DateTime value) => ImeLogTracker.NormalizeUtc(value);
|
||||
|
||||
/// <summary>
|
||||
/// True when an event's source log line is a historic replay: the source timestamp
|
||||
/// was rejected as stale (> <see cref="StaleSourceThreshold"/> in the PAST). Those lines
|
||||
/// are script runs and app installs from a previous enrollment whose IME log survived on
|
||||
/// disk (session eaf3d8c4: 156 replayed script completions + 147 replayed app events
|
||||
/// shown as current activity, immediate-upload flood → ingress_backpressure). Future-skew
|
||||
/// rejections (negative age — mid-enrollment clock jump) and clock-only fallbacks (no
|
||||
/// source timestamp at all) are NOT replay and must still emit. Emits the one-shot
|
||||
/// <c>historic_ime_replay_detected</c> summary on the first suppression; replay is
|
||||
/// chronological, so that first suppressed line carries the earliest rejected source
|
||||
/// timestamp. Bypassed in <see cref="ImeLogTracker.SimulationMode"/> — the
|
||||
/// --replay-log-dir dev tool feeds historic lines on purpose.
|
||||
/// </summary>
|
||||
private bool SuppressIfHistoricReplay(
|
||||
bool derivedFromClock, DateTime? rawSourceTs, string eventLabel, string entityId)
|
||||
{
|
||||
if (_tracker.SimulationMode) return false;
|
||||
if (!derivedFromClock || !rawSourceTs.HasValue) return false;
|
||||
var asUtc = NormalizeUtc(rawSourceTs.Value);
|
||||
var clockNow = _clock.UtcNow;
|
||||
var age = clockNow - asUtc;
|
||||
if (age <= StaleSourceThreshold) return false; // future-skew or fresh → emit
|
||||
|
||||
bool emitSummary;
|
||||
int suppressedSoFar;
|
||||
lock (_lock)
|
||||
{
|
||||
suppressedSoFar = ++_suppressedHistoricReplayEvents;
|
||||
emitSummary = !_historicReplayNoted;
|
||||
_historicReplayNoted = true;
|
||||
}
|
||||
|
||||
if (emitSummary)
|
||||
{
|
||||
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||||
// Standard batch upload on purpose — no immediateUpload; the whole point of the
|
||||
// suppression is to stop the replayed batch from flooding the ingest.
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.HistoricImeReplayDetected,
|
||||
source: SourceLabel,
|
||||
message: "Historic IME activity from a previous enrollment detected in the log "
|
||||
+ $"(earliest source timestamp {asUtc:o}) — its script and app events are suppressed for this session.",
|
||||
severity: EventSeverity.Info,
|
||||
data: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["decision"] = "historic_ime_replay_suppressed",
|
||||
["earliestRejectedSourceTimestamp"] = asUtc.ToString("o", culture),
|
||||
["staleThresholdHours"] = StaleSourceThreshold.TotalHours.ToString("F0", culture),
|
||||
});
|
||||
}
|
||||
|
||||
_logger?.Debug(
|
||||
$"ImeAdapter: suppressed historic {eventLabel} for {entityId} "
|
||||
+ $"(source ts {asUtc:o}, age {age.TotalHours:F1}h, suppressed so far: {suppressedSoFar})");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void EmitEspPhase(string phase)
|
||||
{
|
||||
if (string.IsNullOrEmpty(phase)) return;
|
||||
@@ -504,6 +573,14 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
|
||||
// Historic replay (a previous enrollment's log) — do not emit at all. Placed BEFORE
|
||||
// the sub-phase declaration, the timing bookkeeping and the terminal-signal dedup so
|
||||
// a replayed app neither consumes the fire-once flags nor poisons _appTimings (which
|
||||
// feeds FinalStatusBuilder) nor posts a phantom DecisionSignal. The early return also
|
||||
// covers the shadow download_progress and the app_tracking_summary snapshot below.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, $"app-state {oldState}->{newState}", app.Id))
|
||||
return;
|
||||
|
||||
// Plan §5 Fix 2: first app activity in a given ESP phase opens the sub-phase on
|
||||
// the UI timeline via a `phase_transition` declaration event. Fire-once per ESP
|
||||
// phase; emission happens BEFORE the actual app_* event so the UI sees the phase
|
||||
@@ -616,10 +693,20 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan §5 Fix 4c — set-once per-app install-lifecycle timing. Called for every
|
||||
/// state transition, no-op for duplicate stamps. Returns the up-to-date
|
||||
/// Plan §5 Fix 4c — per-app install-lifecycle timing. Called for every state
|
||||
/// transition, no-op for duplicate stamps. Returns the up-to-date
|
||||
/// <see cref="AppInstallTiming"/> so the emitter can inline the values on the
|
||||
/// current event's payload without a second dictionary lookup.
|
||||
/// <para>
|
||||
/// Audit Q1: a terminal→active transition (the IME retry path — <see
|
||||
/// cref="AppPackageState.UpdateState"/> permits e.g. Error→Downloading and
|
||||
/// Skipped→Downloading) re-arms <c>Completed</c> to <c>null</c>, so the NEXT terminal
|
||||
/// transition stamps a fresh CompletedAt. StartedAt keeps its first value — the final
|
||||
/// terminal payload then covers first-start → last-terminal (total occupancy across
|
||||
/// attempts) instead of freezing at the first failure. Observed pre-fix: a user-scope
|
||||
/// app Skipped early in DeviceSetup, retried after sign-in, emitted its final
|
||||
/// Installed event with the stale early CompletedAt (durationSeconds=-2342).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private AppInstallTiming UpdateAppTiming(string appId, AppInstallationState newState, DateTime now)
|
||||
{
|
||||
@@ -627,13 +714,20 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
{
|
||||
_appTimings.TryGetValue(appId, out var current);
|
||||
|
||||
// Terminal→active (retry): the earlier terminal stamp no longer describes this
|
||||
// app's outcome — re-arm so the next terminal transition stamps fresh.
|
||||
if (current.Completed != null && IsLifecycleActive(newState))
|
||||
{
|
||||
current.Completed = null;
|
||||
}
|
||||
|
||||
// First lifecycle-active transition → record StartedAt.
|
||||
if (current.Started == null && IsLifecycleActive(newState))
|
||||
{
|
||||
current.Started = now;
|
||||
}
|
||||
|
||||
// First terminal transition → record CompletedAt.
|
||||
// Terminal transition → record CompletedAt (fresh after a re-arm).
|
||||
if (current.Completed == null && IsCompletedState(newState))
|
||||
{
|
||||
current.Completed = now;
|
||||
@@ -819,6 +913,11 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
|
||||
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
|
||||
// Historic replay — a week-old DO summary is not this enrollment's download.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
|
||||
return;
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
var label = string.IsNullOrEmpty(app.Name) ? app.Id : app.Name;
|
||||
@@ -910,13 +1009,27 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
now = ResolveOccurredAt(script.ExitObservedAtUtc, out derivedFromClock, out rawSourceTs);
|
||||
else
|
||||
now = ResolveOccurredAt(out derivedFromClock, out rawSourceTs);
|
||||
|
||||
// Historic replay (a previous enrollment's log) — do not emit at all. The early
|
||||
// return also skips MaybeEmitBootstrapDetected / MaybeEmitScriptTimeoutSuspected,
|
||||
// which would otherwise fire on week-old evidence.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, eventType, script.PolicyId))
|
||||
return;
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
// Per-script run duration (start line → this completion, `now`). Surfaced on every
|
||||
// Per-script run duration (start line → this completion). Surfaced on every
|
||||
// script completion so the UI can flag slow / inefficient scripts, not just timed-out
|
||||
// ones. Omitted when the start line was never observed (StartedAtUtc null); clamped at 0
|
||||
// against clock skew between the (possibly clock-derived) completion stamp and start.
|
||||
//
|
||||
// When the completion timestamp was clock-derived because the source line's timestamp
|
||||
// was rejected (future-skew CMTrace clock jump — the stale case never reaches here),
|
||||
// `now` lives on the clock's timeline while StartedAtUtc was captured RAW from the
|
||||
// source log. Mixing the two produced absurd durations (session eaf3d8c4: 170 h), so
|
||||
// the duration uses the raw source pair — both ends on the same timeline, the
|
||||
// difference is the true runtime. The event stamp stays `now` (timeline position).
|
||||
//
|
||||
// L19 (delta review 2026-07-02): the same field name carries two different semantics —
|
||||
// start→own-end-signal (≈ script runtime) vs HS-SCRIPT-START→HS-NEW-RESULT (the whole
|
||||
// cycle INCLUDING IME's 30 s–3 min batched reporting latency, i.e. systematically
|
||||
@@ -924,10 +1037,16 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
// consumers never compare the two as equals. The emitting handler stamps the basis on
|
||||
// the state (health-script early-signal completions are script_runtime too); the
|
||||
// scriptType fallback covers states from paths that predate the field.
|
||||
var completionForDuration = derivedFromClock && rawSourceTs.HasValue
|
||||
? NormalizeUtc(rawSourceTs.Value)
|
||||
: now;
|
||||
if (script.StartedAtUtc.HasValue)
|
||||
{
|
||||
var durationSeconds = (now - script.StartedAtUtc.Value).TotalSeconds;
|
||||
if (durationSeconds >= 0)
|
||||
var durationSeconds = (completionForDuration - script.StartedAtUtc.Value).TotalSeconds;
|
||||
// Plausibility backstop: IME's script execution timeout is ~30 min; anything above
|
||||
// 24 h is a cross-run artifact (e.g. a stale pending start slot from a previous
|
||||
// enrollment paired with a fresh completion) — omit rather than lie.
|
||||
if (durationSeconds >= 0 && durationSeconds <= StaleSourceThreshold.TotalSeconds)
|
||||
{
|
||||
data["durationSeconds"] = durationSeconds.ToString("F2", culture);
|
||||
data["durationBasis"] = script.DurationBasis
|
||||
@@ -979,7 +1098,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
$"ImeAdapter: script completed policyId={shortId} type={script.ScriptType ?? "?"} result={script.Result ?? "?"} exit={(script.ExitCode.HasValue ? script.ExitCode.Value.ToString() : "n/a")}");
|
||||
|
||||
MaybeEmitBootstrapDetected(script, now);
|
||||
MaybeEmitScriptTimeoutSuspected(script, now);
|
||||
MaybeEmitScriptTimeoutSuspected(script, now, completionForDuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -991,15 +1110,19 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
/// late start / pre-failed ESP. Advisory only: no state mutation, the underlying
|
||||
/// <c>script_failed</c> is still emitted separately. Health (remediation) scripts are
|
||||
/// excluded — they re-run on their own cadence and have a different timeout regime.
|
||||
/// <paramref name="durationEndUtc"/> is the timeline-consistent completion for duration
|
||||
/// math (raw source ts on rejected-timestamp emissions); <paramref name="occurredAtUtc"/>
|
||||
/// stamps the emitted event.
|
||||
/// </summary>
|
||||
private void MaybeEmitScriptTimeoutSuspected(ScriptExecutionState script, DateTime completionUtc)
|
||||
private void MaybeEmitScriptTimeoutSuspected(ScriptExecutionState script, DateTime occurredAtUtc, DateTime durationEndUtc)
|
||||
{
|
||||
if (IsRemediation(script.ScriptType)) return; // platform scripts only
|
||||
if (!script.StartedAtUtc.HasValue) return; // no start ⇒ no duration
|
||||
if (!string.Equals(script.Result, "Failed", StringComparison.OrdinalIgnoreCase)) return;
|
||||
|
||||
var duration = completionUtc - script.StartedAtUtc.Value;
|
||||
var duration = durationEndUtc - script.StartedAtUtc.Value;
|
||||
if (duration < ScriptTimeoutSuspectedThreshold) return;
|
||||
if (duration > StaleSourceThreshold) return; // implausible — cross-run artifact, not a hung script
|
||||
|
||||
if (!_tracker.TryClaimScriptTimeoutSuspected(script.PolicyId)) return; // restart-safe dedup per policyId (persisted tracker state)
|
||||
|
||||
@@ -1026,7 +1149,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
message: $"Platform script {shortId} ran {durationMinutes:F0} min and was marked Failed — suspected IME script timeout blocking the enrollment.",
|
||||
severity: EventSeverity.Warning,
|
||||
data: data,
|
||||
occurredAtUtc: completionUtc);
|
||||
occurredAtUtc: occurredAtUtc);
|
||||
|
||||
_logger?.Info(
|
||||
$"ImeAdapter: script_timeout_suspected policyId={shortId} duration={durationMinutes:F1}min result={script.Result} espPhase={espPhase ?? "(none)"}");
|
||||
@@ -1081,6 +1204,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
|
||||
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
|
||||
// Historic replay — suppressing here also kills the immediateUpload flood the
|
||||
// replayed batch caused (session eaf3d8c4: 3× ingress_backpressure).
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.ScriptStarted, info.PolicyId))
|
||||
return;
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
var label = IsRemediation(info.ScriptType) ? "Health script" : "Platform script";
|
||||
|
||||
+45
-12
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Runtime;
|
||||
@@ -38,8 +39,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
/// <see cref="SessionStage.WhiteGloveSealed"/> (Part-1 exit, session resumes Part 2).</item>
|
||||
/// <item>WhiteGlove Part-1 path: stop peripheral collectors first (so their stop-time
|
||||
/// stragglers — e.g. <c>network_bandwidth_estimate</c> — sequence BEFORE the marker),
|
||||
/// then emit <c>whiteglove_part1_complete</c>, drain spool, and write
|
||||
/// <c>whiteglove.complete</c> marker via <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
|
||||
/// upload the diagnostics package as a pre-provisioning intermediate (suffix
|
||||
/// <c>preprov</c>, same mode gating as the terminal path — the seal counts as a
|
||||
/// success, so <c>OnFailure</c> skips), then emit <c>whiteglove_part1_complete</c>,
|
||||
/// drain spool, and write <c>whiteglove.complete</c> marker via
|
||||
/// <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
|
||||
/// so Part-2 resume is detected on the next boot.</item>
|
||||
/// <item>Signal the caller-owned shutdown <see cref="ManualResetEventSlim"/>.</item>
|
||||
/// </list>
|
||||
@@ -93,6 +97,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// correction between agent start and termination no longer skews bootToStartSeconds.
|
||||
private readonly ulong _bootAnchorTickMs;
|
||||
private readonly DateTime _bootAnchorUtc;
|
||||
// WinRT OOBE state at construction (handler is built at agent start) — corroborates
|
||||
// the late-start heuristic: "completed" here means OOBE was already over on arrival.
|
||||
private readonly string _oobeStateAtAgentStart;
|
||||
private int _handled;
|
||||
|
||||
public EnrollmentTerminationHandler(
|
||||
@@ -129,6 +136,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
|
||||
_bootAnchorTickMs = ObservationCoverage.CurrentTickMs();
|
||||
_bootAnchorUtc = DateTime.UtcNow;
|
||||
_oobeStateAtAgentStart = OobeStateReader.Read();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -236,6 +244,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// the full Stop() later is a no-op for the hosts.
|
||||
StopPeripheralCollectorsBestEffort();
|
||||
|
||||
// V1 parity (restored 2026-07-28) — upload the diagnostics package as a
|
||||
// pre-provisioning intermediate. Between reseal and user OOBE the device
|
||||
// may sit boxed for weeks, and if Part 2 never uploads (never runs, or
|
||||
// OnFailure + success) the Part-1 forensics would be lost to self-destruct.
|
||||
// No dedicated flag: the existing mode semantics apply — Always uploads,
|
||||
// OnFailure skips (the seal is a success; a Part-1 failure terminates via
|
||||
// the regular Failed path below, which uploads), Off does nothing. Must run
|
||||
// BEFORE the whiteglove_part1_complete emit so diagnostics_* events keep a
|
||||
// lower sequence than the Part-1/Part-2 boundary marker. Best-effort: a
|
||||
// reseal-reboot racing the upload just loses the intermediate package.
|
||||
RunUploadDiagnosticsWithEvents(args, suffixOverride: "preprov");
|
||||
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -618,12 +638,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits a one-shot <c>agent_late_start</c> Warning when the agent had low observation
|
||||
/// Emits a one-shot <c>agent_late_start</c> event when the agent had low observation
|
||||
/// coverage: it started a long time after device boot AND lived only briefly before this
|
||||
/// terminal outcome (gate in <see cref="ObservationCoverage"/>). Such a session looks like a
|
||||
/// normal multi-minute failure in the list (StartedAt is back-dated to the earliest replayed
|
||||
/// IME-log event) but the agent actually observed only the end-state — so the operator should
|
||||
/// read the diagnosis as a post-mortem reconstruction, not live evidence. No state mutation.
|
||||
/// IME-log event) but the agent actually observed only the end-state. Severity and phrasing
|
||||
/// are outcome-calibrated via <see cref="ObservationCoverage.DescribeLateStart"/>: a failure
|
||||
/// stays a Warning with the post-mortem/hung-script note, a success is an Info about
|
||||
/// tail-only coverage (fleet 2026-07-28: tenant 659c3a90 collected 12 Warnings on healthy
|
||||
/// enrollments whose bootstrap merely ran late). No state mutation.
|
||||
/// </summary>
|
||||
private void MaybeEmitAgentLateStart(EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
@@ -637,29 +660,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
out var bootToStartSeconds, out var uptimeSeconds))
|
||||
return;
|
||||
|
||||
ObservationCoverage.DescribeLateStart(
|
||||
args.Outcome, _oobeStateAtAgentStart, bootToStartSeconds, uptimeSeconds,
|
||||
out var severity, out var message, out var note);
|
||||
|
||||
_logger.Info(
|
||||
$"EnrollmentTerminationHandler: low observation coverage — agent started " +
|
||||
$"{bootToStartSeconds / 60.0:F1} min after boot and lived {uptimeSeconds:F0}s; " +
|
||||
$"emitting agent_late_start.");
|
||||
$"emitting agent_late_start ({severity}, outcome {args.Outcome}).");
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
EventType = Constants.EventTypes.AgentLateStart,
|
||||
Severity = EventSeverity.Warning,
|
||||
Severity = severity,
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Agent started {bootToStartSeconds / 60.0:F0} min after boot and observed only " +
|
||||
$"{uptimeSeconds:F0}s before terminating ({args.Outcome}) — low coverage of the enrollment window.",
|
||||
Message = message,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "bootToAgentStartSeconds", Math.Round(bootToStartSeconds, 1) },
|
||||
{ "agentUptimeSeconds", Math.Round(uptimeSeconds, 1) },
|
||||
{ "deviceBootUtc", deviceBootUtc.ToString("o") },
|
||||
{ "agentStartUtc", _agentStartTimeUtc.ToString("o") },
|
||||
{ "oobeStateAtAgentStart", _oobeStateAtAgentStart },
|
||||
{ "outcome", args.Outcome.ToString() },
|
||||
{ "note", "The agent's bootstrap (an Intune platform script) ran only near the end of the enrollment, so the agent observed the already-decided end-state rather than the failure window. Treat the diagnosis as a post-mortem; check for a platform/remediation script that hung ahead of the bootstrap (see script_timeout_suspected)." },
|
||||
{ "note", note },
|
||||
},
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
@@ -765,7 +792,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args)
|
||||
/// <param name="args">Termination payload — outcome drives the OnFailure gate.</param>
|
||||
/// <param name="suffixOverride">
|
||||
/// Blob-name suffix override. Null (terminal path) derives "success"/"failure" from
|
||||
/// the outcome; the WhiteGlove Part-1 seal passes "preprov" so the intermediate
|
||||
/// package is distinguishable from the later Part-2 terminal upload.
|
||||
/// </param>
|
||||
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args, string suffixOverride = null)
|
||||
{
|
||||
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
|
||||
if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -801,7 +834,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
DiagnosticsUploadResult result = null;
|
||||
try
|
||||
{
|
||||
var suffix = enrollmentSucceeded ? "success" : "failure";
|
||||
var suffix = suffixOverride ?? (enrollmentSucceeded ? "success" : "failure");
|
||||
result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
@@ -76,5 +78,57 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
uptimeSeconds = Math.Max(0, uptime.TotalSeconds);
|
||||
return bootToStart >= LateStartBootThreshold && uptime <= LowCoverageUptime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity + operator-facing phrasing for the <c>agent_late_start</c> event, calibrated
|
||||
/// by fleet data (2026-07-28): the gate alone is outcome-blind and attached a
|
||||
/// "post-mortem / check for a hung script" Warning to sessions that in fact succeeded —
|
||||
/// there the late bootstrap is deployment hygiene, not an alarm. The OOBE state refines
|
||||
/// the phrasing but must never gate: the canonical hung-script failure terminates on the
|
||||
/// still-shown ESP screen, i.e. with OOBE reading <c>in_progress</c>.
|
||||
/// </summary>
|
||||
/// <param name="outcome">Terminal outcome the enrollment reached.</param>
|
||||
/// <param name="oobeStateAtAgentStart"><see cref="Monitoring.Interop.OobeStateReader"/>
|
||||
/// sample taken at THIS process's start. Only <c>completed</c> / <c>in_progress</c> are
|
||||
/// interpreted; <c>not_started</c> flips in after mid-OOBE reboots and <c>completed</c> is
|
||||
/// routine for post-desktop restarts, so no claim is phrased from the other values.</param>
|
||||
/// <param name="bootToStartSeconds">From <see cref="IsLowObservationCoverage"/>.</param>
|
||||
/// <param name="uptimeSeconds">From <see cref="IsLowObservationCoverage"/>.</param>
|
||||
internal static void DescribeLateStart(
|
||||
EnrollmentTerminationOutcome outcome,
|
||||
string oobeStateAtAgentStart,
|
||||
double bootToStartSeconds,
|
||||
double uptimeSeconds,
|
||||
out EventSeverity severity,
|
||||
out string message,
|
||||
out string note)
|
||||
{
|
||||
var succeeded = outcome == EnrollmentTerminationOutcome.Succeeded;
|
||||
severity = succeeded ? EventSeverity.Info : EventSeverity.Warning;
|
||||
|
||||
message = $"Agent started {bootToStartSeconds / 60.0:F0} min after boot and observed only " +
|
||||
$"{uptimeSeconds:F0}s before terminating ({outcome}) — low coverage of the enrollment window.";
|
||||
switch (oobeStateAtAgentStart)
|
||||
{
|
||||
case "completed":
|
||||
message += " OOBE was already completed when the agent started — the diagnosis is a " +
|
||||
"post-mortem reconstruction, not live observation.";
|
||||
break;
|
||||
case "in_progress":
|
||||
message += $" OOBE was still in progress at agent start — the agent did observe the " +
|
||||
$"final {uptimeSeconds:F0}s live.";
|
||||
break;
|
||||
}
|
||||
|
||||
note = succeeded
|
||||
? "The agent's bootstrap (an Intune platform script) ran late in the enrollment, so only the " +
|
||||
"final stretch was observed live; earlier timeline entries are reconstructed from IME log " +
|
||||
"replay. The enrollment itself succeeded. If this recurs across sessions, review which " +
|
||||
"platform/remediation scripts run ahead of the bootstrap (see script_timeout_suspected)."
|
||||
: "The agent's bootstrap (an Intune platform script) ran only near the end of the enrollment, " +
|
||||
"so the agent observed the already-decided end-state rather than the failure window. Treat " +
|
||||
"the diagnosis as a post-mortem; check for a platform/remediation script that hung ahead of " +
|
||||
"the bootstrap (see script_timeout_suspected).";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,8 +221,18 @@ namespace AutopilotMonitor.Agent.V2
|
||||
},
|
||||
});
|
||||
|
||||
// Step 4 — run the executor and wait.
|
||||
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride))
|
||||
// Step 4 — run the executor and wait. Diagnostic mode always writes the
|
||||
// gather-rule debug trace (CLI override or default path) and mirrors it
|
||||
// to the console in --console mode.
|
||||
var gatherDebugLogPath = !string.IsNullOrEmpty(config.GatherRuleDebugLogPath)
|
||||
? config.GatherRuleDebugLogPath
|
||||
: Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
logger.Info($"--run-gather-rules: debug trace -> {gatherDebugLogPath}");
|
||||
if (consoleMode) Console.WriteLine($"Debug trace: {gatherDebugLogPath}");
|
||||
|
||||
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath,
|
||||
debugEcho: consoleMode ? (Action<string>)(line => Console.WriteLine($" {line}")) : null))
|
||||
{
|
||||
// Diagnostic mode has no enrollment phase context — scoped rules
|
||||
// (activePhases / activeFromPhase) execute unconditionally here.
|
||||
|
||||
@@ -303,6 +303,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
var apiBaseUrl = GetArgValue(args, "--api-url", "--backend-api") ?? Constants.ApiBaseUrl;
|
||||
var imeLogPathOverride = GetArgValue(args, "--ime-log-path");
|
||||
var imeMatchLogPath = GetArgValue(args, "--ime-match-log");
|
||||
var gatherDebugLogPath = GetArgValue(args, "--gather-debug-log");
|
||||
|
||||
// Dev / test — IME log replay (V1 compat mode). Feeds ImeLogTracker.SimulationMode
|
||||
// + SpeedFactor so recorded raw IME logs are replayed at an accelerated rate; signal
|
||||
@@ -373,6 +374,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
EnableGeoLocation = !disableGeoLocation,
|
||||
ImeLogPathOverride = imeLogPathOverride,
|
||||
ImeMatchLogPath = imeMatchLogPath,
|
||||
GatherRuleDebugLogPath = gatherDebugLogPath,
|
||||
KeepLogFile = keepLogFile,
|
||||
SelfDestructOnComplete = !noCleanup,
|
||||
CommandLineArgs = FormatArgsForLog(args),
|
||||
@@ -485,6 +487,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
Console.Out.WriteLine(" --api-url <URL> Override backend API base URL (alias: --backend-api)");
|
||||
Console.Out.WriteLine(" --ime-log-path <PATH> Override IME logs directory");
|
||||
Console.Out.WriteLine(" --ime-match-log <PATH> Write matched IME log lines to file (debug)");
|
||||
Console.Out.WriteLine(" --gather-debug-log <PATH> Write gather-rule evaluation trace to file (debug)");
|
||||
Console.Out.WriteLine();
|
||||
Console.Out.WriteLine("Dev / Test:");
|
||||
Console.Out.WriteLine(" --replay-log-dir <PATH> Replay IME logs from this directory (simulation mode)");
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
@@ -68,6 +69,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{ "configVersion", configVersion },
|
||||
{ "remoteConfigFetched", outcome == RemoteConfigFetchOutcome.Succeeded },
|
||||
{ "remoteConfigOutcome", outcome.ToString() },
|
||||
// WinRT OOBE state sampled at this start. Together with previousExitType
|
||||
// this makes late starts provable: "completed" on a first_run means OOBE
|
||||
// was already over when the agent arrived (e.g. IME queue blocked).
|
||||
{ "oobeStateAtAgentStart", OobeStateReader.Read() },
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType))
|
||||
|
||||
@@ -46,8 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
try { var _ = await remoteConfigService.FetchConfigAsync(); return true; }
|
||||
catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; }
|
||||
},
|
||||
// On-demand request mid-enrollment — the session has no outcome yet. Null keeps
|
||||
// sessioninfo.txt honest ("In Progress") and passes the OnFailure gate by
|
||||
// semantics (there is no success to skip on) instead of the former
|
||||
// enrollmentSucceeded:false workaround that stamped in-flight packages "Failed".
|
||||
uploadDiagnosticsAsync: async (suffix) =>
|
||||
await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix),
|
||||
await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: suffix),
|
||||
onTerminateRequested: action => OnTerminateRequested(
|
||||
action, agentConfig, orchestrator, terminationHandler, shutdown, shutdownComplete, logger),
|
||||
post: post);
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Session 4910a5a5 (2026-07-23) — advisory recovery. Field failure: DeviceSetup/Apps
|
||||
/// failed (culprit app never started installing), the advisory defang armed the
|
||||
/// <c>advisory_completion</c> window, the user pressed "Try again" ~23 min later, the apps
|
||||
/// re-ran to 10/10 and <c>DeviceSetupProvisioningComplete</c> arrived — yet the window
|
||||
/// fired 30 min after the advisory and un-defanged the stale failure
|
||||
/// (<c>enrollment_failed: esp_terminal_failure</c>) on a live enrollment mid-AccountSetup,
|
||||
/// because the advisory variant was exempt from ALL re-arm/rebase guards. Coverage:
|
||||
/// <list type="bullet">
|
||||
/// <item>The advisory records the failed category + culprit apps.</item>
|
||||
/// <item>A ProvisioningComplete of the SAME category sets the resolved fact + emits
|
||||
/// <c>esp_failure_advisory_resolved</c>; a different category does not.</item>
|
||||
/// <item>Deadline fire after recovery + enforcement progress → re-arm, not Failed.</item>
|
||||
/// <item>Deadline fire after recovery without progress → Failed with the truthful
|
||||
/// <c>esp_recovered_without_completion_evidence</c> reason and
|
||||
/// LastFailureTrigger=DeadlineFired (likely-stuck promotion off).</item>
|
||||
/// <item>Unrecovered advisory keeps the legacy un-defang exactly (regression guard).</item>
|
||||
/// <item>Reboot re-bases a recovered-advisory window (and still does NOT touch an
|
||||
/// unrecovered one).</item>
|
||||
/// <item>Snapshot round-trip + census surfacing of the new facts.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class AdvisoryRecoveryTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 7, 23, 3, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null)
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.AdvisoryCompletion });
|
||||
|
||||
private static DecisionEffect? FindTimelineEffect(DecisionStep step, string eventType) =>
|
||||
step.Effects.FirstOrDefault(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == eventType);
|
||||
|
||||
private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
|
||||
state.Deadlines.FirstOrDefault(d => d.Name == name);
|
||||
|
||||
/// <summary>
|
||||
/// Replays the session-4910a5a5 shape up to (but excluding) the ESP terminal failure:
|
||||
/// Classic UserDriven, ContinueAnyway profile, Hello disabled, DeviceSetup entered,
|
||||
/// AccountSetup entered (04:18, BEFORE the 04:34 DeviceSetup/Apps failure — the IME
|
||||
/// phase line landed while device apps were still at 9/10).
|
||||
/// </summary>
|
||||
private static DecisionState SetupSession(DecisionEngine engine)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-4910a5a5", "tenant-e46bc88e", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.SkipUserEsp] = "false",
|
||||
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||
[SignalPayloadKeys.EspSyncFailureTimeoutMinutes] = "60",
|
||||
[SignalPayloadKeys.EspAllowContinueAnyway] = "true",
|
||||
})).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
8, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(6),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(48),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
return state;
|
||||
}
|
||||
|
||||
private static DecisionStep ApplyDeviceSetupAppsFailure(DecisionEngine engine, DecisionState state, long ordinal = 50) =>
|
||||
engine.Reduce(state, MakeSignal(
|
||||
ordinal, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(64),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["failureType"] = "Provisioning_DeviceSetup_Apps_Failed",
|
||||
["failedSubcategory"] = "Apps",
|
||||
["category"] = "DeviceSetup",
|
||||
["likelyCulpritApps"] = "Oriflame Cosmectics - Teams Backgroundsv v2",
|
||||
["likelyCulpritAppCount"] = "1",
|
||||
}));
|
||||
|
||||
private static DecisionStep ApplyDeviceSetupResolved(DecisionEngine engine, DecisionState state, long ordinal = 60) =>
|
||||
engine.Reduce(state, MakeSignal(
|
||||
ordinal, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(87)));
|
||||
|
||||
// ================================================== advisory arming context ====
|
||||
|
||||
[Fact]
|
||||
public void Advisory_RecordsFailedCategory_AndCulpritApps()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
|
||||
var step = ApplyDeviceSetupAppsFailure(engine, state);
|
||||
|
||||
Assert.NotNull(step.NewState.EspAdvisoryFailureRecordedUtc);
|
||||
Assert.Equal("DeviceSetup", step.NewState.EspAdvisoryFailureCategory?.Value);
|
||||
Assert.Null(step.NewState.EspAdvisoryFailureResolvedUtc);
|
||||
|
||||
var advisory = FindTimelineEffect(step, "esp_failure_advisory");
|
||||
Assert.NotNull(advisory);
|
||||
Assert.Equal("Oriflame Cosmectics - Teams Backgroundsv v2", advisory!.Parameters!["likelyCulpritApps"]);
|
||||
}
|
||||
|
||||
// ===================================================== recovery hook ====
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupComplete_SameCategory_ResolvesAdvisory_AndEmitsStoryEvent()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
state = ApplyDeviceSetupAppsFailure(engine, state).NewState;
|
||||
|
||||
var step = ApplyDeviceSetupResolved(engine, state);
|
||||
|
||||
Assert.NotNull(step.NewState.EspAdvisoryFailureResolvedUtc);
|
||||
Assert.Equal(60, step.NewState.EspAdvisoryFailureResolvedUtc!.SourceSignalOrdinal);
|
||||
// The anchor itself stays — the advisory happened; it just no longer blocks.
|
||||
Assert.NotNull(step.NewState.EspAdvisoryFailureRecordedUtc);
|
||||
|
||||
var resolved = FindTimelineEffect(step, "esp_failure_advisory_resolved");
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal("DeviceSetup", resolved!.Parameters!["category"]);
|
||||
Assert.Equal("23", resolved.Parameters["minutesSinceAdvisory"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupComplete_DifferentCategory_DoesNotResolve()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
// Advisory for AccountSetup — DeviceSetup resolving must not defuse it.
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
50, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(64),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["failureType"] = "Provisioning_AccountSetup_Apps_Failed",
|
||||
["failedSubcategory"] = "Apps",
|
||||
["category"] = "AccountSetup",
|
||||
})).NewState;
|
||||
|
||||
var step = ApplyDeviceSetupResolved(engine, state);
|
||||
|
||||
Assert.Null(step.NewState.EspAdvisoryFailureResolvedUtc);
|
||||
Assert.Null(FindTimelineEffect(step, "esp_failure_advisory_resolved"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupComplete_Duplicate_ResolvesOnlyOnce()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
state = ApplyDeviceSetupAppsFailure(engine, state).NewState;
|
||||
state = ApplyDeviceSetupResolved(engine, state).NewState;
|
||||
|
||||
// Post-reboot duplicate (AnchorAlreadySet passthrough branch).
|
||||
var duplicate = engine.Reduce(state, MakeSignal(
|
||||
65, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(92)));
|
||||
|
||||
Assert.Equal(60, duplicate.NewState.EspAdvisoryFailureResolvedUtc!.SourceSignalOrdinal);
|
||||
Assert.Null(FindTimelineEffect(duplicate, "esp_failure_advisory_resolved"));
|
||||
}
|
||||
|
||||
// ================================================ deadline fire semantics ====
|
||||
|
||||
[Fact]
|
||||
public void DeadlineFire_AfterRecovery_WithEnforcementProgress_ReArmsInsteadOfFailing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
state = ApplyDeviceSetupAppsFailure(engine, state).NewState;
|
||||
state = ApplyDeviceSetupResolved(engine, state).NewState;
|
||||
// Post-reboot IME re-assert of AccountSetup (session shape: 05:02:59, ordinal 654)
|
||||
// — user-phase enforcement demonstrably progressed since the arming at ordinal 50.
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
70, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(92),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(80, T0.AddMinutes(94)));
|
||||
|
||||
// Not failed — the window re-arms with fresh baselines instead.
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotEqual(SessionStage.Failed, step.NewState.Stage);
|
||||
var rearmed = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(rearmed);
|
||||
Assert.Equal(T0.AddMinutes(94).AddMinutes(30), rearmed!.DueAtUtc);
|
||||
Assert.Contains(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeadlineFire_AfterRecovery_NoProgress_FailsWithRecoveredReason()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
state = ApplyDeviceSetupAppsFailure(engine, state).NewState;
|
||||
state = ApplyDeviceSetupResolved(engine, state).NewState;
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(80, T0.AddMinutes(94)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
|
||||
|
||||
var failed = FindTimelineEffect(step, "enrollment_failed");
|
||||
Assert.NotNull(failed);
|
||||
// Truthful reason: the original failure recovered — do not blame it.
|
||||
Assert.Equal("esp_recovered_without_completion_evidence", failed!.Parameters!["reason"]);
|
||||
Assert.Equal("true", failed.Parameters["failureRecovered"]);
|
||||
Assert.Equal("advisory_recovered_window_expired_without_completion_evidence", failed.Parameters["advisoryReason"]);
|
||||
// Likely-stuck app promotion stays off: apps are not stuck, they succeeded.
|
||||
Assert.Equal(nameof(DecisionSignalKind.DeadlineFired), step.NewState.LastFailureTrigger?.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeadlineFire_WithoutRecovery_KeepsLegacyUndefang()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
state = ApplyDeviceSetupAppsFailure(engine, state).NewState;
|
||||
// No recovery — even with enforcement progress the advisory variant must
|
||||
// un-defang exactly as before (its anchor failure never un-happened).
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
70, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(92),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(80, T0.AddMinutes(94)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
var failed = FindTimelineEffect(step, "enrollment_failed");
|
||||
Assert.NotNull(failed);
|
||||
Assert.Equal("esp_terminal_failure", failed!.Parameters!["reason"]);
|
||||
Assert.Equal(nameof(DecisionSignalKind.EspTerminalFailure), step.NewState.LastFailureTrigger?.Value);
|
||||
}
|
||||
|
||||
// ======================================================= reboot rebase ====
|
||||
|
||||
[Fact]
|
||||
public void Reboot_AfterRecovery_RebasesAdvisoryWindow()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
state = ApplyDeviceSetupAppsFailure(engine, state).NewState;
|
||||
state = ApplyDeviceSetupResolved(engine, state).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(
|
||||
70, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(92)));
|
||||
|
||||
Assert.Contains(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.CancelDeadline
|
||||
&& e.CancelDeadlineName == DeadlineNames.AdvisoryCompletion);
|
||||
var rebased = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(rebased);
|
||||
Assert.Equal(T0.AddMinutes(92).AddMinutes(30), rebased!.DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reboot_WithoutRecovery_DoesNotTouchAdvisoryWindow()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
state = ApplyDeviceSetupAppsFailure(engine, state).NewState;
|
||||
var originalDue = FindDeadline(state, DeadlineNames.AdvisoryCompletion)!.DueAtUtc;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(
|
||||
70, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(92)));
|
||||
|
||||
Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.CancelDeadline);
|
||||
Assert.Equal(originalDue, FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion)!.DueAtUtc);
|
||||
}
|
||||
|
||||
// ============================================== full session-4910a5a5 replay ====
|
||||
|
||||
[Fact]
|
||||
public void Session4910a5a5_FailureRecoveryRebootFire_DoesNotFailTheLiveEnrollment()
|
||||
{
|
||||
// Field ordinals: 527 EspTerminalFailure → 611 DeviceSetupProvisioningComplete
|
||||
// (post "Try again", apps 10/10) → 635 SystemRebootObserved → 654 AccountSetup
|
||||
// re-assert → 671 DeadlineFired. Pre-fix outcome: enrollment_failed at the fire.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
state = ApplyDeviceSetupAppsFailure(engine, state, ordinal: 527).NewState;
|
||||
state = ApplyDeviceSetupResolved(engine, state, ordinal: 611).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
635, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(92))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
654, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(93),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(671, T0.AddMinutes(94)));
|
||||
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotEqual(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion));
|
||||
}
|
||||
|
||||
// =============================================== serialization + census ====
|
||||
|
||||
[Fact]
|
||||
public void SnapshotRoundTrip_PreservesRecoveryFacts()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
state = ApplyDeviceSetupAppsFailure(engine, state).NewState;
|
||||
state = ApplyDeviceSetupResolved(engine, state).NewState;
|
||||
|
||||
var roundtripped = StateSerializer.Deserialize(StateSerializer.Serialize(state));
|
||||
|
||||
Assert.Equal("DeviceSetup", roundtripped.EspAdvisoryFailureCategory?.Value);
|
||||
Assert.Equal(state.EspAdvisoryFailureResolvedUtc!.Value, roundtripped.EspAdvisoryFailureResolvedUtc?.Value);
|
||||
Assert.Equal(state.EspAdvisoryFailureResolvedUtc.SourceSignalOrdinal, roundtripped.EspAdvisoryFailureResolvedUtc?.SourceSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacySnapshot_WithoutRecoveryFacts_DeserializesToNull()
|
||||
{
|
||||
// Simulate a snapshot written by a pre-fix agent: strip the new properties from
|
||||
// the JSON entirely — deserialization must default them to null (additive compat).
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
state = ApplyDeviceSetupAppsFailure(engine, state).NewState;
|
||||
state = ApplyDeviceSetupResolved(engine, state).NewState;
|
||||
|
||||
var legacy = Newtonsoft.Json.Linq.JObject.Parse(StateSerializer.Serialize(state));
|
||||
foreach (var prop in legacy.Properties()
|
||||
.Where(p => p.Name.IndexOf("EspAdvisoryFailureResolved", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| p.Name.IndexOf("EspAdvisoryFailureCategory", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
.ToList())
|
||||
{
|
||||
prop.Remove();
|
||||
}
|
||||
|
||||
var roundtripped = StateSerializer.Deserialize(legacy.ToString());
|
||||
Assert.Null(roundtripped.EspAdvisoryFailureResolvedUtc);
|
||||
Assert.Null(roundtripped.EspAdvisoryFailureCategory);
|
||||
// The pre-existing advisory anchor still round-trips.
|
||||
Assert.NotNull(roundtripped.EspAdvisoryFailureRecordedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_SurfacesAdvisoryResolved()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupSession(engine);
|
||||
state = ApplyDeviceSetupAppsFailure(engine, state).NewState;
|
||||
state = ApplyDeviceSetupResolved(engine, state).NewState;
|
||||
|
||||
var census = DecisionStateSignalCensus.Build(state);
|
||||
|
||||
Assert.Contains("esp_advisory_failure_resolved", census.SignalsSeen);
|
||||
Assert.True(census.SignalTimestamps.ContainsKey("espAdvisoryFailureResolved"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
// state-change-only dedupe anchor for the completion_waiting event → 17.
|
||||
// Session 772fe502 (2026-07-13) added `helloWizardStartedUtc` — genuine
|
||||
// Shell-Core 62404 wizard launch; vetoes the policy-disabled Hello skip → 18.
|
||||
Assert.Equal(18, facts.Count);
|
||||
// Session 4910a5a5 (2026-07-23) added `espAdvisoryFailureResolvedUtc` (the failed
|
||||
// category recovered — lifts the advisory's re-arm/rebase guard exemption) and
|
||||
// `espAdvisoryFailureCategory` (which category the advisory was for) → 20.
|
||||
Assert.Equal(20, facts.Count);
|
||||
Assert.All(facts.Values, v => Assert.Null(v));
|
||||
}
|
||||
|
||||
@@ -236,10 +239,12 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
// `completionWaitingFingerprint` as the state-change-only dedupe anchor for
|
||||
// the completion_waiting event → 17. Session 772fe502 (2026-07-13) added
|
||||
// `helloWizardStartedUtc` — the genuine Shell-Core 62404 wizard-launch fact
|
||||
// that vetoes/retracts the policy-disabled Hello skip → 18. If this number
|
||||
// ever changes, both the count expectation AND the actual snapshot output
|
||||
// need to evolve in lockstep.
|
||||
Assert.Equal(18, expectedFactKeys.Count);
|
||||
// that vetoes/retracts the policy-disabled Hello skip → 18. Session 4910a5a5
|
||||
// (2026-07-23) added `espAdvisoryFailureResolvedUtc` + `espAdvisoryFailureCategory`
|
||||
// — advisory-recovery facts that lift the AdvisoryCompletion guard exemption → 20.
|
||||
// If this number ever changes, both the count expectation AND the actual snapshot
|
||||
// output need to evolve in lockstep.
|
||||
Assert.Equal(20, expectedFactKeys.Count);
|
||||
|
||||
var snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t"));
|
||||
var facts = (Dictionary<string, object?>)snapshot["facts"]!;
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
using System.Text.Json;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Functions.Apps;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// F1 PR1 (insights spec §F1, source-data audit Q2/Q3): app identity on the name-keyed
|
||||
/// summary row and the ESP-blocking join.
|
||||
///
|
||||
/// Ingest side: <c>appId</c> is adopted from the app events; a second appId under the same
|
||||
/// name flags the row (<see cref="AppInstallSummary.AppIdCollision"/>) — in-batch and
|
||||
/// cross-batch. Store side: sentinel-gated columns + sticky collision. Join side:
|
||||
/// <see cref="EspBlockingSets"/> is positive evidence only, and
|
||||
/// <see cref="TableStorageService.ShouldStampEspBlocking"/> never stamps collision rows,
|
||||
/// id-less rows, or non-members. Aggregate side: collision rows leave every per-app fleet
|
||||
/// aggregate with a disclosed exclusion count.
|
||||
/// </summary>
|
||||
public class AppInstallIdentityAndEspBlockingTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string IdA = "aaaaaaaa-1111-2222-3333-444444444444";
|
||||
private const string IdB = "bbbbbbbb-1111-2222-3333-444444444444";
|
||||
|
||||
// ── ingest fold: appId adoption + in-batch collision ────────────────────
|
||||
|
||||
private static Dictionary<string, AppInstallAggregationState> Aggregate(params EnrollmentEvent[] events)
|
||||
{
|
||||
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var evt in events)
|
||||
EventIngestProcessor.AggregateAppInstallEvent(evt, "tenant", "session", summaries);
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private static EnrollmentEvent AppEvent(string eventType, string? appId = null, string appName = "App")
|
||||
{
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = eventType,
|
||||
Timestamp = T0,
|
||||
Data = new Dictionary<string, object> { ["appName"] = appName },
|
||||
};
|
||||
if (appId != null) evt.Data["appId"] = appId;
|
||||
return evt;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_AdoptsAppId_FromFirstObservingEvent()
|
||||
{
|
||||
var s = Aggregate(
|
||||
AppEvent("app_install_started", IdA),
|
||||
AppEvent("app_install_completed", IdA))["App"].Summary;
|
||||
|
||||
Assert.Equal(IdA, s.AppId);
|
||||
Assert.False(s.AppIdCollision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_SameName_DifferentAppId_FlagsCollision_FirstSeenIdWins()
|
||||
{
|
||||
// Device- + user-scope assignment of the same display name in one batch (audit Q3:
|
||||
// observed in production as two "Company Portal" appIds in one session).
|
||||
var s = Aggregate(
|
||||
AppEvent("app_install_started", IdA),
|
||||
AppEvent("app_install_started", IdB))["App"].Summary;
|
||||
|
||||
Assert.True(s.AppIdCollision);
|
||||
Assert.Equal(IdA, s.AppId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_AppIdCaseDrift_IsNotACollision()
|
||||
{
|
||||
var s = Aggregate(
|
||||
AppEvent("app_install_started", IdA),
|
||||
AppEvent("app_install_completed", IdA.ToUpperInvariant()))["App"].Summary;
|
||||
|
||||
Assert.False(s.AppIdCollision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_EventsWithoutAppId_LeaveSentinelEmpty()
|
||||
{
|
||||
var s = Aggregate(AppEvent("app_install_completed"))["App"].Summary;
|
||||
Assert.Equal(string.Empty, s.AppId);
|
||||
Assert.False(s.AppIdCollision);
|
||||
}
|
||||
|
||||
// ── reconcile: cross-batch identity ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_AdoptsStoredAppId_WhenIncomingBatchHasNone()
|
||||
{
|
||||
var existing = new TableEntity("tenant", "session_App") { ["AppId"] = IdA };
|
||||
var summary = new AppInstallSummary { AppName = "App", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Equal(IdA, summary.AppId);
|
||||
Assert.False(summary.AppIdCollision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_CrossBatchDifferentAppId_FlagsCollision_StoredIdWins()
|
||||
{
|
||||
var existing = new TableEntity("tenant", "session_App") { ["AppId"] = IdA };
|
||||
var summary = new AppInstallSummary { AppName = "App", AppId = IdB, StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.True(summary.AppIdCollision);
|
||||
Assert.Equal(IdA, summary.AppId); // first-seen (stored) identity wins, matching the fold rule
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_CollisionIsSticky_AndEspBlockingVerdictIsAdopted()
|
||||
{
|
||||
var existing = new TableEntity("tenant", "session_App")
|
||||
{
|
||||
["AppId"] = IdA,
|
||||
["AppIdCollision"] = true,
|
||||
["EspBlocking"] = true,
|
||||
};
|
||||
var summary = new AppInstallSummary { AppName = "App", AppId = IdA, StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.True(summary.AppIdCollision); // one-way sticky
|
||||
Assert.True(summary.EspBlocking); // terminal-path verdict survives late batches
|
||||
}
|
||||
|
||||
// ── entity builder: sentinel gating ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void EntityBuilder_IdentityColumns_AreSentinelGated()
|
||||
{
|
||||
var empty = TableStorageService.BuildAppInstallSummaryEntity(
|
||||
new AppInstallSummary { AppName = "App" }, "rk");
|
||||
// Absent columns → Merge-mode preserves prior values / stays unknown.
|
||||
Assert.False(empty.ContainsKey("AppId"));
|
||||
Assert.False(empty.ContainsKey("EspBlocking"));
|
||||
Assert.False(empty.ContainsKey("AppIdCollision"));
|
||||
|
||||
var full = TableStorageService.BuildAppInstallSummaryEntity(
|
||||
new AppInstallSummary { AppName = "App", AppId = IdA, EspBlocking = true, AppIdCollision = true }, "rk");
|
||||
Assert.Equal(IdA, full.GetString("AppId"));
|
||||
Assert.True(full.GetBoolean("EspBlocking"));
|
||||
Assert.True(full.GetBoolean("AppIdCollision"));
|
||||
}
|
||||
|
||||
// ── EspBlockingSets: positive-evidence parsing ──────────────────────────
|
||||
|
||||
private static Dictionary<string, object> Lists(
|
||||
object? win32 = null, object? userWin32 = null, object? msi = null, object? pfn = null,
|
||||
long? win32Count = null)
|
||||
{
|
||||
var d = new Dictionary<string, object> { ["source"] = "registry_firstsync" };
|
||||
if (win32 != null) d["espTrackedWin32AppIds"] = win32;
|
||||
if (userWin32 != null) d["espTrackedUserWin32AppIds"] = userWin32;
|
||||
if (msi != null) d["espTrackedMsiProductCodes"] = msi;
|
||||
if (pfn != null) d["espTrackedModernAppPfns"] = pfn;
|
||||
if (win32Count.HasValue) d["espTrackedWin32Count"] = win32Count.Value;
|
||||
return d;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sets_PayloadWithoutListKeys_YieldsNull_NoListsIsNotEmptyLists()
|
||||
{
|
||||
Assert.Null(EspBlockingSets.FromEventData(null));
|
||||
Assert.Null(EspBlockingSets.FromEventData(Lists())); // probe found no Diagnostics key
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sets_MembershipIsCaseInsensitive_AcrossAllFourLists()
|
||||
{
|
||||
// Storage rehydration shape: List<object> (EventDataNormalizer).
|
||||
var sets = EspBlockingSets.FromEventData(Lists(
|
||||
win32: new List<object> { IdA },
|
||||
msi: new List<object> { "{11111111-2222-3333-4444-555555555555}" },
|
||||
pfn: new List<object> { "Contoso.App_abc123" }))!;
|
||||
|
||||
Assert.True(sets.Contains(IdA.ToUpperInvariant()));
|
||||
Assert.True(sets.Contains("{11111111-2222-3333-4444-555555555555}"));
|
||||
Assert.True(sets.Contains("Contoso.App_abc123"));
|
||||
Assert.False(sets.Contains(IdB)); // absent ⇒ unknown at the caller, never false here
|
||||
Assert.False(sets.Contains(null));
|
||||
Assert.Equal(3, sets.ListedCount);
|
||||
Assert.False(sets.IsTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sets_EmittedCountAboveListLength_MeansTruncated()
|
||||
{
|
||||
var sets = EspBlockingSets.FromEventData(Lists(
|
||||
win32: new List<object> { IdA }, win32Count: 60))!;
|
||||
|
||||
Assert.True(sets.IsTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sets_UserScopeSubset_IsExposedForCoverageLabeling()
|
||||
{
|
||||
var sets = EspBlockingSets.FromEventData(Lists(
|
||||
win32: new List<object> { IdA, IdB },
|
||||
userWin32: new List<object> { IdB }))!;
|
||||
|
||||
Assert.Contains(IdB, sets.UserWin32AppIds);
|
||||
Assert.DoesNotContain(IdA, sets.UserWin32AppIds);
|
||||
}
|
||||
|
||||
// ── terminal-path stamping predicate ────────────────────────────────────
|
||||
|
||||
private static TableEntity Row(string? appId = null, bool? collision = null, bool? espBlocking = null)
|
||||
{
|
||||
var row = new TableEntity("tenant", "session_App");
|
||||
if (appId != null) row["AppId"] = appId;
|
||||
if (collision.HasValue) row["AppIdCollision"] = collision.Value;
|
||||
if (espBlocking.HasValue) row["EspBlocking"] = espBlocking.Value;
|
||||
return row;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stamp_OnlyPositiveMembers_NeverIdlessCollidedOrAlreadyStamped()
|
||||
{
|
||||
var sets = EspBlockingSets.FromEventData(Lists(win32: new List<object> { IdA }))!;
|
||||
|
||||
Assert.True(TableStorageService.ShouldStampEspBlocking(Row(IdA), sets));
|
||||
Assert.False(TableStorageService.ShouldStampEspBlocking(Row(), sets)); // no identity → unknown
|
||||
Assert.False(TableStorageService.ShouldStampEspBlocking(Row(IdB), sets)); // absent ⇒ unknown, never false
|
||||
Assert.False(TableStorageService.ShouldStampEspBlocking(Row(IdA, collision: true), sets)); // ambiguous identity
|
||||
Assert.False(TableStorageService.ShouldStampEspBlocking(Row(IdA, espBlocking: true), sets)); // idempotent
|
||||
}
|
||||
|
||||
// ── per-app fleet aggregates: collision rows leave, disclosed ───────────
|
||||
|
||||
private static AppInstallSummary Summary(
|
||||
string appName, string status = "Succeeded", bool collision = false, int duration = 30)
|
||||
=> new()
|
||||
{
|
||||
TenantId = "00000000-0000-0000-0000-000000000fa1",
|
||||
SessionId = "s-1",
|
||||
AppName = appName,
|
||||
Status = status,
|
||||
TerminalState = status == "Succeeded" ? "Installed" : "Error",
|
||||
DurationSeconds = duration,
|
||||
StartedAt = DateTime.UtcNow.AddHours(-2),
|
||||
AppIdCollision = collision,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AppMetricsPayload_ExcludesCollisionRowsFromGroups_DisclosesCount()
|
||||
{
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
Summary("Clean App"),
|
||||
Summary("Clean App"),
|
||||
Summary("Company Portal", collision: true),
|
||||
Summary("Company Portal", status: "Failed", collision: true),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("totalApps").GetInt32()); // collided name forms no group
|
||||
Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged
|
||||
Assert.Equal(2, root.GetProperty("totalCollisionExcluded").GetInt32());
|
||||
Assert.Empty(root.GetProperty("topFailingApps").EnumerateArray()); // the collided Failed row ranks nowhere
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppsList_ExcludesCollisionRows_DisclosesCount()
|
||||
{
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
Summary("Clean App"),
|
||||
Summary("Company Portal", collision: true),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var apps = root.GetProperty("apps").EnumerateArray().ToList();
|
||||
Assert.Single(apps);
|
||||
Assert.Equal("Clean App", apps[0].GetProperty("appName").GetString());
|
||||
Assert.Equal(2, root.GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppAnalytics_ExcludesCollisionRows_DisclosesCount()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((SessionSummary?)null);
|
||||
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
Summary("Company Portal"),
|
||||
Summary("Company Portal", status: "Failed", collision: true), // the second identity's failure
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
var stats = root.GetProperty("summary");
|
||||
Assert.Equal(1, stats.GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(0, stats.GetProperty("failed").GetInt32()); // ambiguous failure not attributed
|
||||
Assert.Equal(0d, stats.GetProperty("failureRate").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppAnalytics_AllRowsCollided_ReturnsEmptyShape_WithDisclosure()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((SessionSummary?)null);
|
||||
|
||||
var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
Assert.Equal(0, root.GetProperty("summary").GetProperty("totalInstalls").GetInt32());
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR0 (2026-07-26, source-data audit §0.5 of tasks/insights-expansion-spec.md).
|
||||
///
|
||||
/// Ingest side: the agent emits <c>app_install_completed</c> for EVERY terminal transition —
|
||||
/// including Skipped/Postponed no-ops (V1 wire parity; production: 78 % of zero-duration rows
|
||||
/// were skips). The payload's <c>state</c> field must be persisted as
|
||||
/// <see cref="AppInstallSummary.TerminalState"/> so metrics can exclude skips from durations
|
||||
/// and rates.
|
||||
///
|
||||
/// Store side: <see cref="TableStorageService.ReconcileAppInstallSummaryWithExisting"/> pins the
|
||||
/// cross-batch contract — earliest StartedAt wins, terminal status is sticky, and the Q4
|
||||
/// out-of-order case (terminal batch first, started batch later) recomputes the duration.
|
||||
/// </summary>
|
||||
public class AppInstallTerminalStateAndReconcileTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── ingest: TerminalState fold ──────────────────────────────────────────
|
||||
|
||||
private static Dictionary<string, AppInstallAggregationState> Aggregate(params EnrollmentEvent[] events)
|
||||
{
|
||||
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var evt in events)
|
||||
EventIngestProcessor.AggregateAppInstallEvent(evt, "tenant", "session", summaries);
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private static EnrollmentEvent AppEvent(string eventType, DateTime ts, string? state = null, string appName = "App")
|
||||
{
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = eventType,
|
||||
Timestamp = ts,
|
||||
Data = new Dictionary<string, object> { ["appName"] = appName },
|
||||
};
|
||||
if (state != null) evt.Data["state"] = state;
|
||||
return evt;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Installed")]
|
||||
[InlineData("Skipped")]
|
||||
[InlineData("Postponed")]
|
||||
public void Completed_PersistsPayloadState(string state)
|
||||
{
|
||||
var s = Aggregate(AppEvent("app_install_completed", T0, state))["App"].Summary;
|
||||
|
||||
Assert.Equal("Succeeded", s.Status); // backward-compatible status
|
||||
Assert.Equal(state, s.TerminalState); // new precision column
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Completed_WithoutState_LeavesSentinelEmpty_NeverGuessed()
|
||||
{
|
||||
var s = Aggregate(AppEvent("app_install_completed", T0))["App"].Summary;
|
||||
Assert.Equal(string.Empty, s.TerminalState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Completed_WithNonTerminalOrGarbageState_LeavesSentinelEmpty()
|
||||
{
|
||||
// A payload state outside the terminal set (bug or drift) must not be persisted.
|
||||
var s = Aggregate(AppEvent("app_install_completed", T0, "Downloading"))["App"].Summary;
|
||||
Assert.Equal(string.Empty, s.TerminalState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_SetsError()
|
||||
{
|
||||
var s = Aggregate(AppEvent("app_install_failed", T0))["App"].Summary;
|
||||
Assert.Equal("Failed", s.Status);
|
||||
Assert.Equal("Error", s.TerminalState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedThenCompleted_RetrySucceeds_LastTerminalWins()
|
||||
{
|
||||
// IME retry path (audit Q1): Error → retry → Installed. The later completed event is
|
||||
// the authoritative outcome for Status AND TerminalState.
|
||||
var s = Aggregate(
|
||||
AppEvent("app_install_failed", T0),
|
||||
AppEvent("app_install_completed", T0.AddMinutes(10), "Installed"))["App"].Summary;
|
||||
|
||||
Assert.Equal("Succeeded", s.Status);
|
||||
Assert.Equal("Installed", s.TerminalState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkippedEvent_SetsSkipped_ButNeverOverridesStrongerTerminal()
|
||||
{
|
||||
var fresh = Aggregate(AppEvent("app_install_skipped", T0))["App"].Summary;
|
||||
Assert.Equal("Succeeded", fresh.Status);
|
||||
Assert.Equal("Skipped", fresh.TerminalState);
|
||||
|
||||
var afterFailure = Aggregate(
|
||||
AppEvent("app_install_failed", T0),
|
||||
AppEvent("app_install_skipped", T0.AddSeconds(5)))["App"].Summary;
|
||||
Assert.Equal("Error", afterFailure.TerminalState);
|
||||
}
|
||||
|
||||
// ── store: cross-batch reconcile ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_Q4_OutOfOrder_RecomputesDurationFromStoredCompletedAt()
|
||||
{
|
||||
// Terminal batch landed first: row has CompletedAt but no usable duration.
|
||||
var existing = new TableEntity("tenant", "session_App")
|
||||
{
|
||||
["Status"] = "Succeeded",
|
||||
["StartedAt"] = new DateTimeOffset(T0.AddMinutes(5)), // late (completed-event) stamp
|
||||
["CompletedAt"] = new DateTimeOffset(T0.AddMinutes(5)),
|
||||
};
|
||||
// Started batch arrives now with the true (earlier) start.
|
||||
var summary = new AppInstallSummary
|
||||
{
|
||||
AppName = "App", SessionId = "session", TenantId = "tenant",
|
||||
Status = "InProgress", StartedAt = T0,
|
||||
};
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Equal(T0, summary.StartedAt); // earlier start kept
|
||||
Assert.Equal(T0.AddMinutes(5), summary.CompletedAt); // endpoint adopted from the row
|
||||
Assert.Equal(300, summary.DurationSeconds); // recomputed — was permanently unset before PR0
|
||||
Assert.Equal("Succeeded", summary.Status); // sticky terminal (below)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_TerminalStatusIsSticky_AgainstLateInProgressBatch()
|
||||
{
|
||||
var existing = new TableEntity("tenant", "session_App") { ["Status"] = "Failed" };
|
||||
var summary = new AppInstallSummary { AppName = "App", Status = "InProgress", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Equal("Failed", summary.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_TerminalToTerminal_StaysWithIncomingEvidence()
|
||||
{
|
||||
// Failed row + succeeded batch (successful retry) → the new terminal wins.
|
||||
var existing = new TableEntity("tenant", "session_App") { ["Status"] = "Failed" };
|
||||
var summary = new AppInstallSummary { AppName = "App", Status = "Succeeded", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Equal("Succeeded", summary.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_Q4Guard_DoesNotFireWithoutStoredCompletedAt()
|
||||
{
|
||||
var existing = new TableEntity("tenant", "session_App") { ["Status"] = "InProgress" };
|
||||
var summary = new AppInstallSummary { AppName = "App", Status = "InProgress", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Null(summary.CompletedAt);
|
||||
Assert.Equal(0, summary.DurationSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_Q4Guard_IgnoresStoredCompletedAtBeforeIncomingStart()
|
||||
{
|
||||
// Negative interval (clock mess) must not produce a negative duration.
|
||||
var existing = new TableEntity("tenant", "session_App")
|
||||
{
|
||||
["Status"] = "Succeeded",
|
||||
["CompletedAt"] = new DateTimeOffset(T0.AddMinutes(-10)),
|
||||
};
|
||||
var summary = new AppInstallSummary { AppName = "App", Status = "InProgress", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Null(summary.CompletedAt);
|
||||
Assert.Equal(0, summary.DurationSeconds);
|
||||
}
|
||||
|
||||
// ── entity builder: sentinel gating for the new column ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void EntityBuilder_TerminalState_IsSentinelGated()
|
||||
{
|
||||
var withState = new AppInstallSummary { AppName = "App", TerminalState = "Skipped" };
|
||||
var entity = TableStorageService.BuildAppInstallSummaryEntity(withState, "rk");
|
||||
Assert.Equal("Skipped", entity.GetString("TerminalState"));
|
||||
|
||||
var withoutState = new AppInstallSummary { AppName = "App" };
|
||||
var empty = TableStorageService.BuildAppInstallSummaryEntity(withoutState, "rk");
|
||||
// Column absent → Merge-mode preserves a prior batch's terminal state.
|
||||
Assert.False(empty.ContainsKey("TerminalState"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Apps;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the terminal-only failure-rate convention on the Apps dashboard aggregations
|
||||
/// (<see cref="AppsAnalyticsHelper"/>): every failureRate is Failed / (Failed + Succeeded) —
|
||||
/// finished installs only. "InProgress" rows (still installing, or orphaned by a session that
|
||||
/// died mid-install) sit in the install counts but never dilute a rate, matching the enrollment
|
||||
/// success-rate convention. Responses are anonymous projections, so tests assert on the
|
||||
/// JSON wire shape like the sibling suites.
|
||||
/// </summary>
|
||||
public class AppsAnalyticsFailureRateTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-0000-0000-000000000fa1";
|
||||
|
||||
private static AppInstallSummary A(
|
||||
string status,
|
||||
string appName = "Contoso App",
|
||||
DateTime? startedAt = null,
|
||||
string sessionId = "s-1",
|
||||
string appVersion = "")
|
||||
=> new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = sessionId,
|
||||
AppName = appName,
|
||||
AppVersion = appVersion,
|
||||
Status = status,
|
||||
StartedAt = startedAt ?? DateTime.UtcNow.AddHours(-2),
|
||||
DurationSeconds = status == "Succeeded" ? 30 : 0,
|
||||
};
|
||||
|
||||
// ── /apps/list ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void List_FailureRate_IsOverFinishedInstalls_InProgressExcluded()
|
||||
{
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
A("Failed"),
|
||||
A("Succeeded"),
|
||||
A("InProgress"),
|
||||
A("InProgress"),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
Assert.Equal(4, app.GetProperty("totalInstalls").GetInt32());
|
||||
// 1 / (1 + 1) = 50% (over finished), NOT 1 / 4 = 25% (over all installs).
|
||||
Assert.Equal(50d, app.GetProperty("failureRate").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void List_FailureRate_IsZero_WhenNothingFinishedYet()
|
||||
{
|
||||
var summaries = new List<AppInstallSummary> { A("InProgress"), A("InProgress") };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
Assert.Equal(0d, root.GetProperty("apps")[0].GetProperty("failureRate").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void List_Trend_UsesFinishedRates_AndGatesOnFinishedSamples()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var firstHalf = now.AddDays(-20); // days: 30 → midpoint at now - 15d
|
||||
var secondHalf = now.AddDays(-5);
|
||||
|
||||
var summaries = new List<AppInstallSummary>();
|
||||
// First half: 1 failed / 5 finished = 20%.
|
||||
summaries.Add(A("Failed", startedAt: firstHalf));
|
||||
for (var i = 0; i < 4; i++) summaries.Add(A("Succeeded", startedAt: firstHalf));
|
||||
// Second half: 3 failed / 5 finished = 60% — plus 10 in-flight installs that
|
||||
// previously dragged the half's rate down to 20% and masked the regression.
|
||||
for (var i = 0; i < 3; i++) summaries.Add(A("Failed", startedAt: secondHalf));
|
||||
for (var i = 0; i < 2; i++) summaries.Add(A("Succeeded", startedAt: secondHalf));
|
||||
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: secondHalf));
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
Assert.Equal("worsening", app.GetProperty("trend").GetString());
|
||||
Assert.Equal(40d, app.GetProperty("trendDelta").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void List_Trend_StaysStable_WhenAHalfLacksFiveFinishedInstalls()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var summaries = new List<AppInstallSummary>();
|
||||
// First half: 5 finished. Second half: 4 finished + many in-flight — the old
|
||||
// install-count gate would have passed; the finished-sample gate must not.
|
||||
for (var i = 0; i < 5; i++) summaries.Add(A("Succeeded", startedAt: now.AddDays(-20)));
|
||||
for (var i = 0; i < 4; i++) summaries.Add(A("Failed", startedAt: now.AddDays(-5)));
|
||||
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: now.AddDays(-5)));
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
Assert.Equal("stable", app.GetProperty("trend").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, app.GetProperty("trendDelta").ValueKind);
|
||||
}
|
||||
|
||||
// ── /apps/{appName}/analytics ───────────────────────────────────────────
|
||||
|
||||
private static async Task<JsonElement> BuildAnalyticsAsync(
|
||||
List<AppInstallSummary> summaries, ISessionRepository? sessionRepo = null)
|
||||
{
|
||||
if (sessionRepo == null)
|
||||
{
|
||||
var mock = new Mock<ISessionRepository>();
|
||||
mock.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((SessionSummary?)null);
|
||||
sessionRepo = mock.Object;
|
||||
}
|
||||
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, sessionRepo, "Contoso App", days: 30);
|
||||
return JsonSerializer.SerializeToElement(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analytics_SummaryVersionAndTimeSeriesRates_AreOverFinishedInstalls()
|
||||
{
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
A("Failed", appVersion: "1.0"),
|
||||
A("Succeeded", appVersion: "1.0"),
|
||||
A("InProgress", appVersion: "1.0"),
|
||||
A("InProgress", appVersion: "1.0"),
|
||||
};
|
||||
|
||||
var root = await BuildAnalyticsAsync(summaries);
|
||||
|
||||
// Summary: 1 / (1 + 1) = 50%, NOT 1 / 4 = 25%.
|
||||
var summary = root.GetProperty("summary");
|
||||
Assert.Equal(4, summary.GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(50d, summary.GetProperty("failureRate").GetDouble());
|
||||
|
||||
var version = root.GetProperty("versionBreakdown")[0];
|
||||
Assert.Equal(4, version.GetProperty("installs").GetInt32());
|
||||
Assert.Equal(50d, version.GetProperty("failureRate").GetDouble());
|
||||
|
||||
// All four installs land in today's bucket → same terminal-only rate there.
|
||||
var buckets = root.GetProperty("timeSeries").EnumerateArray().ToList();
|
||||
var todayBucket = buckets.Single(b => b.GetProperty("installs").GetInt32() > 0);
|
||||
Assert.Equal(50d, todayBucket.GetProperty("failureRate").GetDouble());
|
||||
}
|
||||
|
||||
// ── PR0 (2026-07-26): skip / unmeasured classification ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void List_Skips_LeaveRateAndDurations_AndAreReportedSeparately()
|
||||
{
|
||||
var skip = A("Succeeded");
|
||||
skip.TerminalState = "Skipped";
|
||||
skip.DurationSeconds = 0;
|
||||
var unmeasured = A("Succeeded");
|
||||
unmeasured.TerminalState = "Installed";
|
||||
unmeasured.DurationSeconds = 0;
|
||||
|
||||
var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
var app = root.GetProperty("apps")[0];
|
||||
|
||||
Assert.Equal(4, app.GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(2, app.GetProperty("succeeded").GetInt32()); // real install + unmeasured
|
||||
Assert.Equal(1, app.GetProperty("skipped").GetInt32());
|
||||
Assert.Equal(1, app.GetProperty("unmeasured").GetInt32());
|
||||
// 1 / (1 + 2) — the skip is not an attempt.
|
||||
Assert.Equal(33.3d, app.GetProperty("failureRate").GetDouble());
|
||||
// avg over the single measured row (30s) — the zero must not drag it to 15.
|
||||
Assert.Equal(30d, app.GetProperty("avgDurationSeconds").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analytics_Summary_CarriesSkippedAndUnmeasured_AndP95OverMeasuredOnly()
|
||||
{
|
||||
var skip = A("Succeeded");
|
||||
skip.TerminalState = "Skipped";
|
||||
skip.DurationSeconds = 0;
|
||||
var unmeasured = A("Succeeded");
|
||||
unmeasured.TerminalState = "Installed";
|
||||
unmeasured.DurationSeconds = 0;
|
||||
var measured = A("Succeeded");
|
||||
measured.TerminalState = "Installed";
|
||||
measured.DurationSeconds = 100;
|
||||
|
||||
var root = await BuildAnalyticsAsync(new List<AppInstallSummary> { skip, unmeasured, measured });
|
||||
var summary = root.GetProperty("summary");
|
||||
|
||||
Assert.Equal(2, summary.GetProperty("succeeded").GetInt32());
|
||||
Assert.Equal(1, summary.GetProperty("skipped").GetInt32());
|
||||
Assert.Equal(1, summary.GetProperty("unmeasured").GetInt32());
|
||||
Assert.Equal(100d, summary.GetProperty("avgDurationSeconds").GetDouble());
|
||||
Assert.Equal(100, summary.GetProperty("p95DurationSeconds").GetInt32());
|
||||
Assert.Equal(0d, summary.GetProperty("failureRate").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analytics_DeviceModelBreakdown_RatesAndSampleFloor_UseFinishedInstalls()
|
||||
{
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(r => r.GetSessionAsync(TenantId, It.IsAny<string>()))
|
||||
.ReturnsAsync((string _, string sessionId) => new SessionSummary
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = sessionId,
|
||||
Manufacturer = "Contoso",
|
||||
Model = sessionId.StartsWith("s-a") ? "ModelA" : "ModelB",
|
||||
});
|
||||
|
||||
var summaries = new List<AppInstallSummary>();
|
||||
// ModelA: 5 finished (1 failed → 20%) + 3 in-flight → ranked, rate undiluted.
|
||||
summaries.Add(A("Failed", sessionId: "s-a-0"));
|
||||
for (var i = 1; i <= 4; i++) summaries.Add(A("Succeeded", sessionId: $"s-a-{i}"));
|
||||
for (var i = 5; i <= 7; i++) summaries.Add(A("InProgress", sessionId: $"s-a-{i}"));
|
||||
// ModelB: 4 finished + 6 in-flight — 10 installs, but below the 5-finished floor.
|
||||
for (var i = 0; i <= 3; i++) summaries.Add(A("Failed", sessionId: $"s-b-{i}"));
|
||||
for (var i = 4; i <= 9; i++) summaries.Add(A("InProgress", sessionId: $"s-b-{i}"));
|
||||
|
||||
var root = await BuildAnalyticsAsync(summaries, sessionRepo.Object);
|
||||
|
||||
var breakdown = root.GetProperty("deviceModelBreakdown");
|
||||
var model = Assert.Single(breakdown.EnumerateArray());
|
||||
Assert.Equal("ModelA", model.GetProperty("model").GetString());
|
||||
Assert.Equal(8, model.GetProperty("installs").GetInt32());
|
||||
Assert.Equal(20d, model.GetProperty("failureRate").GetDouble());
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ public class AppsProjectionEquivalenceTests
|
||||
["AppType"] = "Win32",
|
||||
["AppVersion"] = "2.1.0",
|
||||
["Status"] = "Failed",
|
||||
// PR0/PR1 classification columns. The fixture MUST populate every column a Build*
|
||||
// method reads: a column absent here passes the equivalence assert trivially (both
|
||||
// sides map to default) — exactly how the missing TerminalState projection slipped
|
||||
// through and zeroed totalSkipped in production (2026-07-27).
|
||||
["TerminalState"] = "Skipped",
|
||||
["AppId"] = "9e8d7c6b-5a49-4832-9182-736451234567",
|
||||
["EspBlocking"] = true,
|
||||
["AppIdCollision"] = true,
|
||||
["StartedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-6)),
|
||||
["CompletedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-5)),
|
||||
["DurationSeconds"] = 340,
|
||||
@@ -83,6 +91,9 @@ public class AppsProjectionEquivalenceTests
|
||||
|
||||
Assert.Equal(fromFull.AppName, fromProjected.AppName);
|
||||
Assert.Equal(fromFull.Status, fromProjected.Status);
|
||||
// IsSkipTerminalState / skip classification input — see the fixture comment.
|
||||
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
|
||||
Assert.Equal(fromFull.AppIdCollision, fromProjected.AppIdCollision);
|
||||
Assert.Equal(fromFull.StartedAt, fromProjected.StartedAt);
|
||||
Assert.Equal(fromFull.DurationSeconds, fromProjected.DurationSeconds);
|
||||
Assert.Equal(fromFull.DownloadBytes, fromProjected.DownloadBytes);
|
||||
@@ -122,6 +133,10 @@ public class AppsProjectionEquivalenceTests
|
||||
Assert.Equal(fromFull.FailureMessage, fromProjected.FailureMessage);
|
||||
Assert.Equal(fromFull.ExitCode, fromProjected.ExitCode);
|
||||
Assert.Equal(fromFull.DetectionResult, fromProjected.DetectionResult);
|
||||
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
|
||||
Assert.Equal(fromFull.AppId, fromProjected.AppId);
|
||||
Assert.Equal(fromFull.EspBlocking, fromProjected.EspBlocking);
|
||||
Assert.Equal(fromFull.AppIdCollision, fromProjected.AppIdCollision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the pure helpers of <see cref="AzureMonitorAlertWebhookFunction"/> — the anonymous,
|
||||
/// secret-gated bridge that turns Azure Monitor Common Alert Schema notifications into ops
|
||||
/// events. Load-bearing bits: the secret gate is fail-closed (an unset app setting rejects
|
||||
/// everything), severity mapping can never escalate on schema drift (unknown → Info), and
|
||||
/// parsing is defensive (only the alert rule name is mandatory).
|
||||
/// </summary>
|
||||
public class AzureMonitorAlertWebhookFunctionTests
|
||||
{
|
||||
// ── SecretMatches ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SecretMatches_accepts_equal_secrets()
|
||||
{
|
||||
Assert.True(AzureMonitorAlertWebhookFunction.SecretMatches("s3cret-value", "s3cret-value"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("wrong", "right")]
|
||||
[InlineData("right-but-longer", "right")]
|
||||
[InlineData("", "configured")]
|
||||
[InlineData(null, "configured")]
|
||||
public void SecretMatches_rejects_mismatch_or_missing_provided(string? provided, string configured)
|
||||
{
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(provided, configured));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void SecretMatches_fails_closed_when_not_configured(string? configured)
|
||||
{
|
||||
// While the app setting is unset, NOTHING matches — not even an empty provided value.
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("anything", configured));
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("", configured));
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(null, configured));
|
||||
}
|
||||
|
||||
// ── MapSeverity ────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Sev0", OpsEventSeverity.Critical)]
|
||||
[InlineData("Sev1", OpsEventSeverity.Error)]
|
||||
[InlineData("Sev2", OpsEventSeverity.Warning)]
|
||||
[InlineData("Sev3", OpsEventSeverity.Info)]
|
||||
[InlineData("Sev4", OpsEventSeverity.Info)]
|
||||
public void MapSeverity_maps_azure_scale_to_ops_scale(string azure, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("Sev5")]
|
||||
[InlineData("Critical")] // someone posting the ops scale directly must not pass through
|
||||
public void MapSeverity_falls_back_to_info_on_unknown_values(string? azure)
|
||||
{
|
||||
Assert.Equal(OpsEventSeverity.Info, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
|
||||
}
|
||||
|
||||
// ── TryParse ───────────────────────────────────────────────────────────
|
||||
|
||||
private const string FullPayload = """
|
||||
{
|
||||
"schemaId": "azureMonitorCommonAlertSchema",
|
||||
"data": {
|
||||
"essentials": {
|
||||
"alertId": "/subscriptions/xxx/providers/Microsoft.AlertsManagement/alerts/abc",
|
||||
"alertRule": "Portal RSC prefetch tail",
|
||||
"severity": "Sev2",
|
||||
"signalType": "Log",
|
||||
"monitorCondition": "Fired",
|
||||
"monitoringService": "Log Alerts V2",
|
||||
"alertTargetIDs": [
|
||||
"/subscriptions/xxx/resourcegroups/rg-autopilotmonitor-prd-gwc/providers/microsoft.insights/components/autopilotmonitorweb"
|
||||
],
|
||||
"description": "RSC prefetches exceeding 10s",
|
||||
"firedDateTime": "2026-07-27T15:30:00.000Z"
|
||||
},
|
||||
"alertContext": {
|
||||
"condition": {
|
||||
"windowSize": "PT1H",
|
||||
"allOf": [
|
||||
{
|
||||
"searchQuery": "dependencies | where name contains '_rsc='",
|
||||
"metricValue": 18.0,
|
||||
"operator": "GreaterThan",
|
||||
"threshold": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void TryParse_extracts_essentials_and_metric_value()
|
||||
{
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(FullPayload);
|
||||
|
||||
Assert.NotNull(alert);
|
||||
Assert.Equal("Portal RSC prefetch tail", alert!.AlertRule);
|
||||
Assert.Equal("Sev2", alert.Severity);
|
||||
Assert.Equal("Fired", alert.MonitorCondition);
|
||||
Assert.Equal("RSC prefetches exceeding 10s", alert.Description);
|
||||
Assert.Equal("Log Alerts V2", alert.MonitoringService);
|
||||
// No targetResourceName in the payload → resource name segment of the first target ID.
|
||||
Assert.Equal("autopilotmonitorweb", alert.TargetResource);
|
||||
Assert.Equal(18.0, alert.MetricValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_prefers_explicit_target_resource_name()
|
||||
{
|
||||
const string payload = """
|
||||
{"data":{"essentials":{"alertRule":"r","targetResourceName":"explicit-name",
|
||||
"alertTargetIDs":["/sub/x/other-name"]}}}
|
||||
""";
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
|
||||
Assert.Equal("explicit-name", alert!.TargetResource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_minimal_payload_needs_only_the_alert_rule()
|
||||
{
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(
|
||||
"""{"data":{"essentials":{"alertRule":"minimal"}}}""");
|
||||
|
||||
Assert.NotNull(alert);
|
||||
Assert.Equal("minimal", alert!.AlertRule);
|
||||
Assert.Null(alert.Severity);
|
||||
Assert.Equal("Fired", alert.MonitorCondition); // default when absent
|
||||
Assert.Null(alert.Description);
|
||||
Assert.Null(alert.MetricValue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("{}")] // no data
|
||||
[InlineData("""{"data":{}}""")] // no essentials
|
||||
[InlineData("""{"data":{"essentials":{}}}""")] // no alertRule
|
||||
[InlineData("""{"data":{"essentials":{"alertRule":""}}}""")] // empty alertRule
|
||||
[InlineData("""{"data":{"essentials":{"alertRule":123}}}""")] // wrong type
|
||||
public void TryParse_rejects_unusable_payloads(string? body)
|
||||
{
|
||||
Assert.Null(AzureMonitorAlertWebhookFunction.TryParse(body!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_tolerates_malformed_alert_context()
|
||||
{
|
||||
// alertContext with unexpected shapes must not break essentials extraction.
|
||||
const string payload = """
|
||||
{"data":{"essentials":{"alertRule":"r","severity":"Sev0"},
|
||||
"alertContext":{"condition":{"allOf":"not-an-array"}}}}
|
||||
""";
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
|
||||
Assert.NotNull(alert);
|
||||
Assert.Equal("Sev0", alert!.Severity);
|
||||
Assert.Null(alert.MetricValue);
|
||||
}
|
||||
}
|
||||
@@ -183,4 +183,13 @@ public class ConfigRedactionTests
|
||||
public void CanViewSecrets_PureDelegatedReader_False()
|
||||
=> Assert.False(GetTenantConfigurationFunction.CanViewSecrets(
|
||||
new RequestContext { IsDelegatedReader = true, TenantId = TenantA, TargetTenantId = TenantB }));
|
||||
|
||||
[Theory]
|
||||
[InlineData(Constants.TenantRoles.Operator)]
|
||||
[InlineData(Constants.TenantRoles.Viewer)]
|
||||
public void CanViewSecrets_OwnTenantOperatorOrViewer_False(string role)
|
||||
// Member-tier read-only Settings view (config GET is MemberRead): an own-tenant
|
||||
// Operator/Viewer arrives with IsTenantAdmin=false ⇒ always the redacted copy.
|
||||
=> Assert.False(GetTenantConfigurationFunction.CanViewSecrets(
|
||||
new RequestContext { UserRole = role, TenantId = TenantA, TargetTenantId = TenantA }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cover for the ConnectionType predicate used on every search path that does NOT push the
|
||||
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
|
||||
/// unpaged scan. Sessions that predate the projection (null ConnectionType) must never match
|
||||
/// a set filter; an unset filter matches everything.
|
||||
/// </summary>
|
||||
public class ConnectionTypeFilterTests
|
||||
{
|
||||
private static SessionSummary Session(string? connectionType) =>
|
||||
new() { SessionId = "s", TenantId = "t", ConnectionType = connectionType };
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("WiFi")]
|
||||
[InlineData("Ethernet")]
|
||||
public void NoFilter_AlwaysMatches(string? connectionType)
|
||||
{
|
||||
var filter = new SessionSearchFilter();
|
||||
Assert.True(TableStorageService.MatchesConnectionType(Session(connectionType), filter));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Ethernet", true)]
|
||||
[InlineData("ethernet", true)] // case-insensitive, matching the other string filters
|
||||
[InlineData("WiFi", false)]
|
||||
[InlineData(null, false)] // legacy session without the projected column
|
||||
public void EthernetFilter_MatchesOnlyEthernet(string? connectionType, bool expected)
|
||||
{
|
||||
var filter = new SessionSearchFilter { ConnectionType = "Ethernet" };
|
||||
Assert.Equal(expected, TableStorageService.MatchesConnectionType(Session(connectionType), filter));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("WiFi", true)]
|
||||
[InlineData("Ethernet", false)]
|
||||
[InlineData(null, false)]
|
||||
public void WiFiFilter_MatchesOnlyWiFi(string? connectionType, bool expected)
|
||||
{
|
||||
var filter = new SessionSearchFilter { ConnectionType = "WiFi" };
|
||||
Assert.Equal(expected, TableStorageService.MatchesConnectionType(Session(connectionType), filter));
|
||||
}
|
||||
}
|
||||
@@ -324,4 +324,42 @@ public class CrossTenantAccessTests
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
|
||||
}
|
||||
|
||||
// ── Cross-tenant rule WRITES: global/rules/{gather,analyze}/{ruleId} ──
|
||||
// Bug (2026-07-24): a Global Admin editing a foreign tenant's rule via the JWT-scoped
|
||||
// rules/{type}/{ruleId} route silently upserted into the GA's OWN tenant (200 → false "saved");
|
||||
// the reload re-read the foreign tenant and showed the unchanged rule. Fix: dedicated cross-tenant
|
||||
// write routes. They MUST be GlobalAdminOnly (a write tier → no delegated-read rescue, so a
|
||||
// tenant-admin / delegated caller can never cross into another tenant's rules) + QueryParam (the
|
||||
// tenant is named by ?tenantId=, not the {ruleId} segment). A downgrade to a tenant tier, or a slip
|
||||
// to TenantScoping.None (which would ignore ?tenantId= and re-introduce the home-tenant misroute),
|
||||
// is caught here.
|
||||
|
||||
[Theory]
|
||||
[InlineData("PUT", "global/rules/gather/{ruleId}")]
|
||||
[InlineData("DELETE", "global/rules/gather/{ruleId}")]
|
||||
[InlineData("PUT", "global/rules/analyze/{ruleId}")]
|
||||
[InlineData("DELETE", "global/rules/analyze/{ruleId}")]
|
||||
public void GlobalRuleWriteRoutes_AreGlobalAdminOnly_QueryParam(string httpMethod, string routeTemplate)
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.Entries
|
||||
.FirstOrDefault(e => e.HttpMethod == httpMethod.ToUpperInvariant() && e.RouteTemplate == routeTemplate);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry!.Policy);
|
||||
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PUT", "/api/global/rules/gather/rule-1")]
|
||||
[InlineData("DELETE", "/api/global/rules/gather/rule-1")]
|
||||
[InlineData("PUT", "/api/global/rules/analyze/rule-1")]
|
||||
[InlineData("DELETE", "/api/global/rules/analyze/rule-1")]
|
||||
public void GlobalRuleWrite_CrossTenant_GlobalAdmin_IsAllowed(string httpMethod, string path)
|
||||
{
|
||||
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: true, queryTenantId: TenantB);
|
||||
|
||||
Assert.False(isBlocked, $"Global Admin should reach the target tenant on the cross-tenant write route: {httpMethod} {path}");
|
||||
Assert.Equal(TenantB, targetTenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +75,9 @@ public class DeletionManifestBuilderTests
|
||||
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
|
||||
Assert.Equal(11, c["signalsByKind"]);
|
||||
|
||||
// 15 cascade tables + tombstone = 16 steps; no inventory steps when no contributions row.
|
||||
Assert.Equal(16, manifest.Steps.Count);
|
||||
// 16 cascade tables (incl. SessionTimeBreakdowns, F1 PR2) + tombstone = 17 steps;
|
||||
// no inventory steps when no contributions row.
|
||||
Assert.Equal(17, manifest.Steps.Count);
|
||||
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
|
||||
}
|
||||
|
||||
@@ -102,7 +103,8 @@ public class DeletionManifestBuilderTests
|
||||
.WithSessionsByStage(1, propValue: "stage_marker")
|
||||
.WithDeadEndsByReason(1, propValue: "deadend_marker")
|
||||
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
|
||||
.WithSignalsByKind(1, propValue: "signalkind_marker"));
|
||||
.WithSignalsByKind(1, propValue: "signalkind_marker")
|
||||
.WithSessionTimeBreakdown(propValue: "breakdown_marker"));
|
||||
|
||||
var manifest = await NewBuilder(reader).BuildAsync(
|
||||
TenantId, SessionId, "admin_delete",
|
||||
@@ -145,8 +147,8 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 15 cascade-table steps.
|
||||
Assert.Equal(15, nonTombstoneSteps.Count);
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 16 cascade-table steps.
|
||||
Assert.Equal(16, nonTombstoneSteps.Count);
|
||||
foreach (var step in nonTombstoneSteps)
|
||||
{
|
||||
Assert.Equal(0, step.RowCount);
|
||||
@@ -175,8 +177,8 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
// Expect: 15 cascade + 2 inventory + 1 tombstone = 18 steps.
|
||||
Assert.Equal(18, manifest.Steps.Count);
|
||||
// Expect: 16 cascade + 2 inventory + 1 tombstone = 19 steps.
|
||||
Assert.Equal(19, manifest.Steps.Count);
|
||||
|
||||
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
|
||||
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
|
||||
@@ -640,6 +642,7 @@ public class DeletionManifestBuilderTests
|
||||
private TableEntity? _vulnerabilityReport;
|
||||
private TableEntity? _deviceSnapshot;
|
||||
private TableEntity? _eventSessionIndex;
|
||||
private TableEntity? _sessionTimeBreakdown;
|
||||
private TableEntity? _sessionInventoryContributions;
|
||||
private string _sessionsIndexRowKey = "INDEX_" + SessionId;
|
||||
|
||||
@@ -719,6 +722,16 @@ public class DeletionManifestBuilderTests
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReaderSeed WithSessionTimeBreakdown(string propValue = "tab")
|
||||
{
|
||||
_sessionTimeBreakdown = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["Marker"] = propValue,
|
||||
};
|
||||
_sessionTimeBreakdown.ETag = new ETag("0xTAB");
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReaderSeed WithEventTypeIndex(int n, string propValue = "eti")
|
||||
{
|
||||
// PK = {tenantId}_{eventType}, RK ends with _{sessionId}.
|
||||
@@ -796,6 +809,8 @@ public class DeletionManifestBuilderTests
|
||||
.ReturnsAsync(_deviceSnapshot);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.EventSessionIndex, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_eventSessionIndex);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionTimeBreakdowns, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionTimeBreakdown);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionInventoryContributions);
|
||||
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// F2 PR4 (insights spec §F2): device-key normalization (junk serials), terminal-only chain
|
||||
/// maintenance (upsert, cap, deletion-ref pruning), journey grouping (first-success end,
|
||||
/// 30-day gap, Incomplete attempts, WG-Pending stays open), the pure daily FTR aggregation
|
||||
/// core (<see cref="MaintenanceService.BuildDeviceJourneyAggregates"/>) and the persistence
|
||||
/// round-trips (table-storage-serialization rule — every property must survive Store→Map).
|
||||
/// </summary>
|
||||
public class DeviceJourneyAndFtrTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 7, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
private const string TenantA = "aaaaaaaa-0000-0000-0000-000000000001";
|
||||
private const string TenantB = "bbbbbbbb-0000-0000-0000-000000000002";
|
||||
|
||||
private static SessionSummary Session(
|
||||
string sessionId,
|
||||
SessionStatus status = SessionStatus.Succeeded,
|
||||
DateTime? startedAt = null,
|
||||
DateTime? completedAt = null,
|
||||
int? durationSeconds = 1800,
|
||||
string? serial = "PF4X1ABC",
|
||||
string tenantId = TenantA,
|
||||
bool wg = false,
|
||||
string enrollmentType = "v1",
|
||||
string? adminMarkedAction = null)
|
||||
=> new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SessionId = sessionId,
|
||||
SerialNumber = serial!,
|
||||
Status = status,
|
||||
StartedAt = startedAt ?? T0,
|
||||
CompletedAt = completedAt ?? (startedAt ?? T0).AddSeconds(durationSeconds ?? 0),
|
||||
DurationSeconds = durationSeconds,
|
||||
IsPreProvisioned = wg,
|
||||
EnrollmentType = enrollmentType,
|
||||
AdminMarkedAction = adminMarkedAction,
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Laptop 5",
|
||||
};
|
||||
|
||||
private static DeviceSessionRef Ref(
|
||||
string sessionId, SessionStatus status, DateTime startedAt, DateTime? completedAt = null)
|
||||
=> new()
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Status = status.ToString(),
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt ?? startedAt.AddMinutes(30),
|
||||
};
|
||||
|
||||
// ── serial normalization / junk exclusion ───────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("abc")] // shorter than 4 chars
|
||||
[InlineData("0")]
|
||||
[InlineData("None")]
|
||||
[InlineData("INVALID")]
|
||||
[InlineData("Unknown")] // the agent's WMI-failure sentinel (audit Q5)
|
||||
[InlineData("unknown")]
|
||||
[InlineData("System Serial Number")]
|
||||
[InlineData("TO BE FILLED BY O.E.M.")]
|
||||
[InlineData("Default String")]
|
||||
[InlineData(" Default string ")] // junk match happens after trim + case-fold
|
||||
public void NormalizeSerial_JunkAndPlaceholders_YieldNull(string? raw)
|
||||
{
|
||||
Assert.Null(DeviceJourneyCalculator.NormalizeSerial(raw));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PF4X1ABC", "pf4x1abc")]
|
||||
[InlineData(" PF4X1ABC ", "pf4x1abc")] // trim
|
||||
[InlineData("pf4x1abc", "pf4x1abc")]
|
||||
[InlineData("1234", "1234")] // exactly 4 chars is a valid identity
|
||||
public void NormalizeSerial_RealSerials_TrimAndCaseFold(string raw, string expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyCalculator.NormalizeSerial(raw));
|
||||
}
|
||||
|
||||
// ── terminal set / ref building ─────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionStatus.Succeeded, true)]
|
||||
[InlineData(SessionStatus.Failed, true)]
|
||||
[InlineData(SessionStatus.Incomplete, true)]
|
||||
[InlineData(SessionStatus.InProgress, false)]
|
||||
[InlineData(SessionStatus.Pending, false)] // WG part 1 sealed — an OPEN session
|
||||
[InlineData(SessionStatus.Stalled, false)]
|
||||
[InlineData(SessionStatus.AwaitingUser, false)]
|
||||
[InlineData(SessionStatus.Unknown, false)]
|
||||
public void IsTerminal_MatchesTheF2TerminalSet(SessionStatus status, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyCalculator.IsTerminal(status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSessionRef_MapsAllFields_DurationIsAuthoritativeDurationSeconds()
|
||||
{
|
||||
// CompletedAt deliberately diverges from StartedAt + DurationSeconds (25 % of terminal
|
||||
// sessions in production) — the ref must carry DurationSeconds verbatim.
|
||||
var session = Session("s-1", startedAt: T0, completedAt: T0.AddHours(5), durationSeconds: 1800,
|
||||
wg: true, enrollmentType: "v1", adminMarkedAction: "Succeeded");
|
||||
|
||||
var reference = DeviceJourneyCalculator.BuildSessionRef(session)!;
|
||||
|
||||
Assert.Equal("s-1", reference.SessionId);
|
||||
Assert.Equal(T0, reference.StartedAt);
|
||||
Assert.Equal(T0.AddHours(5), reference.CompletedAt);
|
||||
Assert.Equal("Succeeded", reference.Status);
|
||||
Assert.Equal("v1", reference.EnrollmentType);
|
||||
Assert.True(reference.IsPreProvisioned);
|
||||
Assert.Equal(1800, reference.DurationSeconds);
|
||||
Assert.True(reference.AdminMarked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSessionRef_WhiteGlovePending_YieldsNull_NeverAnAttempt()
|
||||
{
|
||||
var pending = Session("s-1", status: SessionStatus.Pending, wg: true, durationSeconds: null);
|
||||
Assert.Null(DeviceJourneyCalculator.BuildSessionRef(pending));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSessionRef_Incomplete_KeepsNullDuration()
|
||||
{
|
||||
var incomplete = Session("s-1", status: SessionStatus.Incomplete, durationSeconds: null);
|
||||
var reference = DeviceJourneyCalculator.BuildSessionRef(incomplete)!;
|
||||
Assert.Equal("Incomplete", reference.Status);
|
||||
Assert.Null(reference.DurationSeconds);
|
||||
Assert.False(reference.AdminMarked);
|
||||
}
|
||||
|
||||
// ── chain maintenance ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MergeChain_UpsertsBySessionId_ReclassificationReplacesTheEntry()
|
||||
{
|
||||
var existing = new List<DeviceSessionRef> { Ref("s-1", SessionStatus.Incomplete, T0) };
|
||||
var update = Ref("s-1", SessionStatus.Succeeded, T0);
|
||||
|
||||
var merged = DeviceJourneyCalculator.MergeChain(existing, new[] { update });
|
||||
|
||||
var entry = Assert.Single(merged);
|
||||
Assert.Equal("Succeeded", entry.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeChain_OrdersByStartedAt_TiebreakSessionId_AndCapsToMostRecent()
|
||||
{
|
||||
var refs = new List<DeviceSessionRef>();
|
||||
for (var i = 0; i < 25; i++)
|
||||
refs.Add(Ref($"s-{i:D2}", SessionStatus.Failed, T0.AddDays(i)));
|
||||
// Same StartedAt as s-24 — the ordinal sessionId tiebreak must keep the order stable.
|
||||
refs.Add(Ref("s-00-twin", SessionStatus.Failed, T0.AddDays(24)));
|
||||
|
||||
var merged = DeviceJourneyCalculator.MergeChain(null, refs);
|
||||
|
||||
Assert.Equal(20, merged.Count); // cap 20 most recent
|
||||
Assert.Equal("s-06", merged[0].SessionId); // s-00..s-05 dropped (oldest)
|
||||
Assert.Equal(T0.AddDays(24), merged[^1].StartedAt);
|
||||
Assert.Equal("s-24", merged[^1].SessionId); // "s-00-twin" < "s-24" ordinal → sorts first
|
||||
Assert.Equal("s-00-twin", merged[^2].SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeChain_DropsNonTerminalRefs_Defensively()
|
||||
{
|
||||
var corrupt = new List<DeviceSessionRef>
|
||||
{
|
||||
Ref("s-1", SessionStatus.Succeeded, T0),
|
||||
new() { SessionId = "s-2", Status = "Pending", StartedAt = T0.AddDays(1) },
|
||||
new() { SessionId = "s-3", Status = "garbage", StartedAt = T0.AddDays(2) },
|
||||
};
|
||||
|
||||
var merged = DeviceJourneyCalculator.MergeChain(corrupt, Array.Empty<DeviceSessionRef>());
|
||||
|
||||
Assert.Equal("s-1", Assert.Single(merged).SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveSessionRefs_DropsDeletedSessions_EmptyChainSignalsRowDeletion()
|
||||
{
|
||||
var chain = new List<DeviceSessionRef>
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Succeeded, T0.AddDays(1)),
|
||||
};
|
||||
|
||||
var partial = DeviceJourneyCalculator.RemoveSessionRefs(
|
||||
chain, new HashSet<string>(StringComparer.Ordinal) { "s-1" });
|
||||
Assert.Equal("s-2", Assert.Single(partial).SessionId);
|
||||
|
||||
var empty = DeviceJourneyCalculator.RemoveSessionRefs(
|
||||
chain, new HashSet<string>(StringComparer.Ordinal) { "s-1", "s-2" });
|
||||
Assert.Empty(empty);
|
||||
}
|
||||
|
||||
// ── journey grouping ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_SingleSuccess_IsOneCompletedFirstTimeRightJourney()
|
||||
{
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(
|
||||
new[] { Ref("s-1", SessionStatus.Succeeded, T0) });
|
||||
|
||||
var journey = Assert.Single(journeys);
|
||||
Assert.True(journey.Completed);
|
||||
Assert.Single(journey.Attempts);
|
||||
Assert.Equal("s-1", journey.CompletingRef!.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_FailAndIncompleteAreAttempts_JourneyEndsWithFirstSuccess()
|
||||
{
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Incomplete, T0.AddHours(2)),
|
||||
Ref("s-3", SessionStatus.Succeeded, T0.AddHours(4)),
|
||||
});
|
||||
|
||||
var journey = Assert.Single(journeys);
|
||||
Assert.True(journey.Completed);
|
||||
Assert.Equal(3, journey.Attempts.Count); // Incomplete is a non-successful attempt
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_SessionAfterSuccess_StartsANewJourney_Redeployment()
|
||||
{
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Succeeded, T0),
|
||||
Ref("s-2", SessionStatus.Failed, T0.AddDays(2)),
|
||||
});
|
||||
|
||||
Assert.Equal(2, journeys.Count);
|
||||
Assert.True(journeys[0].Completed);
|
||||
Assert.False(journeys[1].Completed); // open — no failed verdict, just not done yet
|
||||
Assert.Null(journeys[1].CompletingRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_FailuresOnly_JourneyStaysOpen_NeverCompleted()
|
||||
{
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Failed, T0.AddDays(1)),
|
||||
});
|
||||
|
||||
var journey = Assert.Single(journeys);
|
||||
Assert.False(journey.Completed);
|
||||
Assert.Equal(2, journey.Attempts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_GapOver30Days_StartsANewJourney_AbandonedStaysUncompleted()
|
||||
{
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0, completedAt: T0.AddHours(1)),
|
||||
Ref("s-2", SessionStatus.Failed, T0.AddHours(1).AddDays(31)),
|
||||
Ref("s-3", SessionStatus.Succeeded, T0.AddHours(1).AddDays(31).AddHours(3)),
|
||||
});
|
||||
|
||||
Assert.Equal(2, journeys.Count);
|
||||
Assert.False(journeys[0].Completed); // shelved/abandoned — never counts for FTR
|
||||
Assert.Single(journeys[0].Attempts);
|
||||
Assert.True(journeys[1].Completed);
|
||||
Assert.Equal(2, journeys[1].Attempts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_GapBoundary_Exactly30DaysStaysOneJourney_StrictlyMoreSplits()
|
||||
{
|
||||
var prevEnd = T0.AddHours(1);
|
||||
|
||||
var exactly30 = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0, completedAt: prevEnd),
|
||||
Ref("s-2", SessionStatus.Failed, prevEnd.AddDays(30)),
|
||||
});
|
||||
Assert.Single(exactly30); // spec: "> 30 days" — the boundary itself does not split
|
||||
|
||||
var over30 = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0, completedAt: prevEnd),
|
||||
Ref("s-2", SessionStatus.Failed, prevEnd.AddDays(30).AddHours(1)),
|
||||
});
|
||||
Assert.Equal(2, over30.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_GapMeasuredFromCompletedAt_NotStartedAt()
|
||||
{
|
||||
// Previous attempt STARTED 40 days before the next one but went terminal only 10 days
|
||||
// before it — the gap is 10 days, one journey. StartedAt-based measuring would split.
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0.AddDays(-40), completedAt: T0.AddDays(-10)),
|
||||
Ref("s-2", SessionStatus.Succeeded, T0),
|
||||
});
|
||||
|
||||
var journey = Assert.Single(journeys);
|
||||
Assert.Equal(2, journey.Attempts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_WhiteGloveIsOneSessionRow_OneAttempt_NoStitching()
|
||||
{
|
||||
// WG part 1 + part 2 share one session row (second-sweep finding): a completed WG
|
||||
// enrollment is exactly one ref and one first-time-right attempt.
|
||||
var wg = Session("s-1", wg: true, durationSeconds: 5400, completedAt: T0.AddDays(6));
|
||||
var reference = DeviceJourneyCalculator.BuildSessionRef(wg)!;
|
||||
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[] { reference });
|
||||
|
||||
var journey = Assert.Single(journeys);
|
||||
Assert.True(journey.Completed);
|
||||
Assert.Single(journey.Attempts);
|
||||
Assert.Equal(5400, journey.Attempts[0].DurationSeconds); // pause-free combined duration, verbatim
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Derive_CurrentJourneyAttempts_IsTheLastJourneys_OpenOrCompleted()
|
||||
{
|
||||
var (journeyCount, currentAttempts) = DeviceJourneyCalculator.Derive(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Succeeded, T0),
|
||||
Ref("s-2", SessionStatus.Failed, T0.AddDays(1)),
|
||||
Ref("s-3", SessionStatus.Failed, T0.AddDays(2)),
|
||||
});
|
||||
|
||||
Assert.Equal(2, journeyCount);
|
||||
Assert.Equal(2, currentAttempts); // the open redeployment journey has 2 attempts so far
|
||||
|
||||
Assert.Equal((0, 0), DeviceJourneyCalculator.Derive(Array.Empty<DeviceSessionRef>()));
|
||||
}
|
||||
|
||||
// ── history row assembly (display precedence) ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildDeviceHistoryRow_DisplayFields_FollowTheNewestChainEntry()
|
||||
{
|
||||
var newest = Session("s-2", serial: " PF4X1ABC ", startedAt: T0.AddDays(1));
|
||||
var chain = new List<DeviceSessionRef>
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Succeeded, T0.AddDays(1)),
|
||||
};
|
||||
|
||||
var row = TableStorageService.BuildDeviceHistoryRow(TenantA, "pf4x1abc", chain, existing: null, newest);
|
||||
|
||||
Assert.Equal("PF4X1ABC", row.SerialNumber); // trimmed original casing
|
||||
Assert.Equal("Contoso", row.Manufacturer);
|
||||
Assert.Equal("Laptop 5", row.Model);
|
||||
Assert.Equal(2, row.CurrentJourneyAttempts);
|
||||
Assert.Equal(1, row.JourneyCount);
|
||||
Assert.Equal(DeviceJourneyCalculator.CurrentVersion, row.JourneyVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDeviceHistoryRow_BackfillingAnOlderSession_KeepsExistingDisplayFields()
|
||||
{
|
||||
var older = Session("s-1", serial: "pf4x1abc", startedAt: T0);
|
||||
older.Model = "Laptop 4"; // stale model from the older enrollment
|
||||
var chain = new List<DeviceSessionRef>
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Succeeded, T0.AddDays(1)),
|
||||
};
|
||||
var existing = new DeviceHistory
|
||||
{
|
||||
TenantId = TenantA, SerialKey = "pf4x1abc",
|
||||
SerialNumber = "PF4X1ABC", Manufacturer = "Contoso", Model = "Laptop 5",
|
||||
};
|
||||
|
||||
var row = TableStorageService.BuildDeviceHistoryRow(TenantA, "pf4x1abc", chain, existing, older);
|
||||
|
||||
Assert.Equal("PF4X1ABC", row.SerialNumber); // not regressed to the older session's data
|
||||
Assert.Equal("Laptop 5", row.Model);
|
||||
}
|
||||
|
||||
// ── persistence round-trips ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DeviceHistoryEntity_RoundTripsAllFields_IncludingChainJson()
|
||||
{
|
||||
var history = new DeviceHistory
|
||||
{
|
||||
TenantId = TenantA,
|
||||
SerialKey = "pf4x1abc",
|
||||
SerialNumber = "PF4X1ABC",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Laptop 5",
|
||||
CurrentJourneyAttempts = 2,
|
||||
JourneyCount = 3,
|
||||
JourneyVersion = 1,
|
||||
LastUpdated = T0,
|
||||
Chain = new List<DeviceSessionRef>
|
||||
{
|
||||
new()
|
||||
{
|
||||
SessionId = "s-1", StartedAt = T0, CompletedAt = T0.AddHours(5),
|
||||
Status = "Succeeded", EnrollmentType = "v2", IsPreProvisioned = true,
|
||||
DurationSeconds = 1800, AdminMarked = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
SessionId = "s-2", StartedAt = T0.AddDays(1), CompletedAt = null,
|
||||
Status = "Incomplete", EnrollmentType = "v1", DurationSeconds = null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var mapped = TableStorageService.MapToDeviceHistory(
|
||||
TableStorageService.BuildDeviceHistoryEntity(history));
|
||||
|
||||
Assert.Equal(TenantA, mapped.TenantId);
|
||||
Assert.Equal("pf4x1abc", mapped.SerialKey);
|
||||
Assert.Equal("PF4X1ABC", mapped.SerialNumber);
|
||||
Assert.Equal("Contoso", mapped.Manufacturer);
|
||||
Assert.Equal("Laptop 5", mapped.Model);
|
||||
Assert.Equal(2, mapped.CurrentJourneyAttempts);
|
||||
Assert.Equal(3, mapped.JourneyCount);
|
||||
Assert.Equal(1, mapped.JourneyVersion);
|
||||
Assert.Equal(T0, mapped.LastUpdated);
|
||||
|
||||
Assert.Equal(2, mapped.Chain.Count);
|
||||
var first = mapped.Chain[0];
|
||||
Assert.Equal("s-1", first.SessionId);
|
||||
Assert.Equal(T0, first.StartedAt);
|
||||
Assert.Equal(T0.AddHours(5), first.CompletedAt);
|
||||
Assert.Equal("Succeeded", first.Status);
|
||||
Assert.Equal("v2", first.EnrollmentType);
|
||||
Assert.True(first.IsPreProvisioned);
|
||||
Assert.Equal(1800, first.DurationSeconds);
|
||||
Assert.True(first.AdminMarked);
|
||||
var second = mapped.Chain[1];
|
||||
Assert.Null(second.CompletedAt); // Incomplete: no terminal timestamp requirement
|
||||
Assert.Null(second.DurationSeconds); // Incomplete: deliberately no duration — stays null
|
||||
Assert.False(second.AdminMarked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceHistoryEntity_RowKeyEncoding_MakesForbiddenKeyCharsSafe_AndRoundTrips()
|
||||
{
|
||||
// '/', '\', '#', '?' are forbidden in Table Storage keys — a defensive encoding keeps a
|
||||
// pathological serial writable, and the mapper restores the exact normalized serial.
|
||||
var weird = "ab/cd\\ef#1?x";
|
||||
var history = new DeviceHistory { TenantId = TenantA, SerialKey = weird };
|
||||
|
||||
var entity = TableStorageService.BuildDeviceHistoryEntity(history);
|
||||
Assert.All(new[] { "/", "\\", "#", "?" }, c => Assert.DoesNotContain(c, entity.RowKey));
|
||||
|
||||
Assert.Equal(weird, TableStorageService.MapToDeviceHistory(entity).SerialKey);
|
||||
|
||||
// Ordinary serials stay human-readable.
|
||||
Assert.Equal("pf4x1abc", TableStorageService.SerialRowKey("pf4x1abc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceJourneyAggregateEntity_RoundTripsAllFields_RowKeyIsTheDate()
|
||||
{
|
||||
var aggregate = new DeviceJourneyDailyAggregate
|
||||
{
|
||||
TenantId = TenantA,
|
||||
Date = "2026-07-20",
|
||||
JourneyVersion = 1,
|
||||
CompletedJourneyCount = 23,
|
||||
FirstTimeRightCount = 19,
|
||||
ExcludedSessionCount = 2,
|
||||
ComputedAt = T0,
|
||||
AttemptHistogram = new List<DeviceJourneyAttemptBucket>
|
||||
{
|
||||
new() { Attempts = 1, JourneyCount = 19 },
|
||||
new() { Attempts = 2, JourneyCount = 3 },
|
||||
new() { Attempts = 4, JourneyCount = 1 },
|
||||
},
|
||||
};
|
||||
|
||||
var entity = TableStorageService.BuildDeviceJourneyAggregateEntity(aggregate);
|
||||
Assert.Equal("2026-07-20", entity.RowKey);
|
||||
|
||||
var mapped = TableStorageService.MapToDeviceJourneyAggregate(entity);
|
||||
Assert.Equal(TenantA, mapped.TenantId);
|
||||
Assert.Equal("2026-07-20", mapped.Date);
|
||||
Assert.Equal(1, mapped.JourneyVersion);
|
||||
Assert.Equal(23, mapped.CompletedJourneyCount);
|
||||
Assert.Equal(19, mapped.FirstTimeRightCount);
|
||||
Assert.Equal(2, mapped.ExcludedSessionCount);
|
||||
Assert.Equal(T0, mapped.ComputedAt);
|
||||
Assert.Equal(3, mapped.AttemptHistogram.Count);
|
||||
Assert.Equal(1, mapped.AttemptHistogram[0].Attempts);
|
||||
Assert.Equal(19, mapped.AttemptHistogram[0].JourneyCount);
|
||||
Assert.Equal(4, mapped.AttemptHistogram[2].Attempts);
|
||||
}
|
||||
|
||||
// ── daily FTR aggregation core ──────────────────────────────────────────
|
||||
|
||||
private static readonly DateTime WindowStart = T0.Date.AddDays(-30);
|
||||
private static readonly DateTime WindowEnd = T0.Date.AddDays(1);
|
||||
|
||||
private static DeviceHistory History(string tenantId, params DeviceSessionRef[] chain)
|
||||
=> new() { TenantId = tenantId, SerialKey = Guid.NewGuid().ToString("N"), Chain = chain.ToList() };
|
||||
|
||||
[Fact]
|
||||
public void FtrAggregates_BucketByCompletingSessionsStartDate_AndMirrorGlobal()
|
||||
{
|
||||
var histories = new List<DeviceHistory>
|
||||
{
|
||||
// Tenant A device 1: fail → success (2 attempts, completes on T0 date).
|
||||
History(TenantA,
|
||||
Ref("a1-1", SessionStatus.Failed, T0.AddDays(-1)),
|
||||
Ref("a1-2", SessionStatus.Succeeded, T0)),
|
||||
// Tenant A device 2: first-time right on the same date.
|
||||
History(TenantA, Ref("a2-1", SessionStatus.Succeeded, T0.AddHours(2))),
|
||||
// Tenant B device: first-time right on a different date.
|
||||
History(TenantB, Ref("b1-1", SessionStatus.Succeeded, T0.AddDays(-3))),
|
||||
};
|
||||
|
||||
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
|
||||
histories, Array.Empty<SessionSummary>(), WindowStart, WindowEnd, T0);
|
||||
|
||||
// Tenant A (1 date) + Tenant B (1 date) + global (2 dates) = 4 rows.
|
||||
Assert.Equal(4, rows.Count);
|
||||
|
||||
var tenantARow = rows.Single(r => r.TenantId == TenantA);
|
||||
Assert.Equal(T0.ToString("yyyy-MM-dd"), tenantARow.Date);
|
||||
Assert.Equal(2, tenantARow.CompletedJourneyCount);
|
||||
Assert.Equal(1, tenantARow.FirstTimeRightCount);
|
||||
Assert.Equal(DeviceJourneyCalculator.CurrentVersion, tenantARow.JourneyVersion);
|
||||
Assert.Equal(2, tenantARow.AttemptHistogram.Count);
|
||||
Assert.Equal(1, tenantARow.AttemptHistogram[0].Attempts);
|
||||
Assert.Equal(1, tenantARow.AttemptHistogram[0].JourneyCount);
|
||||
Assert.Equal(2, tenantARow.AttemptHistogram[1].Attempts);
|
||||
|
||||
var globalOnT0 = rows.Single(r => r.TenantId == "global" && r.Date == T0.ToString("yyyy-MM-dd"));
|
||||
Assert.Equal(2, globalOnT0.CompletedJourneyCount); // tenant B's journey sits on its own date
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FtrAggregates_OpenAndAbandonedJourneys_NeverCount()
|
||||
{
|
||||
var histories = new List<DeviceHistory>
|
||||
{
|
||||
// Open journey: failures only (e.g. a WG device still waiting for its user session
|
||||
// has, at most, prior failed attempts in the chain — the Pending row itself never
|
||||
// becomes a ref).
|
||||
History(TenantA, Ref("a1-1", SessionStatus.Failed, T0)),
|
||||
// Abandoned journey (31d gap, no success) followed by a completed journey.
|
||||
History(TenantA,
|
||||
Ref("a2-1", SessionStatus.Failed, T0.AddDays(-20), completedAt: T0.AddDays(-20).AddHours(1)),
|
||||
Ref("a2-2", SessionStatus.Succeeded, T0.AddDays(11).AddHours(2))),
|
||||
};
|
||||
|
||||
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
|
||||
histories, Array.Empty<SessionSummary>(), WindowStart, T0.Date.AddDays(12), T0);
|
||||
|
||||
// Only the completed journey produced rows: tenant + global on one date.
|
||||
Assert.Equal(2, rows.Count);
|
||||
var tenantRow = rows.Single(r => r.TenantId == TenantA);
|
||||
Assert.Equal(1, tenantRow.CompletedJourneyCount);
|
||||
Assert.Equal(1, tenantRow.FirstTimeRightCount); // the abandoned attempt belongs to a DIFFERENT journey
|
||||
Assert.Equal(T0.AddDays(11).ToString("yyyy-MM-dd"), tenantRow.Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FtrAggregates_JourneysCompletedOutsideTheWindow_AreSkipped()
|
||||
{
|
||||
var histories = new List<DeviceHistory>
|
||||
{
|
||||
History(TenantA, Ref("a1-1", SessionStatus.Succeeded, WindowStart.AddDays(-1))),
|
||||
};
|
||||
|
||||
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
|
||||
histories, Array.Empty<SessionSummary>(), WindowStart, WindowEnd, T0);
|
||||
|
||||
Assert.Empty(rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FtrAggregates_JunkSerialExclusions_ProduceDisclosureRows()
|
||||
{
|
||||
var excluded = new List<SessionSummary>
|
||||
{
|
||||
Session("x-1", serial: "Unknown", startedAt: T0),
|
||||
Session("x-2", serial: "Unknown", startedAt: T0.AddHours(1)),
|
||||
Session("x-3", serial: "Unknown", startedAt: WindowStart.AddDays(-2)), // outside window — ignored
|
||||
};
|
||||
|
||||
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
|
||||
Array.Empty<DeviceHistory>(), excluded, WindowStart, WindowEnd, T0);
|
||||
|
||||
Assert.Equal(2, rows.Count); // tenant + global disclosure rows, no journey claim
|
||||
var tenantRow = rows.Single(r => r.TenantId == TenantA);
|
||||
Assert.Equal(0, tenantRow.CompletedJourneyCount);
|
||||
Assert.Equal(0, tenantRow.FirstTimeRightCount);
|
||||
Assert.Empty(tenantRow.AttemptHistogram);
|
||||
Assert.Equal(2, tenantRow.ExcludedSessionCount);
|
||||
Assert.Equal(2, rows.Single(r => r.TenantId == "global").ExcludedSessionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FtrAggregates_TwoCompletedJourneysOfOneDevice_SameDay_BothCount()
|
||||
{
|
||||
// Redeploy on the same day: success closes journey 1, the next success closes journey 2.
|
||||
var histories = new List<DeviceHistory>
|
||||
{
|
||||
History(TenantA,
|
||||
Ref("a1-1", SessionStatus.Succeeded, T0),
|
||||
Ref("a1-2", SessionStatus.Succeeded, T0.AddHours(3))),
|
||||
};
|
||||
|
||||
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
|
||||
histories, Array.Empty<SessionSummary>(), WindowStart, WindowEnd, T0);
|
||||
|
||||
var tenantRow = rows.Single(r => r.TenantId == TenantA);
|
||||
Assert.Equal(2, tenantRow.CompletedJourneyCount);
|
||||
Assert.Equal(2, tenantRow.FirstTimeRightCount);
|
||||
}
|
||||
|
||||
// ── attempt number (session banner, PR5) ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeAttemptNumber_TerminalSessionInChain_ReturnsPositionWithinItsJourney()
|
||||
{
|
||||
var chain = new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Succeeded, T0.AddHours(2)),
|
||||
Ref("s-3", SessionStatus.Failed, T0.AddDays(5)), // redeployment → new journey
|
||||
};
|
||||
|
||||
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-1", T0));
|
||||
Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddHours(2)));
|
||||
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-3", T0.AddDays(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAttemptNumber_LiveSession_ContinuesTheOpenJourney()
|
||||
{
|
||||
var chain = new[] { Ref("s-1", SessionStatus.Failed, T0) };
|
||||
// s-2 is still InProgress — not a chain ref; the virtual-attempt rule places it.
|
||||
Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddDays(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAttemptNumber_LiveSession_AfterCompletedJourney_IsAttemptOne()
|
||||
{
|
||||
var chain = new[] { Ref("s-1", SessionStatus.Succeeded, T0) };
|
||||
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddDays(2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAttemptNumber_LiveSession_AfterThirtyDayGap_IsAttemptOne()
|
||||
{
|
||||
var prevEnd = T0.AddHours(1);
|
||||
var chain = new[] { Ref("s-1", SessionStatus.Failed, T0, completedAt: prevEnd) };
|
||||
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", prevEnd.AddDays(31)));
|
||||
// Inside the gap boundary the open journey continues instead.
|
||||
Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-3", prevEnd.AddDays(29)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAttemptNumber_EmptyChain_YieldsNull_NoGuessedPosition()
|
||||
{
|
||||
Assert.Null(DeviceJourneyCalculator.ComputeAttemptNumber(
|
||||
Array.Empty<DeviceSessionRef>(), "s-1", T0));
|
||||
}
|
||||
|
||||
// ── fleet response helpers (PR5) ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SumAggregates_SumsAdditiveCounts_AndMergesHistograms()
|
||||
{
|
||||
var daily = new List<DeviceJourneyDailyAggregate>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CompletedJourneyCount = 2, FirstTimeRightCount = 1, ExcludedSessionCount = 1,
|
||||
AttemptHistogram = new List<DeviceJourneyAttemptBucket>
|
||||
{
|
||||
new() { Attempts = 1, JourneyCount = 1 },
|
||||
new() { Attempts = 2, JourneyCount = 1 },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
CompletedJourneyCount = 1, FirstTimeRightCount = 1, ExcludedSessionCount = 0,
|
||||
AttemptHistogram = new List<DeviceJourneyAttemptBucket> { new() { Attempts = 1, JourneyCount = 1 } },
|
||||
},
|
||||
};
|
||||
|
||||
var totals = DeviceJourneyMetricsResponse.SumAggregates(daily);
|
||||
|
||||
Assert.Equal(3, totals.CompletedJourneys);
|
||||
Assert.Equal(2, totals.FirstTimeRight);
|
||||
Assert.Equal(66.7, totals.FtrRatePct);
|
||||
Assert.Equal(1, totals.ExcludedSessions);
|
||||
Assert.Equal(2, totals.AttemptHistogram.Count);
|
||||
Assert.Equal(2, totals.AttemptHistogram.Single(b => b.Attempts == 1).JourneyCount);
|
||||
Assert.Equal(1, totals.AttemptHistogram.Single(b => b.Attempts == 2).JourneyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero()
|
||||
{
|
||||
var totals = DeviceJourneyMetricsResponse.SumAggregates(new List<DeviceJourneyDailyAggregate>
|
||||
{
|
||||
new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 },
|
||||
});
|
||||
|
||||
Assert.Equal(0, totals.CompletedJourneys);
|
||||
Assert.Null(totals.FtrRatePct);
|
||||
Assert.Equal(3, totals.ExcludedSessions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 30)] // default window
|
||||
[InlineData("garbage", 30)]
|
||||
[InlineData("7", 7)]
|
||||
[InlineData("0", 1)] // clamped low
|
||||
[InlineData("999", 180)] // clamped to aggregate retention
|
||||
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InclusiveWindowStart_YieldsExactlyNCalendarDays()
|
||||
{
|
||||
// Codex review: both range ends are inclusive, so "today - days" returned N+1 keys —
|
||||
// days=1 summed yesterday AND today.
|
||||
var today = new DateTime(2026, 7, 27);
|
||||
Assert.Equal(today, DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 1));
|
||||
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 30));
|
||||
}
|
||||
|
||||
private static DeviceHistory RepeaterHistory(
|
||||
string serial, int attempts, params DeviceSessionRef[] chain)
|
||||
=> new()
|
||||
{
|
||||
TenantId = TenantA,
|
||||
SerialKey = serial,
|
||||
SerialNumber = serial.ToUpperInvariant(),
|
||||
Chain = chain.ToList(),
|
||||
CurrentJourneyAttempts = attempts,
|
||||
JourneyCount = 1,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SelectRepeatDevices_FiltersGate_WindowsOnNewestRef_AndPicksNewestFailure()
|
||||
{
|
||||
var histories = new List<DeviceHistory>
|
||||
{
|
||||
// Single attempt — not a repeat device.
|
||||
RepeaterHistory("clean-1", attempts: 1, Ref("c1", SessionStatus.Succeeded, T0)),
|
||||
// Repeat device, active in window: newest failed ref carries the reason lookup.
|
||||
RepeaterHistory("hot-1", attempts: 3,
|
||||
Ref("h1", SessionStatus.Failed, T0.AddDays(-2)),
|
||||
Ref("h2", SessionStatus.Failed, T0.AddDays(-1)),
|
||||
Ref("h3", SessionStatus.Incomplete, T0)),
|
||||
// Repeat device, but its pain is OLDER than the window — current lists only.
|
||||
RepeaterHistory("old-1", attempts: 4,
|
||||
Ref("o1", SessionStatus.Failed, T0.AddDays(-40))),
|
||||
// Lower attempt count → sorts after hot-1.
|
||||
RepeaterHistory("warm-1", attempts: 2,
|
||||
Ref("w1", SessionStatus.Failed, T0.AddDays(-3)),
|
||||
Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))),
|
||||
};
|
||||
|
||||
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
|
||||
Assert.Equal(2, selected.Count);
|
||||
Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc
|
||||
Assert.Equal("h3", selected[0].Newest.SessionId);
|
||||
Assert.Equal("h2", selected[0].NewestFailed!.SessionId); // last FAILED, not last ref
|
||||
Assert.Equal("warm-1", selected[1].History.SerialKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectRepeatDevices_CapsAtTen_ByAttemptsThenRecency()
|
||||
{
|
||||
var histories = new List<DeviceHistory>();
|
||||
for (var i = 0; i < 12; i++)
|
||||
{
|
||||
histories.Add(RepeaterHistory($"dev-{i:D2}", attempts: 2 + i,
|
||||
Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i))));
|
||||
}
|
||||
|
||||
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
|
||||
Assert.Equal(10, selected.Count);
|
||||
Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first
|
||||
Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-00");
|
||||
Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-01");
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,80 @@ public class DiagnosticsBlobCascadeDeleterTests
|
||||
Assert.Equal(0, deleter.HostedDeleteCalls);
|
||||
}
|
||||
|
||||
// ── REAL DeleteAsync, Hosted branch — prefix sweep + belt-and-braces name delete ──
|
||||
// The RecordingDeleter above replaces DeleteAsync wholesale, so these run the REAL
|
||||
// base implementation against a fake storage service to pin the sweep behaviour.
|
||||
|
||||
private const string SessionGuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
|
||||
[Fact]
|
||||
public async Task RealDeleteAsync_Hosted_SweepsSessionPrefix_AndDeletesManifestBlob()
|
||||
{
|
||||
var storage = new RecordingHostedStorage();
|
||||
var deleter = RealDeleter(storage);
|
||||
var manifest = ManifestWith(blobName: $"{TenantA}/{Filename}", destination: "Hosted");
|
||||
manifest.SessionId = SessionGuid;
|
||||
|
||||
var outcome = await deleter.DeleteAsync(manifest);
|
||||
|
||||
Assert.Equal(DiagnosticsBlobDeleteOutcome.HostedDeleted, outcome);
|
||||
Assert.Equal((TenantA, SessionGuid), storage.SweptSession);
|
||||
Assert.Equal($"{TenantA}/{Filename}", storage.DeletedBlobPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RealDeleteAsync_Hosted_NonGuidSessionId_SkipsSweep_StillDeletesManifestBlob()
|
||||
{
|
||||
// Legacy manifests may carry a malformed/empty SessionId — the sweep must degrade
|
||||
// to the single-blob delete, never throw into the cascade poison path.
|
||||
var storage = new RecordingHostedStorage();
|
||||
var deleter = RealDeleter(storage);
|
||||
var manifest = ManifestWith(blobName: $"{TenantA}/{Filename}", destination: "Hosted");
|
||||
manifest.SessionId = "not-a-guid";
|
||||
|
||||
var outcome = await deleter.DeleteAsync(manifest);
|
||||
|
||||
Assert.Equal(DiagnosticsBlobDeleteOutcome.HostedDeleted, outcome);
|
||||
Assert.Null(storage.SweptSession);
|
||||
Assert.Equal($"{TenantA}/{Filename}", storage.DeletedBlobPath);
|
||||
}
|
||||
|
||||
private static DiagnosticsBlobCascadeDeleter RealDeleter(RecordingHostedStorage storage)
|
||||
// TenantConfigurationService is only consulted on the CustomerSas branch; the
|
||||
// Hosted-branch tests never reach it, so null keeps the harness Azure-free.
|
||||
=> new(
|
||||
storage,
|
||||
tenantConfig: null!,
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<DiagnosticsBlobCascadeDeleter>.Instance);
|
||||
|
||||
private sealed class RecordingHostedStorage : HostedDiagnosticsBlobService
|
||||
{
|
||||
public (string TenantId, string SessionId)? SweptSession { get; private set; }
|
||||
public string? DeletedBlobPath { get; private set; }
|
||||
|
||||
public RecordingHostedStorage()
|
||||
: base(
|
||||
new Azure.Storage.Blobs.BlobServiceClient("UseDevelopmentStorage=true"),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<HostedDiagnosticsBlobService>.Instance,
|
||||
usesManagedIdentity: false)
|
||||
{
|
||||
}
|
||||
|
||||
public override Task<int> DeleteBySessionPrefixAsync(
|
||||
string tenantId, string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SweptSession = (tenantId, sessionId);
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
public override Task<bool> DeleteIfExistsAsync(
|
||||
string blobPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
DeletedBlobPath = blobPath;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── BuildBlobUrl helper (CustomerSas URL construction) ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
+4
-4
@@ -160,9 +160,9 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
|
||||
Assert.NotNull(getConfig);
|
||||
Assert.NotNull(putConfig);
|
||||
// GET config read is admin-tier read (own-tenant Admin / GA / read-only GlobalReader);
|
||||
// PUT config write stays Admin/GA only.
|
||||
Assert.Equal(EndpointPolicy.TenantAdminOrGlobalReader, getConfig.Policy);
|
||||
// GET config read is member-tier (Operators/Viewers get the redacted read-only Settings
|
||||
// view; the handler redacts for every non-admin caller); PUT config write stays Admin/GA only.
|
||||
Assert.Equal(EndpointPolicy.MemberRead, getConfig.Policy);
|
||||
Assert.Equal(EndpointPolicy.TenantAdminOrGA, putConfig.Policy);
|
||||
|
||||
var getRules = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/rules/gather");
|
||||
@@ -338,7 +338,7 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
[InlineData("POST", "/api/agent/telemetry", true)] // DeviceOrBootstrapAuth
|
||||
[InlineData("GET", "/api/bootstrap/validate/ABC123", true)] // PublicAnonymous (param route)
|
||||
[InlineData("GET", "/api/sessions", false)] // MemberRead
|
||||
[InlineData("GET", "/api/config/00000000-0000-0000-0000-000000000001", false)] // TenantAdminOrGlobalReader
|
||||
[InlineData("GET", "/api/config/00000000-0000-0000-0000-000000000001", false)] // MemberRead
|
||||
[InlineData("GET", "/api/global/sessions", false)] // GlobalReadOrAdmin
|
||||
[InlineData("GET", "/api/nonexistent", false)] // unregistered → fail-closed
|
||||
public void SkipsJwtValidation_MatchesPolicyTier(string method, string path, bool expectedExempt)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user