diff --git a/.gitignore b/.gitignore
index f5611b6b..aad1cd58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -112,6 +112,14 @@ tmpclaude-*-cwd
scripts/Deployment/build_and_upload_debug_agent_build.ps1
scripts/Deployment/build_and_upload_release_agent_build.ps1
+# V2 Deployment scripts (tokens + SAS URLs, analog to Legacy) — plan §4 M2
+scripts/Deployment/V2/
+
+# M2 — local raw session fixtures before anonymization (plan §4 M2)
+tests/fixtures/enrollment-sessions-local/*
+!tests/fixtures/enrollment-sessions-local/.gitkeep
+!tests/fixtures/enrollment-sessions-local/README.md
+
.dotnet-home/
.vscode/
diff --git a/AutopilotMonitor.sln b/AutopilotMonitor.sln
index 12db1d33..96727b8f 100644
--- a/AutopilotMonitor.sln
+++ b/AutopilotMonitor.sln
@@ -27,6 +27,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.Core
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.DecisionCore", "src\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj", "{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2", "src\Agent\AutopilotMonitor.Agent.V2\AutopilotMonitor.Agent.V2.csproj", "{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2.Core", "src\Agent\AutopilotMonitor.Agent.V2.Core\AutopilotMonitor.Agent.V2.Core.csproj", "{22CB0E75-793A-4FC9-ADE4-48580BCE6321}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.DecisionCore.Tests", "src\Agent\AutopilotMonitor.DecisionCore.Tests\AutopilotMonitor.DecisionCore.Tests.csproj", "{11B36E5C-B512-4D1E-A76B-E5AD866B033C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -133,6 +139,42 @@ Global
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x64.Build.0 = Release|Any CPU
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x86.ActiveCfg = Release|Any CPU
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x86.Build.0 = Release|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x64.Build.0 = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x86.Build.0 = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x64.ActiveCfg = Release|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x64.Build.0 = Release|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x86.ActiveCfg = Release|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x86.Build.0 = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x64.Build.0 = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x86.Build.0 = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|Any CPU.Build.0 = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x64.ActiveCfg = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x64.Build.0 = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x86.ActiveCfg = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x86.Build.0 = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x64.Build.0 = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x86.Build.0 = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x64.ActiveCfg = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x64.Build.0 = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x86.ActiveCfg = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -149,5 +191,8 @@ Global
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E} = {096B6098-F141-7114-B090-72D1899D8853}
{89BDAC07-A666-4708-A9D3-A956A89A4E4D} = {096B6098-F141-7114-B090-72D1899D8853}
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054} = {096B6098-F141-7114-B090-72D1899D8853}
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321} = {096B6098-F141-7114-B090-72D1899D8853}
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C} = {096B6098-F141-7114-B090-72D1899D8853}
EndGlobalSection
EndGlobal
diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1
new file mode 100644
index 00000000..062cdd7d
--- /dev/null
+++ b/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1
@@ -0,0 +1,278 @@
+<#
+.SYNOPSIS
+ DEV variant of the bootstrap script to deploy and start the Autopilot Monitor agent.
+
+.DESCRIPTION
+ Funktionally identical to Install-AutopilotMonitor.ps1 with two differences:
+ 1. Default $AgentDownloadUrl points to AutopilotMonitor-Agent-dev.zip
+ 2. Integrity check is read from version-dev.json (NOT version.json)
+ This enables parallel lab testing of unreleased agent builds via a dedicated
+ Intune Platform Script assignment, without touching any production artefacts.
+
+ See Install-AutopilotMonitor.ps1 for the full behaviour (guards, SHA-256
+ integrity verification, scheduled task registration via --install, etc.).
+
+.PARAMETER AgentDownloadUrl
+ URL to download the DEV agent binaries from (ZIP file). Defaults to
+ AutopilotMonitor-Agent-dev.zip in the same blob container as production.
+
+.NOTES
+ - ASCII-only. PowerShell 5.1 / IME AgentExecutor reads scripts without BOM
+ as ANSI and corrupts multi-byte chars.
+ - Guards are intentionally identical to Prod bootstrapper so lab behaviour
+ matches production behaviour on fresh images.
+
+.CHANGELOG
+ 2026-04-20 v2.0-pre-dev Forked from Install-AutopilotMonitor-Dev.ps1 v1.1-dev for V2-Agent.
+ Default URL -> AutopilotMonitor-Agent-V2-dev.zip,
+ integrity file -> version-v2-dev.json,
+ agent exe -> AutopilotMonitor.Agent.V2.exe.
+ Plan: plans/REFACTOR_AGENT_V2.md section 4 M2.
+ 2026-04-14 v1.1-dev Initial DEV variant forked from Install-AutopilotMonitor.ps1 v1.1.
+ Default URL -> AutopilotMonitor-Agent-dev.zip, integrity file -> version-dev.json.
+#>
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $false)]
+ [string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-V2-dev.zip",
+
+ [Parameter(Mandatory = $false)]
+ [int]$MaxBootstrapWindowHours = 12
+)
+
+# Script version (bump on meaningful changes; see .CHANGELOG above)
+$ScriptVersion = "2.0-pre-dev"
+
+# Configuration - Everything in ProgramData for easy cleanup
+$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
+$AgentBinPath = "$AgentBasePath\Agent"
+$AgentLogPath = "$AgentBasePath\Logs"
+$LogFile = "$AgentLogPath\bootstrap_agent.log"
+
+# Ensure directories exist
+New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+
+function Write-Log {
+ param([string]$Message)
+ $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
+ $logMessage = "[$timestamp] $Message"
+ Write-Output $logMessage
+ Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
+}
+
+function Get-FileMd5Base64 {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Path
+ )
+
+ $md5 = [System.Security.Cryptography.MD5]::Create()
+ try {
+ $stream = [System.IO.File]::OpenRead($Path)
+ try {
+ $hashBytes = $md5.ComputeHash($stream)
+ }
+ finally {
+ $stream.Dispose()
+ }
+
+ return [Convert]::ToBase64String($hashBytes)
+ }
+ finally {
+ $md5.Dispose()
+ }
+}
+
+try {
+ Write-Log "===== Autopilot Monitor Bootstrap Started (DEV) ====="
+ Write-Log "Bootstrap script version: v$ScriptVersion"
+ Write-Log "Agent download URL: $AgentDownloadUrl"
+
+ # -- Pre-flight: Multi-signal guard (identical to production bootstrapper) --
+ # Guard 1: Ghost re-installs (registry marker from previous deployment)
+ # Guard 2: Productive devices (real user profile exists -- WMI + filesystem)
+ # Guard 3: Productive devices (a real user has logged on before)
+ # Guard 4: Bootstrap window expired (device uptime > 12h without agent)
+ # Guard 5: Agent binary already present from a previous run
+
+ # Guard 1
+ $deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
+ if ($deployed) {
+ Write-Log "SKIP: Agent was previously deployed at $deployed."
+ exit 0
+ }
+
+ # Guard 2
+ $excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
+
+ $profileNames = @(
+ try {
+ Get-CimInstance Win32_UserProfile -ErrorAction Stop |
+ Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
+ ForEach-Object { Split-Path $_.LocalPath -Leaf }
+ } catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
+
+ (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
+ ) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
+ Select-Object -Unique
+
+ if ($profileNames) {
+ $names = ($profileNames | Select-Object -First 3) -join ', '
+ Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
+ exit 0
+ }
+
+ # Guard 3
+ $lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
+ if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
+ Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
+ exit 0
+ }
+
+ # Guard 4
+ $lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
+ $uptimeHours = ((Get-Date) - $lastBoot).TotalHours
+ Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
+ if ($uptimeHours -gt $MaxBootstrapWindowHours) {
+ Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
+ exit 0
+ }
+
+ # Guard 5
+ if (Test-Path $AgentBinPath) {
+ $existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
+ if ($existingAgent) {
+ Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
+ exit 0
+ }
+ }
+
+ Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
+
+ # Download and extract agent binaries
+ $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.V2.exe"
+
+ if (Test-Path $agentExePath) {
+ Write-Log "Agent already installed at $agentExePath"
+ }
+ else {
+ Write-Log "Downloading DEV agent from $AgentDownloadUrl..."
+
+ try {
+ # DEV: Integrity-File ist version-dev.json (im selben Container wie das -dev ZIP)
+ $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-v2-dev.json'
+ $expectedSha256 = $null
+
+ try {
+ Write-Log "Fetching version-dev.json from $versionJsonUrl for integrity verification..."
+ $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
+ if ($versionJsonResponse.sha256) {
+ $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
+ Write-Log "SHA-256 hash from version-dev.json: $expectedSha256 (version: $($versionJsonResponse.version))"
+ } else {
+ Write-Log "version-dev.json has no sha256 field - falling back to legacy MD5 check"
+ }
+ }
+ catch {
+ Write-Log "WARNING: Could not fetch version-dev.json - falling back to legacy MD5 check: $($_.Exception.Message)"
+ }
+
+ $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-V2-dev.zip"
+ $maxDownloadAttempts = 3
+ $downloadAttempt = 0
+ $downloadResponse = $null
+
+ do {
+ $downloadAttempt++
+ try {
+ Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
+ $downloadResponse = Invoke-WebRequest `
+ -Uri $AgentDownloadUrl `
+ -OutFile $zipPath `
+ -UseBasicParsing `
+ -TimeoutSec 30 `
+ -ErrorAction Stop `
+ -PassThru
+ Write-Log "Downloaded agent to $zipPath"
+ break
+ }
+ catch {
+ if ($downloadAttempt -ge $maxDownloadAttempts) {
+ throw
+ }
+
+ $retryDelaysInSeconds = @(2, 4, 8)
+ $retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
+ Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
+ Start-Sleep -Seconds $retryDelaySeconds
+ }
+ } while ($downloadAttempt -lt $maxDownloadAttempts)
+
+ if ($expectedSha256) {
+ $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
+ Write-Log "Validating SHA-256 hash against version-dev.json"
+ if ($actualSha256 -ne $expectedSha256) {
+ throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
+ }
+ Write-Log "SHA-256 integrity check passed"
+ }
+ else {
+ $expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
+ $expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
+ if ($expectedMd5 -notmatch '\S') {
+ Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
+ }
+ else {
+ $actualMd5 = Get-FileMd5Base64 -Path $zipPath
+ Write-Log "Validating Content-MD5 header against downloaded ZIP"
+ if ($actualMd5 -ne $expectedMd5) {
+ throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
+ }
+ Write-Log "MD5 integrity check passed"
+ }
+ }
+
+ Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
+ Write-Log "Extracted agent to $AgentBinPath"
+
+ Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
+ Write-Log "Cleaned up temporary files"
+
+ if (-not (Test-Path $agentExePath)) {
+ throw "Agent executable not found after extraction at $agentExePath"
+ }
+
+ Write-Log "DEV agent installation completed successfully"
+ }
+ catch {
+ Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
+ throw
+ }
+ }
+
+ Write-Log "Calling agent install mode (--install)..."
+ & $agentExePath --install
+ $installExitCode = $LASTEXITCODE
+ if ($installExitCode -ne 0) {
+ throw "Agent install failed with exit code $installExitCode"
+ }
+ Write-Log "Agent install mode completed successfully"
+ Write-Log "===== Bootstrap Completed Successfully (DEV) ====="
+
+ exit 0
+}
+catch {
+ Write-Log "===== Bootstrap FAILED (DEV) ====="
+ Write-Log "ERROR: $($_.Exception.Message)"
+ Write-Log "Stack trace: $($_.ScriptStackTrace)"
+ Write-Log "Please check log file: $LogFile"
+
+ $errMsg = "AutopilotMonitor bootstrap (DEV) failed: $($_.Exception.Message)"
+ if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
+ [Console]::Error.WriteLine($errMsg)
+
+ exit 1
+}
diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
new file mode 100644
index 00000000..4edec32d
--- /dev/null
+++ b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
@@ -0,0 +1,322 @@
+<#
+.SYNOPSIS
+ Bootstrap script to deploy and start the Autopilot Monitor agent.
+
+.DESCRIPTION
+ This script is designed to be deployed via Intune as a PowerShell Script during Autopilot.
+ It runs VERY EARLY in the enrollment process (first Intune action) and:
+ 1. Creates a unique session ID for this enrollment
+ 2. Downloads the monitoring agent binaries
+ 3. Verifies integrity via SHA-256 hash from version.json
+ 4. Installs agent binaries (agent uses built-in backend URL)
+ 5. Registers agent as Scheduled Task (runs on computer startup)
+ 6. Agent self-destructs when enrollment completes
+
+ INTEGRITY VERIFICATION:
+ The script downloads version.json from the same blob container as the agent ZIP.
+ If version.json contains a "sha256" field, the downloaded ZIP is verified against it
+ using SHA-256. This prevents tampering during download (MITM) and detects corrupted
+ downloads. If the hash does not match, installation is aborted.
+
+ For backward compatibility: if version.json does not contain a "sha256" field (older
+ builds), the script falls back to the legacy Content-MD5 header check.
+
+.PARAMETER AgentDownloadUrl
+ URL to download the agent binaries from (ZIP file)
+
+.EXAMPLE
+ .\Install-AutopilotMonitor.ps1
+ (Uses built-in backend URL from the agent)
+
+.NOTES
+ - Agent is temporary and auto-removes after enrollment
+ - Everything in C:\ProgramData\AutopilotMonitor (easy cleanup)
+ - One Registry key left after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed (prevents ghost re-installs)
+ - Scheduled Task survives reboots during enrollment
+ - SHA-256 integrity verification since v1.0.706+
+ - IMPORTANT: This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
+ PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
+
+.CHANGELOG
+ 2026-04-20 v2.0-pre Forked from Install-AutopilotMonitor.ps1 v1.1 for V2-Agent.
+ URL -> AutopilotMonitor-Agent-V2.zip, integrity file -> version-v2.json,
+ agent exe -> AutopilotMonitor.Agent.V2.exe. Same guards + install flow.
+ Plan: plans/REFACTOR_AGENT_V2.md section 4 M2.
+ 2026-04-09 v1.1 Introduced explicit script version and log it on startup
+ (all entries above were v1.0)
+ 2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard:
+ registry deployment marker, WMI/filesystem user profile detection,
+ lastloggedonUser set, and 12h bootstrap window.
+ 2026-03-30 Fixed non-ASCII characters (em-dashes, Unicode symbols) that broke
+ script parsing under PowerShell 5.1 / IME AgentExecutor
+ 2026-03-29 Hardened integrity check: SHA-256 verification via version.json
+ 2026-02-16 Extended OS age default threshold
+ 2026-02-13 Simplified bootstrapper, introduced --install parameter for agent
+ 2026-02-12 More robust download with integrity check (Content-MD5)
+ 2026-02-12 Enhanced pre-flight checks, boot time support
+ 2026-02-12 Added pre-flight check: skip if agent already installed
+ 2026-02-05 Initial version
+#>
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $false)]
+ [string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-V2.zip",
+
+ [Parameter(Mandatory = $false)]
+ [int]$MaxBootstrapWindowHours = 12
+)
+
+# Script version (bump on meaningful changes; see .CHANGELOG above)
+$ScriptVersion = "2.0-pre"
+
+# Configuration - Everything in ProgramData for easy cleanup
+$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
+$AgentBinPath = "$AgentBasePath\Agent"
+$AgentLogPath = "$AgentBasePath\Logs"
+$LogFile = "$AgentLogPath\bootstrap_agent.log"
+
+# Ensure directories exist
+New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+
+function Write-Log {
+ param([string]$Message)
+ $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
+ $logMessage = "[$timestamp] $Message"
+ Write-Output $logMessage
+ Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
+}
+
+function Get-FileMd5Base64 {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Path
+ )
+
+ $md5 = [System.Security.Cryptography.MD5]::Create()
+ try {
+ $stream = [System.IO.File]::OpenRead($Path)
+ try {
+ $hashBytes = $md5.ComputeHash($stream)
+ }
+ finally {
+ $stream.Dispose()
+ }
+
+ return [Convert]::ToBase64String($hashBytes)
+ }
+ finally {
+ $md5.Dispose()
+ }
+}
+
+try {
+ Write-Log "===== Autopilot Monitor Bootstrap Started ====="
+ Write-Log "Bootstrap script version: v$ScriptVersion"
+
+ # -- Pre-flight: Multi-signal guard to prevent installation on non-provisioning devices --
+ # Layered approach: each guard catches a different scenario.
+ # Guard 1: Ghost re-installs (registry marker from previous deployment)
+ # Guard 2: Productive devices (real user profile exists -- WMI + filesystem)
+ # Guard 3: Productive devices (a real user has logged on before)
+ # Guard 4: Bootstrap window expired (device uptime > 12h without agent)
+ # Guard 5: Agent binary already present from a previous run
+ # NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
+ # NOTE: explorer.exe is NOT used -- it runs even in early OOBE right after script execution.
+
+ # Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
+ $deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
+ if ($deployed) {
+ Write-Log "SKIP: Agent was previously deployed at $deployed."
+ exit 0
+ }
+
+ # Guard 2: No real user profile should exist yet (primary productive-device guard)
+ # Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability.
+ $excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
+
+ $profileNames = @(
+ # WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService
+ try {
+ Get-CimInstance Win32_UserProfile -ErrorAction Stop |
+ Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
+ ForEach-Object { Split-Path $_.LocalPath -Leaf }
+ } catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
+
+ # Filesystem view -- catches profiles WMI might miss
+ (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
+ ) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
+ Select-Object -Unique
+
+ if ($profileNames) {
+ $names = ($profileNames | Select-Object -First 3) -join ', '
+ Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
+ exit 0
+ }
+
+ # Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet
+ $lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
+ if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
+ Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
+ exit 0
+ }
+
+ # Guard 4: Bootstrap window check (no key, no user profiles, but device running too long)
+ # NOT "how long may enrollment take" -- agent handles that internally (6h emergency break).
+ # This is "how old can the OOBE state be before I no longer trust it for initial install".
+ # 12h bootstrap window vs 6h agent emergency break = consistent layered approach.
+ # Sleep/standby does NOT reset uptime -- only real boot/restart does.
+ $lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
+ $uptimeHours = ((Get-Date) - $lastBoot).TotalHours
+ Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
+ if ($uptimeHours -gt $MaxBootstrapWindowHours) {
+ Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
+ exit 0
+ }
+
+ # Guard 5: Is the agent already installed? (leftover from previous run)
+ if (Test-Path $AgentBinPath) {
+ $existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
+ if ($existingAgent) {
+ Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
+ exit 0
+ }
+ }
+
+ Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
+
+ # Download and extract agent binaries
+ $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.V2.exe"
+
+ if (Test-Path $agentExePath) {
+ Write-Log "Agent already installed at $agentExePath"
+ }
+ else {
+ Write-Log "Downloading agent from $AgentDownloadUrl..."
+
+ try {
+ # Derive version.json URL from the agent download URL (same blob container)
+ $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-v2.json'
+ $expectedSha256 = $null
+
+ # Download version.json for SHA-256 integrity verification
+ try {
+ Write-Log "Fetching version.json from $versionJsonUrl for integrity verification..."
+ $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
+ if ($versionJsonResponse.sha256) {
+ $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
+ Write-Log "SHA-256 hash from version.json: $expectedSha256 (version: $($versionJsonResponse.version))"
+ } else {
+ Write-Log "version.json has no sha256 field - falling back to legacy MD5 check (older build)"
+ }
+ }
+ catch {
+ Write-Log "WARNING: Could not fetch version.json - falling back to legacy MD5 check: $($_.Exception.Message)"
+ }
+
+ # Download agent ZIP
+ $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-V2.zip"
+ $maxDownloadAttempts = 3
+ $downloadAttempt = 0
+ $downloadResponse = $null
+
+ do {
+ $downloadAttempt++
+ try {
+ Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
+ $downloadResponse = Invoke-WebRequest `
+ -Uri $AgentDownloadUrl `
+ -OutFile $zipPath `
+ -UseBasicParsing `
+ -TimeoutSec 30 `
+ -ErrorAction Stop `
+ -PassThru
+ Write-Log "Downloaded agent to $zipPath"
+ break
+ }
+ catch {
+ if ($downloadAttempt -ge $maxDownloadAttempts) {
+ throw
+ }
+
+ $retryDelaysInSeconds = @(2, 4, 8)
+ $retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
+ Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
+ Start-Sleep -Seconds $retryDelaySeconds
+ }
+ } while ($downloadAttempt -lt $maxDownloadAttempts)
+
+ # Integrity check: SHA-256 (preferred) or legacy Content-MD5 (fallback)
+ if ($expectedSha256) {
+ $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
+ Write-Log "Validating SHA-256 hash against version.json"
+ if ($actualSha256 -ne $expectedSha256) {
+ throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
+ }
+ Write-Log "SHA-256 integrity check passed"
+ }
+ else {
+ # Legacy fallback: Content-MD5 header check
+ $expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
+ $expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
+ if ($expectedMd5 -notmatch '\S') {
+ Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
+ }
+ else {
+ $actualMd5 = Get-FileMd5Base64 -Path $zipPath
+ Write-Log "Validating Content-MD5 header against downloaded ZIP"
+ if ($actualMd5 -ne $expectedMd5) {
+ throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
+ }
+ Write-Log "MD5 integrity check passed"
+ }
+ }
+
+ # Extract to agent bin path
+ Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
+ Write-Log "Extracted agent to $AgentBinPath"
+
+ # Cleanup
+ Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
+ Write-Log "Cleaned up temporary files"
+
+ # Verify extraction
+ if (-not (Test-Path $agentExePath)) {
+ throw "Agent executable not found after extraction at $agentExePath"
+ }
+
+ Write-Log "Agent installation completed successfully"
+ }
+ catch {
+ Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
+ throw
+ }
+ }
+
+ # Let the agent install/deploy itself and manage its own Scheduled Task
+ Write-Log "Calling agent install mode (--install)..."
+ & $agentExePath --install
+ $installExitCode = $LASTEXITCODE
+ if ($installExitCode -ne 0) {
+ throw "Agent install failed with exit code $installExitCode"
+ }
+ Write-Log "Agent install mode completed successfully"
+ Write-Log "===== Bootstrap Completed Successfully ====="
+
+ exit 0
+}
+catch {
+ Write-Log "===== Bootstrap FAILED ====="
+ Write-Log "ERROR: $($_.Exception.Message)"
+ Write-Log "Stack trace: $($_.ScriptStackTrace)"
+ Write-Log "Please check log file: $LogFile"
+
+ # some errors are captured by Intune but not fully visible in the UI (truncated, no stack trace), so also write critical info to stderr for better visibility.
+ $errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
+ if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
+ [Console]::Error.WriteLine($errMsg)
+
+ exit 1
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj b/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj
new file mode 100644
index 00000000..a6212cfb
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj
@@ -0,0 +1,57 @@
+
+
+
+ net48
+ latest
+ AutopilotMonitor.Agent.V2.Core
+
+
+
+
+
+ <_Parameter1>AutopilotMonitor.Agent.V2
+
+
+ <_Parameter1>AutopilotMonitor.DecisionCore.Tests
+
+
+ <_Parameter1>DynamicProxyGenAssembly2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll
+
+
+
+
+
+
+
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs
new file mode 100644
index 00000000..2f6f25f5
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs
@@ -0,0 +1,271 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Core.Configuration
+{
+ ///
+ /// Agent configuration settings
+ ///
+ public class AgentConfiguration
+ {
+ ///
+ /// Backend API base URL
+ ///
+ public string ApiBaseUrl { get; set; }
+
+ ///
+ /// Session identifier for this enrollment
+ ///
+ public string SessionId { get; set; }
+
+ ///
+ /// Tenant identifier
+ ///
+ public string TenantId { get; set; }
+
+ ///
+ /// Local spool directory path
+ ///
+ public string SpoolDirectory { get; set; }
+
+ ///
+ /// Agent log directory path
+ ///
+ public string LogDirectory { get; set; }
+
+ ///
+ /// Upload interval in seconds
+ ///
+ public int UploadIntervalSeconds { get; set; } = 30;
+
+ ///
+ /// Maximum batch size for events
+ ///
+ public int MaxBatchSize { get; set; } = 100;
+
+ ///
+ /// Whether to use client certificate authentication
+ ///
+ public bool UseClientCertAuth { get; set; } = true;
+
+
+ ///
+ /// Log verbosity level: Info (default), Debug, Verbose.
+ /// Overridable via remote config.
+ ///
+ public AgentLogLevel LogLevel { get; set; } = AgentLogLevel.Info;
+
+ ///
+ /// Maximum retry attempts for failed uploads
+ ///
+ public int MaxRetryAttempts { get; set; } = 5;
+
+ ///
+ /// Whether to self-destruct when enrollment completes (remove Scheduled Task and all files).
+ /// This is the only cleanup mode — SelfDestruct always removes task + files.
+ ///
+ public bool SelfDestructOnComplete { get; set; } = true;
+
+ ///
+ /// Name of the Scheduled Task to remove during self-destruct
+ ///
+ public string ScheduledTaskName { get; set; } = Constants.ScheduledTaskName;
+
+ ///
+ /// Whether to reboot the device after enrollment completes
+ ///
+ public bool RebootOnComplete { get; set; } = false;
+
+ ///
+ /// Delay in seconds before the reboot is initiated (shutdown.exe /r /t X).
+ /// Default: 10 seconds
+ ///
+ public int RebootDelaySeconds { get; set; } = 10;
+
+ ///
+ /// When true, the log directory is preserved after self-destruct/cleanup.
+ /// All other files (binaries, config, spool, scheduled task) are still removed.
+ ///
+ public bool KeepLogFile { get; set; } = false;
+
+ ///
+ /// Whether to enable geo-location detection (queries external IP services)
+ ///
+ public bool EnableGeoLocation { get; set; } = true;
+
+ ///
+ /// NTP server address for time check during enrollment.
+ /// Default: "time.windows.com"
+ ///
+ public string NtpServer { get; set; } = "time.windows.com";
+
+ ///
+ /// Whether to automatically set the device timezone based on IP geolocation.
+ /// Requires EnableGeoLocation to be true. Uses tzutil /s to apply.
+ /// Default: false
+ ///
+ public bool EnableTimezoneAutoSet { get; set; } = false;
+
+ ///
+ /// Optional custom path to IME logs directory for testing.
+ /// If set, overrides the default %ProgramData%\Microsoft\IntuneManagementExtension\Logs path.
+ ///
+ public string ImeLogPathOverride { get; set; }
+
+ ///
+ /// Optional path to write every IME log line that matched a pattern (for debugging).
+ /// If empty, no match log is written. Example: %ProgramData%\AutopilotMonitor\Logs\ime-matches.log
+ ///
+ public string ImeMatchLogPath { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public string ReplayLogDir { get; set; }
+
+ ///
+ /// Time compression factor for log replay.
+ /// Higher values = faster replay. Default: 50 (e.g., 50 minutes of real logs = 1 minute of replay).
+ ///
+ public double ReplaySpeedFactor { get; set; } = 50;
+
+ ///
+ /// Wait time in seconds after ESP exit before marking Hello as skipped.
+ /// When ESP exits, we wait this duration for Hello wizard to start (event 62404).
+ /// Default: 30 seconds (reasonable for systems under load).
+ /// If Hello wizard starts within this window, we continue waiting for Hello completion (300/301).
+ ///
+ public int HelloWaitTimeoutSeconds { get; set; } = 30;
+
+ ///
+ /// Maximum consecutive authentication failures (401/403) before the agent shuts down.
+ /// Prevents endless retry traffic when the device is not authorized.
+ /// 0 = disabled (retry forever). Default: 5.
+ ///
+ public int MaxAuthFailures { get; set; } = 5;
+
+ ///
+ /// Maximum time in minutes the agent keeps retrying after the first auth failure.
+ /// 0 = disabled (no time limit, only MaxAuthFailures applies). Default: 0.
+ ///
+ public int AuthFailureTimeoutMinutes { get; set; } = 0;
+
+ ///
+ /// Maximum agent lifetime in minutes. Safety net to prevent zombie agents.
+ /// The agent emits enrollment_failed with failureType "agent_timeout" when this expires.
+ /// 0 = disabled (no lifetime limit). Default: 360 (6 hours).
+ ///
+ public int AgentMaxLifetimeMinutes { get; set; } = 360;
+
+ ///
+ /// Absolute maximum session age in hours across all agent restarts.
+ /// Emergency break: if the session has been alive longer than this, the agent
+ /// forces cleanup and self-destructs regardless of enrollment state.
+ /// Prevents zombie agents caused by unrecoverable logic errors.
+ /// Respects WhiteGlove scenarios (timer resets on Part 2 resume).
+ /// Default: 48 hours.
+ ///
+ public int AbsoluteMaxSessionHours { get; set; } = 48;
+
+ ///
+ /// Bootstrap token for pre-MDM auth during OOBE.
+ /// Embedded by the OOBE bootstrapper script via --bootstrap-token CLI arg.
+ /// When set, sent as X-Bootstrap-Token header instead of cert auth.
+ ///
+ public string BootstrapToken { get; set; }
+
+ ///
+ /// Whether to use bootstrap token auth (true until MDM cert becomes available).
+ ///
+ public bool UseBootstrapTokenAuth { get; set; }
+
+ ///
+ /// Whether to show a visual enrollment summary dialog to the end user.
+ ///
+ public bool ShowEnrollmentSummary { get; set; } = false;
+
+ ///
+ /// Auto-close timeout in seconds for the enrollment summary dialog.
+ /// 0 = no auto-close. Default: 60
+ ///
+ public int EnrollmentSummaryTimeoutSeconds { get; set; } = 60;
+
+ ///
+ /// Optional URL to a branding image for the enrollment summary dialog banner.
+ ///
+ public string EnrollmentSummaryBrandingImageUrl { get; set; }
+
+ ///
+ /// Maximum time in seconds the agent retries launching the enrollment summary dialog
+ /// when the user's desktop is locked by a credential UI (e.g. Windows Hello).
+ /// 0 = no retry (single attempt). Default: 120.
+ ///
+ public int EnrollmentSummaryLaunchRetrySeconds { get; set; } = 120;
+
+ ///
+ /// Whether diagnostics upload is configured for this tenant.
+ /// When true, the agent requests a short-lived upload URL from the backend just before uploading.
+ /// The SAS URL itself is never stored in config — it is fetched on-demand and used in memory only.
+ ///
+ public bool DiagnosticsUploadEnabled { get; set; } = false;
+
+ ///
+ /// When to upload diagnostics packages: "Off", "Always", "OnFailure".
+ /// Default: "Off"
+ ///
+ public string DiagnosticsUploadMode { get; set; } = "Off";
+
+ ///
+ /// Merged list of log paths/wildcards to include in the diagnostics ZIP package.
+ /// Received from the backend config (global + tenant-specific).
+ /// Each entry is validated against DiagnosticsPathGuards before use.
+ ///
+ public List DiagnosticsLogPaths { get; set; } = new List();
+
+ ///
+ /// Whether the agent should send Trace-severity events to the backend.
+ /// Controlled per tenant via remote config. Default: true (on in preview).
+ ///
+ public bool SendTraceEvents { get; set; } = true;
+
+ ///
+ /// When true, agent guardrails are relaxed: all registry, WMI, and command targets are allowed.
+ /// File/diagnostics paths allow everything except C:\Users.
+ /// Default: false.
+ ///
+ public bool UnrestrictedMode { get; set; } = false;
+
+ ///
+ /// Sanitized command-line arguments for the agent_started event.
+ /// Secrets (e.g. bootstrap token) are redacted before assignment.
+ ///
+ public string CommandLineArgs { get; set; }
+
+ ///
+ /// When true, the agent waits for an MDM certificate to appear before starting.
+ /// Used when deploying the agent before Intune enrollment completes.
+ /// The agent polls the certificate store every 5 seconds until a valid cert is found.
+ ///
+ public bool AwaitEnrollment { get; set; } = false;
+
+ ///
+ /// Maximum time in minutes to wait for the MDM certificate in await-enrollment mode.
+ /// 0 = wait indefinitely. Default: 480 (8 hours).
+ ///
+ public int AwaitEnrollmentTimeoutMinutes { get; set; } = 480;
+
+ ///
+ /// Validates the configuration
+ ///
+ public bool IsValid()
+ {
+ return !string.IsNullOrEmpty(ApiBaseUrl) &&
+ !string.IsNullOrEmpty(SessionId) &&
+ !string.IsNullOrEmpty(TenantId);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs
new file mode 100644
index 00000000..8bd7fe2d
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs
@@ -0,0 +1,203 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Shared.Models;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Agent.V2.Core.Configuration
+{
+ ///
+ /// Fetches and caches remote configuration from the backend API
+ /// Provides collector toggles and gather rules to the agent
+ ///
+ public class RemoteConfigService : IDisposable
+ {
+ private readonly BackendApiClient _apiClient;
+ private readonly string _tenantId;
+ private readonly AgentLogger _logger;
+ private readonly string _cacheFilePath;
+ private readonly EmergencyReporter _emergencyReporter;
+ private readonly DistressReporter _distressReporter;
+
+ private AgentConfigResponse _currentConfig;
+ private readonly object _configLock = new object();
+
+ ///
+ /// Gets the current configuration (thread-safe)
+ ///
+ public AgentConfigResponse CurrentConfig
+ {
+ get
+ {
+ lock (_configLock)
+ {
+ return _currentConfig;
+ }
+ }
+ }
+
+ public RemoteConfigService(BackendApiClient apiClient, string tenantId, AgentLogger logger, EmergencyReporter emergencyReporter = null, DistressReporter distressReporter = null)
+ {
+ _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
+ _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _emergencyReporter = emergencyReporter;
+ _distressReporter = distressReporter;
+
+ var cacheDir = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config");
+ _cacheFilePath = Path.Combine(cacheDir, "remote-config.json");
+ }
+
+ ///
+ /// Fetches the initial configuration from the backend
+ /// Falls back to cached config if the API is unreachable
+ ///
+ public async Task FetchConfigAsync()
+ {
+ try
+ {
+ _logger.Info("Fetching remote configuration from backend...");
+ var config = await _apiClient.GetAgentConfigAsync(_tenantId);
+
+ if (config != null)
+ {
+ _logger.Info($"Remote config fetched: ConfigVersion={config.ConfigVersion}, " +
+ $"GatherRules={config.GatherRules?.Count ?? 0}");
+
+ LogCollectorSettings(config.Collectors);
+ SetConfig(config);
+ CacheConfig(config);
+ return config;
+ }
+
+ _logger.Warning("Remote config fetch returned empty response");
+ }
+ catch (BackendAuthException ex)
+ {
+ _logger.Warning($"Config fetch authentication failed: {ex.Message}");
+
+ // Auth failure → use distress channel (pre-auth, works without cert)
+ if (_distressReporter != null)
+ {
+ _ = _distressReporter.TrySendAsync(
+ DistressErrorType.ConfigFetchDenied,
+ ex.Message,
+ httpStatusCode: ex.StatusCode);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Failed to fetch remote config: {ex.Message}");
+
+ // Non-auth failure → use authenticated emergency channel
+ if (_emergencyReporter != null)
+ {
+ _ = _emergencyReporter.TrySendAsync(
+ AgentErrorType.ConfigFetchFailed,
+ ex.Message);
+ }
+ }
+
+ // Fall back to cached config
+ var cached = LoadCachedConfig();
+ if (cached != null)
+ {
+ _logger.Info("Using cached remote configuration");
+ SetConfig(cached);
+ return cached;
+ }
+
+ // Fall back to defaults
+ _logger.Info("No cached config available, using defaults (all optional collectors disabled)");
+ var defaults = CreateDefaultConfig();
+ SetConfig(defaults);
+ return defaults;
+ }
+
+ private void SetConfig(AgentConfigResponse config)
+ {
+ lock (_configLock)
+ {
+ _currentConfig = config;
+ }
+ }
+
+ private void LogCollectorSettings(CollectorConfiguration collectors)
+ {
+ if (collectors == null) return;
+
+ _logger.Info(" Collector settings:");
+ _logger.Info($" Performance: {(collectors.EnablePerformanceCollector ? "ON" : "OFF")} (interval: {collectors.PerformanceIntervalSeconds}s)");
+ }
+
+ private void CacheConfig(AgentConfigResponse config)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(_cacheFilePath);
+ if (!Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ // SECURITY: Never persist UnrestrictedMode to disk — require live backend auth
+ var liveValue = config.UnrestrictedMode;
+ config.UnrestrictedMode = false;
+ var json = JsonConvert.SerializeObject(config, Formatting.Indented);
+ config.UnrestrictedMode = liveValue;
+
+ File.WriteAllText(_cacheFilePath, json);
+ _logger.Debug("Remote config cached to disk");
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"Failed to cache remote config: {ex.Message}");
+ }
+ }
+
+ private AgentConfigResponse LoadCachedConfig()
+ {
+ try
+ {
+ if (File.Exists(_cacheFilePath))
+ {
+ var json = File.ReadAllText(_cacheFilePath);
+ var config = JsonConvert.DeserializeObject(json);
+ if (config != null)
+ {
+ // SECURITY: Never trust cached UnrestrictedMode — always require live backend auth
+ config.UnrestrictedMode = false;
+ }
+ return config;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"Failed to load cached config: {ex.Message}");
+ }
+ return null;
+ }
+
+ private AgentConfigResponse CreateDefaultConfig()
+ {
+ return new AgentConfigResponse
+ {
+ ConfigVersion = 0,
+ UploadIntervalSeconds = 30,
+ SelfDestructOnComplete = true,
+ KeepLogFile = false,
+ EnableGeoLocation = true,
+ EnableImeMatchLog = false,
+ Collectors = CollectorConfiguration.CreateDefault(),
+ GatherRules = new System.Collections.Generic.List(),
+ ImeLogPatterns = new System.Collections.Generic.List()
+ };
+ }
+
+ public void Dispose()
+ {
+ // Nothing to dispose
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs
new file mode 100644
index 00000000..f1f11070
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs
@@ -0,0 +1,131 @@
+using System;
+using System.IO;
+
+namespace AutopilotMonitor.Agent.V2.Core.Logging
+{
+ ///
+ /// Log verbosity levels for the agent.
+ /// Info = normal operational messages (default)
+ /// Debug = includes DEBUG-tagged lines (component state, config, decisions)
+ /// Verbose = includes VERBOSE-tagged lines (per-event details, hot-path tracing)
+ ///
+ public enum AgentLogLevel
+ {
+ Info = 0,
+ Debug = 1,
+ Verbose = 2,
+ Trace = 3
+ }
+
+ ///
+ /// Simple file-based logger for the agent.
+ /// Optionally mirrors output to the console when EnableConsoleOutput is set.
+ ///
+ public class AgentLogger
+ {
+ private readonly string _logFilePath;
+ private AgentLogLevel _logLevel;
+ private readonly object _lockObject = new object();
+
+ ///
+ /// When true, log entries are also written to Console.Out (same format as the log file).
+ /// Set via --console flag or programmatically.
+ ///
+ public bool EnableConsoleOutput { get; set; }
+
+ public AgentLogger(string logDirectory, AgentLogLevel logLevel = AgentLogLevel.Info)
+ {
+ _logLevel = logLevel;
+
+ if (!Directory.Exists(logDirectory))
+ {
+ Directory.CreateDirectory(logDirectory);
+ }
+
+ var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log";
+ _logFilePath = Path.Combine(logDirectory, logFileName);
+ }
+
+ ///
+ /// Updates the active log level at runtime (e.g. after remote config is applied).
+ ///
+ public void SetLogLevel(AgentLogLevel level)
+ {
+ _logLevel = level;
+ }
+
+ public AgentLogLevel LogLevel => _logLevel;
+
+ public void Trace(string message)
+ {
+ if (_logLevel >= AgentLogLevel.Trace)
+ Log("TRACE", message);
+ }
+
+ public void Verbose(string message)
+ {
+ if (_logLevel >= AgentLogLevel.Verbose)
+ Log("VERBOSE", message);
+ }
+
+ public void Debug(string message)
+ {
+ if (_logLevel >= AgentLogLevel.Debug)
+ Log("DEBUG", message);
+ }
+
+ public void Info(string message)
+ {
+ Log("INFO", message);
+ }
+
+ public void Warning(string message)
+ {
+ Log("WARN", message);
+ }
+
+ public void Error(string message, Exception ex = null)
+ {
+ var fullMessage = ex != null ? $"{message} - {ex}" : message;
+ Log("ERROR", fullMessage);
+ }
+
+ private void Log(string level, string message)
+ {
+ try
+ {
+ lock (_lockObject)
+ {
+ var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
+ var logEntry = $"[{timestamp}] [{level}] {SanitizeLogMessage(message)}";
+
+ File.AppendAllText(_logFilePath, logEntry + Environment.NewLine);
+
+ if (EnableConsoleOutput)
+ {
+ try { Console.WriteLine(logEntry); } catch { }
+ }
+ }
+ }
+ catch
+ {
+ // Swallow logging errors to prevent crashes
+ }
+ }
+
+ ///
+ /// Sanitizes log messages to prevent log injection attacks.
+ /// Escapes control characters that could forge log entries.
+ /// Redaction patterns can be added here later as needed.
+ ///
+ private static string SanitizeLogMessage(string message)
+ {
+ if (string.IsNullOrEmpty(message)) return message;
+
+ // Escape newlines and carriage returns to prevent log injection
+ message = message.Replace("\r", "\\r").Replace("\n", "\\n");
+
+ return message;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs
new file mode 100644
index 00000000..6aeb46fb
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs
@@ -0,0 +1,96 @@
+using System;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
+{
+ ///
+ /// Pure-function guards for enrollment completion decisions.
+ /// Extracted 1:1 from the lock block in TryEmitEnrollmentComplete() for testability.
+ /// Each method mirrors the exact condition evaluated in CompletionLogic.cs.
+ ///
+ internal static class CompletionGuards
+ {
+ ///
+ /// Guard 1: Hello resolution check.
+ /// Hello is considered resolved when:
+ /// - No EspAndHelloTracker exists (no Hello tracking)
+ /// - Hello has already completed (success, skip, or timeout)
+ /// - Hello policy is not configured (Hello disabled)
+ /// - Deployment is device-only (no interactive user session)
+ ///
+ ///
+ /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
+ /// line: helloResolved = _espAndHelloTracker == null || ...IsHelloCompleted || !...IsPolicyConfigured || isDeviceOnly
+ ///
+ public static bool IsHelloResolved(bool hasTracker, bool isHelloCompleted,
+ bool isPolicyConfigured, bool isDeviceOnly)
+ {
+ return !hasTracker
+ || isHelloCompleted
+ || !isPolicyConfigured
+ || isDeviceOnly;
+ }
+
+ ///
+ /// Guard 2: ESP gate for desktop-based completion sources.
+ /// Blocks completion when:
+ /// - Source is desktop_arrival or desktop_hello
+ /// - Enrollment type is v1 (not WDP)
+ /// - ESP has been seen but has NOT exited yet
+ /// Rationale: In v1 enrollments with active ESP, desktop presence alone is
+ /// NOT sufficient to complete (AccountSetup may still be running).
+ ///
+ ///
+ /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
+ /// line: espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") && enrollmentType != "v2" && espEverSeen && !espFinalExitSeen
+ ///
+ public static bool IsEspGateBlocking(string source, string enrollmentType,
+ bool espEverSeen, bool espFinalExitSeen)
+ {
+ return (source == "desktop_arrival" || source == "desktop_hello")
+ && enrollmentType != "v2"
+ && espEverSeen
+ && !espFinalExitSeen;
+ }
+
+ ///
+ /// Guard 3: Hybrid join reboot gate.
+ /// In hybrid join, ESP may exit for a mid-enrollment reboot (domain user login required).
+ /// Blocks esp_hello_composite unless we have stronger confirmation:
+ /// - IME user session completed (imePatternSeenUtc has value)
+ /// - Agent restarted after ESP exit (agentStartTimeUtc > espFinalExitUtc)
+ ///
+ ///
+ /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
+ /// lines: if (_isHybridJoin && source == "esp_hello_composite") { ... hybridRebootGateBlocked = !hasImeCompletion && !agentRestartedAfterEspExit; }
+ ///
+ public static bool IsHybridRebootGateBlocking(bool isHybridJoin, string source,
+ DateTime? imePatternSeenUtc, DateTime? espFinalExitUtc, DateTime agentStartTimeUtc)
+ {
+ if (!isHybridJoin || source != "esp_hello_composite")
+ return false;
+
+ bool hasImeCompletion = imePatternSeenUtc.HasValue;
+ bool agentRestartedAfterEspExit = espFinalExitUtc.HasValue
+ && agentStartTimeUtc > espFinalExitUtc.Value;
+
+ return !hasImeCompletion && !agentRestartedAfterEspExit;
+ }
+
+ ///
+ /// Deployment classification: device-only check.
+ /// A deployment is device-only when:
+ /// - Self-Deploying mode (autopilotMode == 1), OR
+ /// - SkipUserStatusPage == true AND no user has AAD-joined
+ ///
+ ///
+ /// Source: EnrollmentTracker.cs, property IsDeviceOnlyDeployment
+ /// line: IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser)
+ ///
+ public static bool IsDeviceOnlyDeployment(int? autopilotMode, bool? skipUserStatusPage,
+ bool aadJoinedWithUser)
+ {
+ bool isSelfDeploying = autopilotMode == 1;
+ return isSelfDeploying || (skipUserStatusPage == true && !aadJoinedWithUser);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs
new file mode 100644
index 00000000..ca91b1cf
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs
@@ -0,0 +1,820 @@
+using System;
+using System.Collections.Generic;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
+{
+ ///
+ /// Read-only snapshot of all state needed for guard evaluation and transition decisions.
+ /// Populated under lock in the caller, consumed outside lock by the state machine.
+ /// Preserves the existing thread-safety pattern from EnrollmentTracker.
+ ///
+ internal class CompletionContext
+ {
+ // Deployment classification
+ public string EnrollmentType { get; set; }
+ public int? AutopilotMode { get; set; }
+ public bool? SkipUserStatusPage { get; set; }
+ public bool AadJoinedWithUser { get; set; }
+ public bool IsHybridJoin { get; set; }
+
+ // Computed deployment classification (mirrors IsDeviceOnlyDeployment property)
+ public bool IsDeviceOnly => CompletionGuards.IsDeviceOnlyDeployment(
+ AutopilotMode, SkipUserStatusPage, AadJoinedWithUser);
+
+ // Signal state
+ public bool EspEverSeen { get; set; }
+ public bool EspFinalExitSeen { get; set; }
+ public bool DesktopArrived { get; set; }
+ public string LastEspPhase { get; set; }
+ public bool DeviceInfoCollected { get; set; }
+
+ // Hello state (from EspAndHelloTracker)
+ public bool HasHelloTracker { get; set; }
+ public bool IsHelloCompleted { get; set; }
+ public bool IsHelloPolicyConfigured { get; set; }
+ public bool HasUnresolvedEspCategories { get; set; }
+ public bool HasAccountSetupActivity { get; set; }
+
+ // WhiteGlove detection signals
+ public bool WhiteGloveStartDetected { get; set; }
+ public bool HasSaveWhiteGloveSuccessResult { get; set; }
+ public bool ShellCoreWhiteGloveSuccess { get; set; }
+ public bool IsFooUserDetected { get; set; }
+
+ // Legacy helper — kept for backwards-compat with places that only need a binary
+ // "there is some WG evidence" read. Actual classification now goes through
+ // .
+ public bool IsWhiteGloveActive => HasSaveWhiteGloveSuccessResult;
+
+ // Computed: has the agent restarted since ESP final exit? Typical WhiteGlove Part 1
+ // boundary marker (the restart between DeviceSetup-complete and user sign-in).
+ public bool AgentRestartedAfterEspExit
+ => EspFinalExitUtc.HasValue && AgentStartTimeUtc > EspFinalExitUtc.Value;
+
+ // Timing
+ public DateTime AgentStartTimeUtc { get; set; }
+ public DateTime? EspFinalExitUtc { get; set; }
+ public DateTime? ImePatternSeenUtc { get; set; }
+
+ // Trigger metadata
+ public string Source { get; set; }
+ }
+
+ ///
+ /// Result of a state machine transition. Contains the new state and metadata
+ /// about what actions the caller should take (emit events, start timers, etc.).
+ /// The state machine itself is side-effect-free — all actions are communicated
+ /// via this result object to preserve the lock-free emission pattern.
+ ///
+ internal class TransitionResult
+ {
+ public static readonly TransitionResult NoTransition = new TransitionResult
+ {
+ Transitioned = false,
+ NewState = EnrollmentCompletionState.Idle
+ };
+
+ public bool Transitioned { get; set; }
+ public EnrollmentCompletionState NewState { get; set; }
+ public EnrollmentCompletionState PreviousState { get; set; }
+ public string CompletionSource { get; set; }
+
+ // Action flags — caller executes these OUTSIDE lock
+ public bool ShouldEmitEnrollmentComplete { get; set; }
+ public bool ShouldEmitEnrollmentFailed { get; set; }
+ public bool ShouldEmitWhiteGloveComplete { get; set; }
+ public bool ShouldStartHelloWaitTimer { get; set; }
+ public bool ShouldStartHelloSafetyTimer { get; set; }
+ public bool ShouldStartEspSettleTimer { get; set; }
+ public bool ShouldStartDeviceOnlyEspTimer { get; set; }
+ public bool ShouldStartDeviceOnlySafetyTimer { get; set; }
+ public bool ShouldForceMarkHelloCompleted { get; set; }
+ public bool ShouldResetEspForResumption { get; set; }
+ public bool ShouldPersistState { get; set; }
+ public string FailureType { get; set; }
+ public string DeferredSource { get; set; }
+
+ // Signals to record
+ public List SignalsToRecord { get; set; }
+ }
+
+ ///
+ /// Explicit state machine for enrollment completion logic.
+ /// Replaces the implicit boolean-flag-based state tracking in CompletionLogic.cs.
+ ///
+ /// Thread safety: The ProcessTrigger method is thread-safe. State reads and transitions
+ /// happen under a single lock (microsecond-level critical sections). All side effects
+ /// (event emission, timer management) are communicated via TransitionResult and executed
+ /// by the caller OUTSIDE the lock — identical pattern to the original code.
+ ///
+ /// This class is intentionally side-effect-free: it computes state transitions and
+ /// returns action flags. The caller (EnrollmentTracker) owns the actual side effects.
+ ///
+ internal class CompletionStateMachine
+ {
+ private readonly object _lock = new object();
+ private EnrollmentCompletionState _currentState = EnrollmentCompletionState.Idle;
+ private string _deferredSource;
+
+ // Signal state (mirrored from EnrollmentTracker fields)
+ private bool _espEverSeen;
+ private bool _espFinalExitSeen;
+ private bool _desktopArrived;
+ private bool _isWaitingForHello;
+ private bool _isWaitingForEspSettle;
+
+ public EnrollmentCompletionState CurrentState
+ {
+ get { lock (_lock) { return _currentState; } }
+ }
+
+ // Expose internal state for shadow-mode comparison and persistence
+ internal bool EspEverSeen { get { lock (_lock) { return _espEverSeen; } } }
+ internal bool EspFinalExitSeen { get { lock (_lock) { return _espFinalExitSeen; } } }
+ internal bool DesktopArrived { get { lock (_lock) { return _desktopArrived; } } }
+
+ ///
+ /// Reconstructs state from persisted EnrollmentStateData (crash recovery).
+ /// Must be called before Start() if recovering from a crash.
+ ///
+ public void RestoreState(EnrollmentCompletionState state, bool espEverSeen,
+ bool espFinalExitSeen, bool desktopArrived, bool isWaitingForHello,
+ bool isWaitingForEspSettle, string deferredSource)
+ {
+ lock (_lock)
+ {
+ _currentState = state;
+ _espEverSeen = espEverSeen;
+ _espFinalExitSeen = espFinalExitSeen;
+ _desktopArrived = desktopArrived;
+ _isWaitingForHello = isWaitingForHello;
+ _isWaitingForEspSettle = isWaitingForEspSettle;
+ _deferredSource = deferredSource;
+ }
+ }
+
+ ///
+ /// Reconstructs the explicit state from boolean flags (backwards compatibility).
+ /// Used when loading state from an old-format persistence file that doesn't
+ /// contain the CompletionState field.
+ ///
+ public static EnrollmentCompletionState ReconstructStateFromFlags(
+ bool enrollmentCompleteEmitted, bool isWaitingForHello, bool isWaitingForEspSettle,
+ bool espFinalExitSeen, bool desktopArrived, bool espEverSeen,
+ bool isDeviceOnly)
+ {
+ if (enrollmentCompleteEmitted)
+ return EnrollmentCompletionState.Completed;
+ if (isWaitingForHello)
+ return EnrollmentCompletionState.WaitingForHello;
+ if (isWaitingForEspSettle)
+ return EnrollmentCompletionState.WaitingForEspSettle;
+ if (isDeviceOnly && espFinalExitSeen)
+ return EnrollmentCompletionState.DeviceOnlyAwaitingCompletion;
+ if (espFinalExitSeen && desktopArrived)
+ return EnrollmentCompletionState.DesktopArrivedAwaitingHello;
+ if (espFinalExitSeen)
+ return EnrollmentCompletionState.EspExitedAwaitingCompletion;
+ if (espEverSeen && desktopArrived)
+ return EnrollmentCompletionState.DesktopArrivedEspBlocking;
+ if (espEverSeen)
+ return EnrollmentCompletionState.EspActive;
+ if (desktopArrived)
+ return EnrollmentCompletionState.DesktopArrivedAwaitingHello;
+ return EnrollmentCompletionState.Idle;
+ }
+
+ ///
+ /// Core state machine: processes a trigger against the current state and context.
+ /// Returns a TransitionResult describing what happened and what actions to take.
+ ///
+ /// The state transition happens atomically under lock.
+ /// All side-effect instructions are in the returned result — caller executes them outside lock.
+ ///
+ public TransitionResult ProcessTrigger(string trigger, CompletionContext ctx)
+ {
+ if (string.IsNullOrEmpty(trigger))
+ return TransitionResult.NoTransition;
+
+ lock (_lock)
+ {
+ // Sync internal signal flags from the authoritative tracker snapshot.
+ // Monoton false→true: these signals are stable-once-seen. The single
+ // intentional reset (HandleEspResumed clearing _espFinalExitSeen) runs
+ // AFTER this sync inside its own switch case, so it remains the last writer.
+ // Without this, the SM can drift from the tracker (e.g. when
+ // _stateData.EspEverSeen=true was set in memory but Save() hadn't run
+ // before a reboot — restore loads false and downstream guards misfire).
+ if (ctx != null)
+ {
+ if (ctx.EspEverSeen) _espEverSeen = true;
+ if (ctx.EspFinalExitSeen) _espFinalExitSeen = true;
+ if (ctx.DesktopArrived) _desktopArrived = true;
+ }
+
+ // Terminal states: no transitions allowed
+ if (_currentState.IsTerminal())
+ return TransitionResult.NoTransition;
+
+ switch (trigger)
+ {
+ case "esp_phase_changed":
+ return HandleEspPhaseChanged(ctx);
+
+ case "esp_exiting":
+ return HandleEspExiting(ctx);
+
+ case "desktop_arrived":
+ return HandleDesktopArrived(ctx);
+
+ case "hello_completed":
+ return HandleHelloCompleted(ctx);
+
+ case "ime_user_session_completed":
+ return HandleImeUserSessionCompleted(ctx);
+
+ case "device_setup_provisioning_complete":
+ return HandleDeviceSetupProvisioningComplete(ctx);
+
+ case "hello_safety_timeout":
+ return HandleHelloSafetyTimeout(ctx);
+
+ case "esp_settle_timeout":
+ return HandleEspSettleTimeout(ctx);
+
+ case "device_only_esp_timer_expired":
+ return HandleDeviceOnlyEspTimerExpired(ctx);
+
+ case "device_only_safety_timeout":
+ return HandleDeviceOnlySafetyTimeout(ctx);
+
+ case "esp_failure_terminal":
+ return HandleEspFailureTerminal(ctx);
+
+ case "esp_failure_grace_expired":
+ return HandleEspFailureTerminal(ctx);
+
+ case "whiteglove_complete":
+ return HandleWhiteGloveComplete(ctx);
+
+ case "esp_resumed":
+ return HandleEspResumed(ctx);
+
+ case "device_info_collected":
+ return HandleDeviceInfoCollected(ctx);
+
+ default:
+ return TransitionResult.NoTransition;
+ }
+ }
+ }
+
+ // ===== Trigger Handlers (all called under _lock) =====
+
+ private TransitionResult HandleEspPhaseChanged(CompletionContext ctx)
+ {
+ if (_currentState != EnrollmentCompletionState.Idle
+ && _currentState != EnrollmentCompletionState.EspActive)
+ return TransitionResult.NoTransition;
+
+ var prev = _currentState;
+ _espEverSeen = true;
+ _currentState = EnrollmentCompletionState.EspActive;
+
+ return new TransitionResult
+ {
+ Transitioned = prev != _currentState,
+ PreviousState = prev,
+ NewState = _currentState,
+ SignalsToRecord = new List { "esp_phase_changed" }
+ };
+ }
+
+ private TransitionResult HandleEspExiting(CompletionContext ctx)
+ {
+ // ESP exiting is only relevant when ESP has been seen
+ if (!_espEverSeen)
+ return TransitionResult.NoTransition;
+
+ var prev = _currentState;
+ var result = new TransitionResult { PreviousState = prev, SignalsToRecord = new List() };
+
+ // Path A: AccountSetup completed, desktop already arrived, or provisioning registry confirms AccountSetup
+ bool accountSetupFromProvisioning = ctx.LastEspPhase == null && ctx.HasAccountSetupActivity;
+ if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)
+ || _desktopArrived
+ || accountSetupFromProvisioning)
+ {
+ _espFinalExitSeen = true;
+ _currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion;
+ result.SignalsToRecord.Add("esp_final_exit");
+ result.ShouldStartHelloWaitTimer = true;
+ result.ShouldPersistState = true;
+
+ // Check if we can complete immediately
+ if (CanComplete(ctx, "esp_hello_composite"))
+ {
+ _currentState = EnrollmentCompletionState.Completed;
+ result.ShouldEmitEnrollmentComplete = true;
+ result.CompletionSource = "esp_hello_composite";
+ }
+ else if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking(
+ ctx.IsHybridJoin, "esp_hello_composite",
+ ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc))
+ {
+ _currentState = EnrollmentCompletionState.HybridRebootGateBlocked;
+ }
+ }
+ // Path B: Registry definitively says device-only
+ else if (ctx.SkipUserStatusPage == true)
+ {
+ // WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as
+ // HandleDeviceSetupProvisioningComplete and the EnrollmentTracker paths).
+ // Weak confidence falls through to the regular device-only path by design.
+ var wgSignals = WhiteGloveSignals.FromContext(ctx);
+ var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
+ if (wgClassification.ShouldRouteToWhiteGlovePart1)
+ {
+ _currentState = EnrollmentCompletionState.WhiteGloveCompleted;
+ result.ShouldEmitWhiteGloveComplete = true;
+ result.SignalsToRecord.Add("whiteglove_guard_esp_exiting");
+ result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}");
+ result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}");
+ result.Transitioned = true;
+ result.NewState = _currentState;
+ return result;
+ }
+
+ _espFinalExitSeen = true;
+ _currentState = EnrollmentCompletionState.DeviceOnlyAwaitingCompletion;
+ result.SignalsToRecord.Add("device_only_esp_registry");
+ result.ShouldStartHelloWaitTimer = true;
+ result.ShouldPersistState = true;
+
+ if (CanComplete(ctx, "device_only_esp_registry"))
+ {
+ _currentState = EnrollmentCompletionState.Completed;
+ result.ShouldEmitEnrollmentComplete = true;
+ result.CompletionSource = "device_only_esp_registry";
+ }
+ else
+ {
+ _currentState = EnrollmentCompletionState.DeviceOnlySafetyWait;
+ result.ShouldStartDeviceOnlySafetyTimer = true;
+ }
+ }
+ // Path C: Unknown — start device-only detection timer
+ else
+ {
+ result.ShouldStartDeviceOnlyEspTimer = true;
+ // State stays as-is (EspActive or DesktopArrivedEspBlocking)
+ }
+
+ result.Transitioned = prev != _currentState;
+ result.NewState = _currentState;
+ return result;
+ }
+
+ private TransitionResult HandleDesktopArrived(CompletionContext ctx)
+ {
+ if (_desktopArrived)
+ return TransitionResult.NoTransition;
+
+ var prev = _currentState;
+ _desktopArrived = true;
+
+ var result = new TransitionResult
+ {
+ PreviousState = prev,
+ ShouldPersistState = true,
+ SignalsToRecord = new List { "desktop_arrived" }
+ };
+
+ // SkipUserStatusPage handling: if ESP seen and SkipUser=true but no final exit yet
+ if (_espEverSeen && ctx.SkipUserStatusPage == true && !_espFinalExitSeen)
+ {
+ _espFinalExitSeen = true;
+ result.SignalsToRecord.Add("desktop_arrived_skip_user");
+ }
+
+ // Determine target state based on ESP status
+ if (_espEverSeen && !_espFinalExitSeen)
+ {
+ // ESP still active — gate blocks
+ _currentState = EnrollmentCompletionState.DesktopArrivedEspBlocking;
+ }
+ else
+ {
+ // No ESP blocking — can proceed toward completion
+ _currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello;
+
+ // Start Hello wait timer if needed (no active ESP, not device-only)
+ if (!ctx.IsDeviceOnly && ctx.HasHelloTracker && !ctx.IsHelloCompleted)
+ {
+ result.ShouldStartHelloWaitTimer = true;
+ }
+
+ // Try immediate completion
+ if (CanComplete(ctx, "desktop_arrival"))
+ {
+ _currentState = EnrollmentCompletionState.Completed;
+ result.ShouldEmitEnrollmentComplete = true;
+ result.CompletionSource = "desktop_arrival";
+ }
+ }
+
+ result.Transitioned = prev != _currentState;
+ result.NewState = _currentState;
+ return result;
+ }
+
+ private TransitionResult HandleHelloCompleted(CompletionContext ctx)
+ {
+ var prev = _currentState;
+ var result = new TransitionResult
+ {
+ PreviousState = prev,
+ SignalsToRecord = new List { "hello_resolved" }
+ };
+
+ if (_isWaitingForHello)
+ {
+ // IME path: was waiting for Hello
+ _isWaitingForHello = false;
+ if (CanCompleteWithHelloResolved(ctx, "ime_hello"))
+ {
+ _currentState = EnrollmentCompletionState.Completed;
+ result.ShouldEmitEnrollmentComplete = true;
+ result.CompletionSource = "ime_hello";
+ }
+ }
+ else if (_espFinalExitSeen)
+ {
+ // ESP composite path
+ string source = "esp_hello_composite";
+ if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking(
+ ctx.IsHybridJoin, source,
+ ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc))
+ {
+ _currentState = EnrollmentCompletionState.HybridRebootGateBlocked;
+ }
+ else
+ {
+ _currentState = EnrollmentCompletionState.Completed;
+ result.ShouldEmitEnrollmentComplete = true;
+ result.CompletionSource = source;
+ }
+ }
+ else if (_desktopArrived)
+ {
+ // Desktop + Hello path
+ string source = "desktop_hello";
+ if (!CompletionGuards.IsEspGateBlocking(source, ctx.EnrollmentType,
+ _espEverSeen, _espFinalExitSeen))
+ {
+ _currentState = EnrollmentCompletionState.Completed;
+ result.ShouldEmitEnrollmentComplete = true;
+ result.CompletionSource = source;
+ }
+ }
+
+ result.Transitioned = prev != _currentState;
+ result.NewState = _currentState;
+ return result;
+ }
+
+ private TransitionResult HandleImeUserSessionCompleted(CompletionContext ctx)
+ {
+ var prev = _currentState;
+ var result = new TransitionResult
+ {
+ PreviousState = prev,
+ SignalsToRecord = new List { "ime_pattern" }
+ };
+
+ // Device-only: skip Hello entirely
+ if (ctx.IsDeviceOnly)
+ {
+ _currentState = EnrollmentCompletionState.Completed;
+ result.ShouldEmitEnrollmentComplete = true;
+ result.CompletionSource = "ime_pattern";
+ result.Transitioned = true;
+ result.NewState = _currentState;
+ return result;
+ }
+
+ // Hello configured but not yet completed — wait
+ if (ctx.HasHelloTracker && ctx.IsHelloPolicyConfigured && !ctx.IsHelloCompleted)
+ {
+ _isWaitingForHello = true;
+ _currentState = EnrollmentCompletionState.WaitingForHello;
+ result.ShouldStartHelloWaitTimer = true;
+ result.ShouldStartHelloSafetyTimer = true;
+ result.ShouldPersistState = true;
+ result.Transitioned = true;
+ result.NewState = _currentState;
+ return result;
+ }
+
+ // ESP categories unresolved — wait for settle
+ if (ctx.HasHelloTracker && ctx.HasUnresolvedEspCategories)
+ {
+ _isWaitingForEspSettle = true;
+ _currentState = EnrollmentCompletionState.WaitingForEspSettle;
+ result.ShouldStartEspSettleTimer = true;
+ result.ShouldPersistState = true;
+ result.Transitioned = true;
+ result.NewState = _currentState;
+ return result;
+ }
+
+ // All clear — complete
+ _currentState = EnrollmentCompletionState.Completed;
+ result.ShouldEmitEnrollmentComplete = true;
+ result.CompletionSource = "ime_pattern";
+ result.Transitioned = true;
+ result.NewState = _currentState;
+ return result;
+ }
+
+ private TransitionResult HandleDeviceSetupProvisioningComplete(CompletionContext ctx)
+ {
+ var prev = _currentState;
+ var result = new TransitionResult
+ {
+ PreviousState = prev,
+ SignalsToRecord = new List { "device_setup_provisioning_complete" }
+ };
+
+ // WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as
+ // EnrollmentTracker.CompletionLogic.OnDeviceSetupProvisioningComplete and
+ // TryEmitEnrollmentComplete). Weak confidence falls through to the regular
+ // self-deploying path by design (asymmetric-conservative routing).
+ var wgSignals = WhiteGloveSignals.FromContext(ctx);
+ var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
+ if (wgClassification.ShouldRouteToWhiteGlovePart1)
+ {
+ _currentState = EnrollmentCompletionState.WhiteGloveCompleted;
+ result.ShouldEmitWhiteGloveComplete = true;
+ result.SignalsToRecord.Add("whiteglove_guard_activated");
+ result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}");
+ result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}");
+ result.Transitioned = true;
+ result.NewState = _currentState;
+ return result;
+ }
+
+ if (!ctx.IsDeviceOnly)
+ {
+ // Not device-only: check if device info collected yet
+ if (!ctx.DeviceInfoCollected)
+ {
+ _deferredSource = "device_setup_provisioning_complete";
+ _currentState = EnrollmentCompletionState.DeferredForDeviceInfo;
+ result.DeferredSource = _deferredSource;
+ result.Transitioned = true;
+ result.NewState = _currentState;
+ return result;
+ }
+ // Not device-only and device info collected: normal paths handle it
+ return TransitionResult.NoTransition;
+ }
+
+ // Device-only: mark ESP as seen and exited
+ if (!_espEverSeen) _espEverSeen = true;
+ if (!_espFinalExitSeen)
+ {
+ _espFinalExitSeen = true;
+ result.SignalsToRecord.Add("self_deploying_esp_final_exit");
+ }
+ result.ShouldPersistState = true;
+
+ _currentState = EnrollmentCompletionState.Completed;
+ result.ShouldEmitEnrollmentComplete = true;
+ result.CompletionSource = "self_deploying_provisioning_complete";
+ result.Transitioned = true;
+ result.NewState = _currentState;
+ return result;
+ }
+
+ private TransitionResult HandleHelloSafetyTimeout(CompletionContext ctx)
+ {
+ if (!_isWaitingForHello)
+ return TransitionResult.NoTransition;
+
+ var prev = _currentState;
+ _isWaitingForHello = false;
+ _currentState = EnrollmentCompletionState.Completed;
+
+ return new TransitionResult
+ {
+ Transitioned = true,
+ PreviousState = prev,
+ NewState = _currentState,
+ ShouldForceMarkHelloCompleted = true,
+ ShouldEmitEnrollmentComplete = true,
+ CompletionSource = "ime_hello_safety_timeout",
+ SignalsToRecord = new List { "ime_hello_safety_timeout" }
+ };
+ }
+
+ private TransitionResult HandleEspSettleTimeout(CompletionContext ctx)
+ {
+ if (!_isWaitingForEspSettle)
+ return TransitionResult.NoTransition;
+
+ var prev = _currentState;
+ _isWaitingForEspSettle = false;
+ _currentState = EnrollmentCompletionState.Completed;
+
+ return new TransitionResult
+ {
+ Transitioned = true,
+ PreviousState = prev,
+ NewState = _currentState,
+ ShouldEmitEnrollmentComplete = true,
+ CompletionSource = "ime_pattern",
+ SignalsToRecord = new List { "esp_provisioning_settled" }
+ };
+ }
+
+ private TransitionResult HandleDeviceOnlyEspTimerExpired(CompletionContext ctx)
+ {
+ // AccountSetup started meanwhile? Timer is obsolete
+ if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ return TransitionResult.NoTransition;
+
+ if (!_desktopArrived)
+ return TransitionResult.NoTransition; // Wait for desktop
+
+ var prev = _currentState;
+ _espFinalExitSeen = true;
+ _currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello;
+
+ return new TransitionResult
+ {
+ Transitioned = true,
+ PreviousState = prev,
+ NewState = _currentState,
+ ShouldStartHelloWaitTimer = true,
+ ShouldPersistState = true,
+ SignalsToRecord = new List { "device_only_esp_final_exit" }
+ };
+ }
+
+ private TransitionResult HandleDeviceOnlySafetyTimeout(CompletionContext ctx)
+ {
+ var prev = _currentState;
+ _currentState = EnrollmentCompletionState.Completed;
+
+ return new TransitionResult
+ {
+ Transitioned = true,
+ PreviousState = prev,
+ NewState = _currentState,
+ ShouldForceMarkHelloCompleted = true,
+ ShouldEmitEnrollmentComplete = true,
+ CompletionSource = "device_only_esp_safety_timeout",
+ SignalsToRecord = new List { "device_only_esp_safety_timeout" }
+ };
+ }
+
+ private TransitionResult HandleEspFailureTerminal(CompletionContext ctx)
+ {
+ var prev = _currentState;
+ _currentState = EnrollmentCompletionState.Failed;
+
+ return new TransitionResult
+ {
+ Transitioned = true,
+ PreviousState = prev,
+ NewState = _currentState,
+ ShouldEmitEnrollmentFailed = true,
+ FailureType = ctx.Source
+ };
+ }
+
+ private TransitionResult HandleWhiteGloveComplete(CompletionContext ctx)
+ {
+ var prev = _currentState;
+ _currentState = EnrollmentCompletionState.WhiteGloveCompleted;
+
+ return new TransitionResult
+ {
+ Transitioned = true,
+ PreviousState = prev,
+ NewState = _currentState,
+ ShouldEmitWhiteGloveComplete = true
+ };
+ }
+
+ private TransitionResult HandleEspResumed(CompletionContext ctx)
+ {
+ if (!ctx.IsHybridJoin || !_espFinalExitSeen)
+ return TransitionResult.NoTransition;
+
+ var prev = _currentState;
+ _espFinalExitSeen = false;
+ _currentState = EnrollmentCompletionState.EspActive;
+
+ return new TransitionResult
+ {
+ Transitioned = true,
+ PreviousState = prev,
+ NewState = _currentState,
+ ShouldResetEspForResumption = true,
+ ShouldPersistState = true,
+ SignalsToRecord = new List { "esp_resumed" }
+ };
+ }
+
+ private TransitionResult HandleDeviceInfoCollected(CompletionContext ctx)
+ {
+ if (_currentState != EnrollmentCompletionState.DeferredForDeviceInfo)
+ return TransitionResult.NoTransition;
+
+ var prev = _currentState;
+ var deferredSource = _deferredSource;
+ _deferredSource = null;
+
+ // Re-evaluate: now that device info is available, check if device-only
+ if (ctx.IsDeviceOnly)
+ {
+ if (!_espEverSeen) _espEverSeen = true;
+ if (!_espFinalExitSeen)
+ {
+ _espFinalExitSeen = true;
+ }
+
+ _currentState = EnrollmentCompletionState.Completed;
+ return new TransitionResult
+ {
+ Transitioned = true,
+ PreviousState = prev,
+ NewState = _currentState,
+ ShouldEmitEnrollmentComplete = true,
+ CompletionSource = deferredSource ?? "device_setup_provisioning_complete",
+ ShouldPersistState = true,
+ SignalsToRecord = new List { "self_deploying_esp_final_exit" }
+ };
+ }
+
+ // Not device-only: revert to appropriate non-terminal state
+ if (_espFinalExitSeen)
+ _currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion;
+ else if (_espEverSeen)
+ _currentState = _desktopArrived
+ ? EnrollmentCompletionState.DesktopArrivedEspBlocking
+ : EnrollmentCompletionState.EspActive;
+ else
+ _currentState = _desktopArrived
+ ? EnrollmentCompletionState.DesktopArrivedAwaitingHello
+ : EnrollmentCompletionState.Idle;
+
+ return new TransitionResult
+ {
+ Transitioned = true,
+ PreviousState = prev,
+ NewState = _currentState,
+ DeferredSource = deferredSource
+ };
+ }
+
+ // ===== Guard Helpers (called under lock) =====
+
+ ///
+ /// Checks all three completion guards for a given source.
+ ///
+ private bool CanComplete(CompletionContext ctx, string source)
+ {
+ bool helloResolved = CompletionGuards.IsHelloResolved(
+ ctx.HasHelloTracker, ctx.IsHelloCompleted,
+ ctx.IsHelloPolicyConfigured, ctx.IsDeviceOnly);
+
+ bool espGateBlocking = CompletionGuards.IsEspGateBlocking(
+ source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen);
+
+ bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking(
+ ctx.IsHybridJoin, source,
+ ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc);
+
+ return helloResolved && !espGateBlocking && !hybridRebootGateBlocking;
+ }
+
+ ///
+ /// Completion check with Hello always resolved (caller has just confirmed Hello completed).
+ /// Only checks ESP gate and hybrid reboot gate.
+ ///
+ private bool CanCompleteWithHelloResolved(CompletionContext ctx, string source)
+ {
+ bool espGateBlocking = CompletionGuards.IsEspGateBlocking(
+ source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen);
+
+ bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking(
+ ctx.IsHybridJoin, source,
+ ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc);
+
+ return !espGateBlocking && !hybridRebootGateBlocking;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs
new file mode 100644
index 00000000..d9ec5c57
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs
@@ -0,0 +1,393 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Security.AccessControl;
+using System.Security.Principal;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Agent.V2.Core.Security;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
+{
+ ///
+ /// Handles enrollment terminal events: enrollment_complete, enrollment_failed, and
+ /// whiteglove_complete shutdown sequences including diagnostics upload, summary dialog,
+ /// self-destruct, and reboot.
+ ///
+ public class EnrollmentCompletionHandler
+ {
+ private readonly AgentConfiguration _configuration;
+ private readonly AgentLogger _logger;
+ private readonly string _agentVersion;
+ private readonly Action _emitEvent;
+ private readonly Action> _emitShutdownEvent;
+ private readonly Func _uploadEventsAsync;
+ private readonly Action _stopUploadTimers;
+ private readonly CleanupService _cleanupService;
+ private readonly DiagnosticsPackageService _diagnosticsService;
+ private readonly SessionPersistence _sessionPersistence;
+ private readonly EventSpool _spool;
+
+ public EnrollmentCompletionHandler(
+ AgentConfiguration configuration,
+ AgentLogger logger,
+ string agentVersion,
+ Action emitEvent,
+ Action> emitShutdownEvent,
+ Func uploadEventsAsync,
+ Action stopUploadTimers,
+ CleanupService cleanupService,
+ DiagnosticsPackageService diagnosticsService,
+ SessionPersistence sessionPersistence,
+ EventSpool spool)
+ {
+ _configuration = configuration;
+ _logger = logger;
+ _agentVersion = agentVersion;
+ _emitEvent = emitEvent;
+ _emitShutdownEvent = emitShutdownEvent;
+ _uploadEventsAsync = uploadEventsAsync;
+ _stopUploadTimers = stopUploadTimers;
+ _cleanupService = cleanupService;
+ _diagnosticsService = diagnosticsService;
+ _sessionPersistence = sessionPersistence;
+ _spool = spool;
+ }
+
+ ///
+ /// Handles normal enrollment completion (success or failure).
+ /// Stops collectors, runs shutdown analyzers, uploads diagnostics, optionally self-destructs.
+ ///
+ public async Task HandleEnrollmentComplete(
+ bool enrollmentSucceeded,
+ bool isWhiteGlovePart2,
+ Action stopCollectors,
+ Action runShutdownAnalyzers)
+ {
+ try
+ {
+ _logger.Info("===== ENROLLMENT COMPLETE - Starting shutdown sequence =====");
+
+ _logger.Info("Stopping event collectors...");
+ stopCollectors();
+ _spool.StopWatching();
+
+ runShutdownAnalyzers(isWhiteGlovePart2 ? 2 : (int?)null);
+
+ _logger.Info("Uploading final events...");
+ await _uploadEventsAsync();
+
+ await Task.Delay(2000);
+
+ await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded);
+
+ if (_configuration.ShowEnrollmentSummary)
+ LaunchEnrollmentSummaryDialog();
+
+ if (_configuration.SelfDestructOnComplete)
+ {
+ _cleanupService.ExecuteSelfDestruct();
+ }
+ else if (_configuration.RebootOnComplete)
+ {
+ _logger.Info($"RebootOnComplete enabled - initiating reboot in {_configuration.RebootDelaySeconds}s");
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = "reboot_triggered",
+ Severity = EventSeverity.Info,
+ Source = "MonitoringService",
+ Phase = EnrollmentPhase.Complete,
+ Message = $"Reboot configured — triggering reboot in {_configuration.RebootDelaySeconds} seconds",
+ Data = new Dictionary
+ {
+ { "rebootDelaySeconds", _configuration.RebootDelaySeconds }
+ },
+ ImmediateUpload = true
+ });
+
+ await _uploadEventsAsync();
+
+ StartReboot(_configuration.RebootDelaySeconds);
+ }
+
+ _emitShutdownEvent(
+ enrollmentSucceeded ? "enrollment_complete" : "enrollment_failed",
+ $"Agent shutting down after enrollment {(enrollmentSucceeded ? "completion" : "failure")}",
+ new Dictionary
+ {
+ { "enrollmentSucceeded", enrollmentSucceeded },
+ { "selfDestruct", _configuration.SelfDestructOnComplete },
+ { "reboot", _configuration.RebootOnComplete }
+ });
+ await _uploadEventsAsync();
+
+ _logger.Info("Shutdown sequence complete. Agent will now exit.");
+ ExitProcess(0);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error during self-destruct sequence", ex);
+ ExitProcess(1);
+ }
+ }
+
+ ///
+ /// Graceful shutdown after WhiteGlove (Pre-Provisioning) completes.
+ /// Does NOT delete the session ID — the session must survive for Part 2.
+ ///
+ public async Task HandleWhiteGloveComplete(
+ Action stopCollectors,
+ Action runShutdownAnalyzers,
+ Func readEventSequence)
+ {
+ try
+ {
+ _logger.Info("===== WHITEGLOVE COMPLETE - Starting graceful shutdown sequence =====");
+
+ runShutdownAnalyzers(1);
+
+ _logger.Info("Uploading final events (draining spool)...");
+ int maxIterations = 20;
+ while (_spool.GetCount() > 0 && maxIterations-- > 0)
+ {
+ await _uploadEventsAsync();
+ }
+
+ await Task.Delay(2000);
+
+ await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov");
+
+ _sessionPersistence.SaveWhiteGloveComplete();
+ _logger.Info("WhiteGlove marker persisted for Part 2 detection");
+
+ _emitShutdownEvent(
+ "whiteglove_complete",
+ "Agent shutting down after WhiteGlove Part 1 — no cleanup, session preserved for Part 2 on next boot",
+ new Dictionary
+ {
+ { "sessionPreserved", true },
+ { "selfDestruct", false },
+ { "reboot", false },
+ { "cleanup", false },
+ { "willResumeOnNextBoot", true }
+ });
+ await _uploadEventsAsync();
+
+ var finalSequence = readEventSequence();
+ _sessionPersistence.SaveSequence(finalSequence);
+ _logger.Info($"Sequence counter persisted at {finalSequence} for next boot");
+
+ _logger.Info("WhiteGlove graceful shutdown complete. Agent exiting.");
+ ExitProcess(0);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error during WhiteGlove shutdown sequence", ex);
+ ExitProcess(0);
+ }
+ }
+
+ internal virtual void ExitProcess(int code) => Environment.Exit(code);
+
+ internal virtual void StartReboot(int delaySeconds)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = SystemPaths.Shutdown,
+ Arguments = $"/r /t {delaySeconds} /c \"Autopilot enrollment completed - rebooting\"",
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ Process.Start(psi);
+ }
+
+ ///
+ /// Deletes the persisted session ID and sequence files.
+ ///
+ public void DeleteSessionId()
+ {
+ _logger.Info("Deleting persisted session ID and sequence...");
+ try
+ {
+ _sessionPersistence.DeleteSession();
+ _logger.Info("Session ID and sequence deleted successfully");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Failed to delete session ID: {ex.Message}");
+ }
+ }
+
+ private async Task UploadDiagnosticsWithTimelineEvents(bool enrollmentSucceeded, string fileNameSuffix = null)
+ {
+ var mode = _configuration.DiagnosticsUploadMode ?? "Off";
+ if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
+ return;
+ if (!_configuration.DiagnosticsUploadEnabled)
+ return;
+ if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
+ return;
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = "diagnostics_collecting",
+ Severity = EventSeverity.Info,
+ Source = "MonitoringService",
+ Phase = EnrollmentPhase.Complete,
+ Message = $"Collecting diagnostics package (mode={mode}{(fileNameSuffix != null ? $", type={fileNameSuffix}" : "")})",
+ Data = new Dictionary
+ {
+ { "uploadMode", mode }
+ }
+ });
+ await _uploadEventsAsync();
+
+ var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix);
+ var success = uploadResult?.Success == true;
+
+ var resultData = new Dictionary
+ {
+ { "uploadMode", mode }
+ };
+ if (uploadResult?.BlobName != null)
+ resultData["blobName"] = uploadResult.BlobName;
+ if (uploadResult?.SasUrlPrefix != null)
+ resultData["sasUrl"] = uploadResult.SasUrlPrefix;
+ if (uploadResult?.ErrorCode != null)
+ resultData["errorCode"] = uploadResult.ErrorCode;
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = success ? "diagnostics_uploaded" : "diagnostics_upload_failed",
+ Severity = success ? EventSeverity.Info : EventSeverity.Warning,
+ Source = "MonitoringService",
+ Phase = EnrollmentPhase.Complete,
+ Message = success
+ ? $"Diagnostics package uploaded: {uploadResult.BlobName}"
+ : $"Diagnostics package upload failed{(uploadResult?.ErrorCode != null ? $": {uploadResult.ErrorCode}" : "")}",
+ Data = resultData
+ });
+ await _uploadEventsAsync();
+ }
+
+ private void LaunchEnrollmentSummaryDialog()
+ {
+ try
+ {
+ var agentDir = Path.GetDirectoryName(
+ System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty;
+ var dialogExe = Path.Combine(agentDir, "AutopilotMonitor.SummaryDialog.exe");
+
+ if (!File.Exists(dialogExe))
+ {
+ _logger.Warning($"Summary dialog EXE not found at {dialogExe} — skipping");
+ return;
+ }
+
+ var statusFile = Path.Combine(
+ Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State"),
+ "final-status.json");
+
+ if (!File.Exists(statusFile))
+ {
+ _logger.Warning($"final-status.json not found at {statusFile} — skipping summary dialog");
+ return;
+ }
+
+ var tempDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "AutopilotMonitor-Summary");
+
+ try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
+ Directory.CreateDirectory(tempDir);
+
+ GrantUserDeletePermission(tempDir);
+
+ var tempDialogExe = Path.Combine(tempDir, "AutopilotMonitor.SummaryDialog.exe");
+ File.Copy(dialogExe, tempDialogExe);
+
+ var jsonDll = Path.Combine(agentDir, "Newtonsoft.Json.dll");
+ if (File.Exists(jsonDll))
+ File.Copy(jsonDll, Path.Combine(tempDir, "Newtonsoft.Json.dll"));
+
+ var exeConfig = dialogExe + ".config";
+ if (File.Exists(exeConfig))
+ File.Copy(exeConfig, tempDialogExe + ".config");
+
+ var tempStatusFile = Path.Combine(tempDir, "final-status.json");
+ File.Copy(statusFile, tempStatusFile);
+
+ var args = $"--status-file \"{tempStatusFile}\" --timeout {_configuration.EnrollmentSummaryTimeoutSeconds} --cleanup";
+ if (!string.IsNullOrEmpty(_configuration.EnrollmentSummaryBrandingImageUrl))
+ args += $" --branding-url \"{_configuration.EnrollmentSummaryBrandingImageUrl}\"";
+
+ _logger.Info($"Launching enrollment summary dialog: {tempDialogExe} {args}");
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = "enrollment_summary_shown",
+ Severity = EventSeverity.Info,
+ Source = "MonitoringService",
+ Phase = EnrollmentPhase.Complete,
+ Message = "Launching enrollment summary dialog to user"
+ });
+
+ try { _uploadEventsAsync().Wait(TimeSpan.FromSeconds(5)); } catch { }
+
+ var launched = UserSessionProcessLauncher.LaunchInUserSession(
+ tempDialogExe, args, _logger,
+ _configuration.EnrollmentSummaryLaunchRetrySeconds);
+ if (!launched)
+ {
+ _logger.Warning("Could not launch summary dialog in user session (no interactive session found)");
+ try { Directory.Delete(tempDir, true); } catch { }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Failed to launch enrollment summary dialog: {ex.Message}");
+ }
+ }
+
+ private void GrantUserDeletePermission(string directoryPath)
+ {
+ try
+ {
+ var dirInfo = new DirectoryInfo(directoryPath);
+ var security = dirInfo.GetAccessControl();
+ var usersIdentity = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
+ security.AddAccessRule(new FileSystemAccessRule(
+ usersIdentity,
+ FileSystemRights.FullControl,
+ InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
+ PropagationFlags.None,
+ AccessControlType.Allow));
+ dirInfo.SetAccessControl(security);
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"Could not set ACL on summary folder: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs
new file mode 100644
index 00000000..47e705cc
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs
@@ -0,0 +1,120 @@
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
+{
+ ///
+ /// Explicit states for the enrollment completion state machine.
+ /// Each state represents a distinguishable configuration of the implicit boolean flags
+ /// that previously existed only as field combinations in EnrollmentTracker.CompletionLogic.cs.
+ ///
+ internal enum EnrollmentCompletionState
+ {
+ ///
+ /// Initial state. No completion-relevant signals received.
+ /// Waiting for: ESP phase event, desktop arrival, or device setup provisioning.
+ ///
+ Idle,
+
+ ///
+ /// ESP has been seen (at least one phase detected) but no final exit yet.
+ /// Waiting for: ESP exit (FinalizingSetup trigger), desktop arrival, or device-only timer.
+ ///
+ EspActive,
+
+ ///
+ /// ESP exited from AccountSetup (or desktop-arrival backup). espFinalExitSeen=true.
+ /// Waiting for: Hello resolution, IME user session completion, or safety timeout.
+ ///
+ EspExitedAwaitingCompletion,
+
+ ///
+ /// IME user session completed but Hello is configured and not yet resolved.
+ /// isWaitingForHello=true. Safety timer running (420s / 7 min).
+ ///
+ WaitingForHello,
+
+ ///
+ /// IME user session completed but ESP provisioning categories not yet resolved in registry.
+ /// isWaitingForEspSettle=true. Settle timer running (30s).
+ ///
+ WaitingForEspSettle,
+
+ ///
+ /// Desktop arrived but ESP is still active (ESP gate blocking completion).
+ /// Waiting for: ESP final exit.
+ ///
+ DesktopArrivedEspBlocking,
+
+ ///
+ /// Desktop arrived, no ESP blocking (ESP exited, never seen, or v2). Hello may be pending.
+ /// Waiting for: Hello resolution or direct completion.
+ ///
+ DesktopArrivedAwaitingHello,
+
+ ///
+ /// Device-only deployment detected (Self-Deploying or SkipUserStatusPage=true without AAD user).
+ /// Waiting for provisioning complete or IME completion. Hello guard bypassed.
+ ///
+ DeviceOnlyAwaitingCompletion,
+
+ ///
+ /// Device-only ESP exit detected but completion didn't fire immediately (Hello pending).
+ /// Safety timer running (420s / 7 min).
+ ///
+ DeviceOnlySafetyWait,
+
+ ///
+ /// Hybrid join: esp_hello_composite blocked by reboot gate.
+ /// Waiting for: IME user session completion or agent restart confirmation after ESP exit.
+ ///
+ HybridRebootGateBlocked,
+
+ ///
+ /// Device info not yet collected when a completion signal arrived.
+ /// Completion source deferred until CollectDeviceInfo finishes (pendingCompletionSource set).
+ ///
+ DeferredForDeviceInfo,
+
+ ///
+ /// Terminal: enrollment_complete event emitted. No further transitions possible.
+ ///
+ Completed,
+
+ ///
+ /// Terminal: enrollment_failed event emitted. No further transitions possible.
+ ///
+ Failed,
+
+ ///
+ /// Terminal: whiteglove_complete event emitted. No further transitions possible.
+ ///
+ WhiteGloveCompleted
+ }
+
+ ///
+ /// Extension methods for EnrollmentCompletionState.
+ ///
+ internal static class EnrollmentCompletionStateExtensions
+ {
+ ///
+ /// Returns true if the state is terminal (Completed, Failed, or WhiteGloveCompleted).
+ /// No further transitions are allowed from terminal states.
+ ///
+ public static bool IsTerminal(this EnrollmentCompletionState state)
+ {
+ return state == EnrollmentCompletionState.Completed
+ || state == EnrollmentCompletionState.Failed
+ || state == EnrollmentCompletionState.WhiteGloveCompleted;
+ }
+
+ ///
+ /// Returns true if the state is a waiting state (actively waiting for a signal or timeout).
+ ///
+ public static bool IsWaiting(this EnrollmentCompletionState state)
+ {
+ return state == EnrollmentCompletionState.WaitingForHello
+ || state == EnrollmentCompletionState.WaitingForEspSettle
+ || state == EnrollmentCompletionState.DeviceOnlySafetyWait
+ || state == EnrollmentCompletionState.HybridRebootGateBlocked
+ || state == EnrollmentCompletionState.DeferredForDeviceInfo;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs
new file mode 100644
index 00000000..000e8943
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
+{
+ ///
+ /// Confidence level for a WhiteGlove Part 1 classification.
+ ///
+ internal enum WhiteGloveConfidence
+ {
+ /// Score below — definitely not WhiteGlove.
+ None,
+
+ /// Score between low and high thresholds — ambiguous. Routed as NOT WhiteGlove (asymmetric-conservative).
+ Weak,
+
+ /// Score at or above — classify as WhiteGlove Part 1.
+ Strong,
+ }
+
+ ///
+ /// Result of a call. Contains the
+ /// final routing decision, the numeric score and the contributing factors for
+ /// observability / tuning.
+ ///
+ internal sealed class WhiteGloveClassification
+ {
+ public WhiteGloveConfidence Confidence { get; set; }
+ public int Score { get; set; }
+ public List ContributingFactors { get; set; } = new List();
+ public string Reason { get; set; }
+
+ ///
+ /// Single Boolean the caller should read to decide whether to route to
+ /// whiteglove_complete (Pending) instead of the normal completion path.
+ /// Only true when is .
+ ///
+ public bool ShouldRouteToWhiteGlovePart1 { get; set; }
+ }
+
+ ///
+ /// Scoring-based WhiteGlove Part 1 classifier — modelled on the backend
+ /// AnalyzeRule.BaseConfidence + ConfidenceFactors + ConfidenceThreshold pattern.
+ ///
+ /// Asymmetric-conservative decision rule: only a
+ /// score routes to WhiteGlove. A score is emitted
+ /// to telemetry for tuning but treated as NOT WhiteGlove. Rationale: a false-positive WG
+ /// classification keeps the agent pending when it should exit; a false-negative emits
+ /// enrollment_complete early and the later user sign-in triggers a new session —
+ /// the user-visible cost of false-negative is much lower than false-positive.
+ ///
+ internal static class WhiteGloveClassifier
+ {
+ // Thresholds (tunable after production data — see project memory).
+ internal const int HighThreshold = 70; // >= 70 → Strong → route to WG
+ internal const int LowThreshold = 30; // 30..69 → Weak (telemetry only)
+ internal const int BaseConfidence = 0;
+
+ // Positive-signal weights
+ internal const int WeightShellCoreWhiteGloveSuccess = 80; // definitive — Event 62407
+ internal const int WeightHasSaveWhiteGloveSuccess = 10; // very weak — also fires on non-WG devices
+ internal const int WeightWhiteGloveStartDetected = 15; // soft — Event 509 also fires on hybrid
+ internal const int WeightFooUserDetected = 20; // weak — pre-provisioning marker
+ internal const int WeightAgentRestartedAfterEspExit = 10; // weak — typical WG boundary
+ internal const int WeightDeviceOnlyDeployment = 15; // context boost
+
+ // Negative-signal weights (hard excluders)
+ internal const int WeightAadJoinedWithUser = -100; // hard exclude
+ internal const int WeightDesktopArrived = -100; // hard exclude
+ internal const int WeightAccountSetupActive = -40; // soft exclude
+
+ public static WhiteGloveClassification Classify(WhiteGloveSignals s)
+ {
+ if (s == null) throw new ArgumentNullException(nameof(s));
+
+ var factors = new List();
+ int score = BaseConfidence;
+
+ // Hard excluders
+ if (s.AadJoinedWithUser)
+ {
+ score += WeightAadJoinedWithUser;
+ factors.Add($"aad_joined_with_user:{WeightAadJoinedWithUser:+#;-#;0}");
+ }
+ if (s.DesktopArrived)
+ {
+ score += WeightDesktopArrived;
+ factors.Add($"desktop_arrived:{WeightDesktopArrived:+#;-#;0}");
+ }
+ if (s.HasAccountSetupActivity)
+ {
+ score += WeightAccountSetupActive;
+ factors.Add($"account_setup_activity:{WeightAccountSetupActive:+#;-#;0}");
+ }
+
+ // Positive signals — weighted by empirical reliability
+ if (s.ShellCoreWhiteGloveSuccess)
+ {
+ score += WeightShellCoreWhiteGloveSuccess;
+ factors.Add($"shellcore_wg_success:{WeightShellCoreWhiteGloveSuccess:+#;-#;0}");
+ }
+ if (s.HasSaveWhiteGloveSuccessResult)
+ {
+ score += WeightHasSaveWhiteGloveSuccess;
+ factors.Add($"save_wg_success:{WeightHasSaveWhiteGloveSuccess:+#;-#;0}");
+ }
+ if (s.IsWhiteGloveStartDetected)
+ {
+ score += WeightWhiteGloveStartDetected;
+ factors.Add($"event509_wg_start:{WeightWhiteGloveStartDetected:+#;-#;0}");
+ }
+ if (s.IsFooUserDetected)
+ {
+ score += WeightFooUserDetected;
+ factors.Add($"foouser_detected:{WeightFooUserDetected:+#;-#;0}");
+ }
+ if (s.AgentRestartedAfterEspExit)
+ {
+ score += WeightAgentRestartedAfterEspExit;
+ factors.Add($"agent_restart_after_esp:{WeightAgentRestartedAfterEspExit:+#;-#;0}");
+ }
+ if (s.IsDeviceOnlyDeployment)
+ {
+ score += WeightDeviceOnlyDeployment;
+ factors.Add($"device_only_deployment:{WeightDeviceOnlyDeployment:+#;-#;0}");
+ }
+
+ // NOTE: no hybrid-join dampener — WhiteGlove works on both AADJ and hybrid.
+ // NOTE: no "long stable since DeviceSetup" bonus — devices stand idle for other reasons.
+
+ // Cap to [0, 100]
+ if (score < 0) score = 0;
+ if (score > 100) score = 100;
+
+ WhiteGloveConfidence confidence;
+ if (score >= HighThreshold) confidence = WhiteGloveConfidence.Strong;
+ else if (score >= LowThreshold) confidence = WhiteGloveConfidence.Weak;
+ else confidence = WhiteGloveConfidence.None;
+
+ return new WhiteGloveClassification
+ {
+ Confidence = confidence,
+ Score = score,
+ ContributingFactors = factors,
+ Reason = $"confidence={confidence} score={score}",
+ ShouldRouteToWhiteGlovePart1 = (confidence == WhiteGloveConfidence.Strong),
+ };
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs
new file mode 100644
index 00000000..80a2c680
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs
@@ -0,0 +1,63 @@
+using System;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
+{
+ ///
+ /// Snapshot of all signals relevant for WhiteGlove (Part 1) classification.
+ /// Populated under _stateLock in the caller and consumed outside the lock by
+ /// — mirrors the CompletionContext
+ /// pattern to preserve the existing thread-safety guarantees.
+ ///
+ /// Source-of-truth is the EnrollmentTracker state. When the tracker uses
+ /// CompletionStateMachine (shadow), use to keep both
+ /// decision paths in sync.
+ ///
+ internal sealed class WhiteGloveSignals
+ {
+ // "Hard" positive signals (registry / Shell-Core)
+ public bool ShellCoreWhiteGloveSuccess { get; set; } // Event 62407 "WhiteGlove_Success" — strongest signal
+ public bool HasSaveWhiteGloveSuccessResult { get; set; } // DeviceSetup registry subcategory — also fires on non-WG
+ public bool IsWhiteGloveStartDetected { get; set; } // Event 509 — soft, fires on hybrid too
+ public bool IsFooUserDetected { get; set; } // foouser@/autopilot@ in JoinInfo — pre-provisioning indicator
+ public bool AgentRestartedAfterEspExit { get; set; } // typical WG Part 1 boundary
+
+ // Hard excluders (drive score negative)
+ public bool AadJoinedWithUser { get; set; } // real user signed in — not WG Part 1 anymore
+ public bool DesktopArrived { get; set; } // explorer.exe running — not WG Part 1 anymore
+ public bool HasAccountSetupActivity { get; set; } // user-driven ESP category active
+
+ // Deployment context
+ public bool IsDeviceOnlyDeployment { get; set; }
+
+ // Optional metadata (populated for observability, not scored)
+ public DateTime? EspFinalExitUtc { get; set; }
+ public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; }
+
+ ///
+ /// Builds a signal snapshot from a . Used by the
+ /// CompletionStateMachine so that state-machine decisions use the exact same
+ /// inputs as the EnrollmentTracker's direct decisions.
+ ///
+ public static WhiteGloveSignals FromContext(CompletionContext ctx)
+ {
+ if (ctx == null) throw new ArgumentNullException(nameof(ctx));
+
+ return new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = ctx.ShellCoreWhiteGloveSuccess,
+ HasSaveWhiteGloveSuccessResult = ctx.HasSaveWhiteGloveSuccessResult,
+ IsWhiteGloveStartDetected = ctx.WhiteGloveStartDetected,
+ IsFooUserDetected = ctx.IsFooUserDetected,
+ AgentRestartedAfterEspExit = ctx.AgentRestartedAfterEspExit,
+
+ AadJoinedWithUser = ctx.AadJoinedWithUser,
+ DesktopArrived = ctx.DesktopArrived,
+ HasAccountSetupActivity = ctx.HasAccountSetupActivity,
+
+ IsDeviceOnlyDeployment = ctx.IsDeviceOnly,
+
+ EspFinalExitUtc = ctx.EspFinalExitUtc,
+ };
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs
new file mode 100644
index 00000000..8f0e322b
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
+{
+ ///
+ /// Handles persistence of EnrollmentTracker state to disk (JSON serialization).
+ /// Enables crash-recovery: after agent restart, completion signals already seen
+ /// (ESP exit, desktop arrival, etc.) are restored so the enrollment can complete.
+ /// Pattern follows ImeTrackerStatePersistence.
+ ///
+ public class EnrollmentStatePersistence
+ {
+ private readonly string _stateDirectory;
+ private readonly string _stateFilePath;
+ private readonly AgentLogger _logger;
+
+ public EnrollmentStatePersistence(string stateDirectory, AgentLogger logger)
+ {
+ _stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
+ _stateFilePath = Path.Combine(_stateDirectory, "enrollment-state.json");
+ _logger = logger;
+ }
+
+ public string StateFilePath => _stateFilePath;
+
+ ///
+ /// Loads persisted state from disk. Returns null if no state file exists or on error.
+ ///
+ public EnrollmentStateData Load()
+ {
+ if (!File.Exists(_stateFilePath))
+ {
+ _logger.Info("EnrollmentStatePersistence: no persisted state found (fresh enrollment)");
+ return null;
+ }
+
+ try
+ {
+ var json = File.ReadAllText(_stateFilePath);
+ var state = JsonConvert.DeserializeObject(json);
+ if (state == null)
+ {
+ _logger.Warning("EnrollmentStatePersistence: persisted state file was empty or invalid");
+ return null;
+ }
+ return state;
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentStatePersistence: failed to load persisted state, starting fresh: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Persists tracker state to disk as JSON (atomic write via temp file).
+ ///
+ public void Save(EnrollmentStateData state)
+ {
+ try
+ {
+ Directory.CreateDirectory(_stateDirectory);
+ var tempPath = _stateFilePath + ".tmp";
+ File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented));
+ File.Copy(tempPath, _stateFilePath, overwrite: true);
+ try { File.Delete(tempPath); } catch { }
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"EnrollmentStatePersistence: failed to save state: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Deletes persisted state file.
+ ///
+ public void Delete()
+ {
+ try
+ {
+ if (File.Exists(_stateFilePath))
+ {
+ File.Delete(_stateFilePath);
+ _logger.Info("EnrollmentStatePersistence: persisted state deleted");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"EnrollmentStatePersistence: failed to delete state file: {ex.Message}");
+ }
+ }
+ }
+
+ // ---- DTO for state serialization ----
+
+ public class EnrollmentStateData
+ {
+ public bool EspEverSeen { get; set; }
+ public bool EspFinalExitSeen { get; set; }
+ public bool DesktopArrived { get; set; }
+ public string LastEspPhase { get; set; }
+ public bool IsWaitingForHello { get; set; }
+ public DateTime? WaitingForHelloStartedUtc { get; set; }
+ public bool EnrollmentCompleteEmitted { get; set; }
+ public string EnrollmentType { get; set; }
+
+ // ESP configuration from registry (FirstSync\SkipUserStatusPage / SkipDeviceStatusPage)
+ public bool? SkipUserStatusPage { get; set; }
+ public bool? SkipDeviceStatusPage { get; set; }
+
+ // Autopilot deployment mode: 0=UserDriven, 1=SelfDeploying, 2=PreProvisioning, null=unknown
+ public int? AutopilotMode { get; set; }
+
+ // True when AAD join status shows a joined device with a real user email
+ // (placeholder accounts like foouser@/autopilot@ are excluded — see AadJoinInfo)
+ public bool AadJoinedWithUser { get; set; }
+
+ // True when a transient AAD provisioning placeholder (foouser@/autopilot@) has been
+ // observed at any point in the session — survives crash/restart so the classifier
+ // keeps its positive WG signal after agent recovery.
+ public bool FooUserDetected { get; set; }
+
+ // True when Autopilot profile indicates Hybrid Azure AD Join (CloudAssignedDomainJoinMethod == 1)
+ public bool IsHybridJoin { get; set; }
+
+ // Signal timestamps for audit trail
+ public DateTime? EspFirstSeenUtc { get; set; }
+ public DateTime? EspFinalExitUtc { get; set; }
+ public DateTime? DesktopArrivedUtc { get; set; }
+ public DateTime? HelloResolvedUtc { get; set; }
+ public DateTime? ImePatternSeenUtc { get; set; }
+ public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; }
+ public List SignalsSeen { get; set; } = new List();
+
+ // ESP provisioning settle wait state (crash recovery)
+ public bool IsWaitingForEspSettle { get; set; }
+ public DateTime? WaitingForEspSettleStartedUtc { get; set; }
+
+ ///
+ /// Explicit completion state machine state (dual-write).
+ /// When present, used to restore the state machine directly.
+ /// When absent (old format), the state is reconstructed from boolean flags.
+ /// Serialized as string (enum name) for forward compatibility.
+ ///
+ public string CompletionState { get; set; }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs
new file mode 100644
index 00000000..1f571f1b
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs
@@ -0,0 +1,1813 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
+{
+ ///
+ /// Partial: Enrollment completion detection, Hello/WhiteGlove/ESP failure handling,
+ /// state persistence, and multi-signal completion logic.
+ ///
+ public partial class EnrollmentTracker
+ {
+ private void OnHelloCompleted(object sender, EventArgs e)
+ {
+ _logger.Info("EnrollmentTracker: Received HelloCompleted event from EspAndHelloTracker");
+
+ lock (_stateLock)
+ {
+ _stateData.HelloResolvedUtc = DateTime.UtcNow;
+ }
+ RecordSignal("hello_resolved");
+
+ // Cancel safety timer — Hello resolved normally
+ _waitingForHelloSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+
+ // Snapshot state under lock for branch decision
+ bool wasWaiting, espFinalExit, desktopArr;
+ lock (_stateLock)
+ {
+ wasWaiting = _isWaitingForHello;
+ espFinalExit = _espFinalExitSeen;
+ desktopArr = _desktopArrived;
+ if (wasWaiting)
+ _isWaitingForHello = false;
+ }
+
+ if (wasWaiting)
+ {
+ _logger.Info("EnrollmentTracker: Hello provisioning completed while waiting (IME path) - marking enrollment as complete now");
+ TryEmitEnrollmentComplete("ime_hello");
+ }
+ else if (espFinalExit)
+ {
+ _logger.Info("EnrollmentTracker: Hello completed + ESP final exit seen — composite completion");
+ TryEmitEnrollmentComplete("esp_hello_composite");
+ }
+ else if (desktopArr)
+ {
+ _logger.Info("EnrollmentTracker: Hello completed + desktop arrived — desktop-hello completion");
+ TryEmitEnrollmentComplete("desktop_hello");
+ }
+ else
+ {
+ _logger.Debug("EnrollmentTracker: HelloCompleted event received but no completion trigger active yet");
+ }
+
+ // Shadow state machine: track hello_completed trigger
+ ShadowProcessTrigger("hello_completed");
+ }
+
+ ///
+ /// Called when DeviceSetup provisioning status shows all subcategories succeeded.
+ /// In Self-Deploying mode, this is the primary completion signal because no user session
+ /// means no Hello, no desktop arrival, and possibly no Shell-Core ESP exit event.
+ /// In non-Self-Deploying mode, this is informational only — normal paths handle completion.
+ ///
+ private void OnDeviceSetupProvisioningComplete(object sender, EventArgs e)
+ {
+ _logger.Info("EnrollmentTracker: DeviceSetup provisioning completed successfully");
+ RecordSignal("device_setup_provisioning_complete");
+ lock (_stateLock)
+ {
+ _stateData.DeviceSetupProvisioningCompleteUtc = DateTime.UtcNow;
+ _stateDirty = true;
+ }
+
+ bool isDeviceOnly;
+ int? autopilotModeSnap;
+ bool? skipUserSnap;
+ lock (_stateLock)
+ {
+ isDeviceOnly = IsDeviceOnlyDeployment;
+ autopilotModeSnap = _autopilotMode;
+ skipUserSnap = _skipUserStatusPage;
+ }
+
+ if (!isDeviceOnly)
+ {
+ if (!_deviceInfoCollected)
+ {
+ _logger.Info("EnrollmentTracker: DeviceSetup provisioning complete but device info not yet collected — " +
+ "deferring classification decision until CollectDeviceInfo completes");
+ lock (_stateLock) { _pendingCompletionSource = "device_setup_provisioning_complete"; }
+ EmitTraceEvent("device_setup_complete_deferred",
+ "DeviceSetup provisioning complete but device classification pending — will re-evaluate after CollectDeviceInfo",
+ new Dictionary { { "autopilotMode", autopilotModeSnap }, { "skipUserStatusPage", skipUserSnap } });
+ return;
+ }
+
+ _logger.Info("EnrollmentTracker: DeviceSetup provisioning complete but not device-only deployment — " +
+ "using normal completion paths (ESP exit + Hello)");
+ EmitTraceEvent("device_setup_complete_non_device_only",
+ "DeviceSetup provisioning complete in non-device-only mode — no action, normal paths apply",
+ new Dictionary { { "autopilotMode", autopilotModeSnap }, { "skipUserStatusPage", skipUserSnap } });
+ return;
+ }
+
+ // WhiteGlove guard — canonical decision via WhiteGloveClassifier.
+ // See plan: all three completion paths (this one, TryEmitEnrollmentComplete,
+ // and CompletionStateMachine.HandleDeviceSetupProvisioningComplete) share the
+ // same classifier output. HasSaveWhiteGloveSuccessResult alone is no longer
+ // sufficient — it also fires on non-WG devices (observed in production).
+ var wgSignals = SnapshotWhiteGloveSignals();
+ var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
+ EmitWhiteGloveClassification(wgClassification, "DeviceSetupProvisioningComplete", wgSignals);
+
+ if (wgClassification.ShouldRouteToWhiteGlovePart1)
+ {
+ _logger.Info($"EnrollmentTracker: Device-only deployment with WhiteGlove (classifier: {wgClassification.Reason}) — " +
+ "routing to WhiteGlove completion instead of self_deploying_provisioning_complete");
+
+ RecordSignal("whiteglove_guard_activated");
+
+ EmitTraceEvent("whiteglove_guard_activated",
+ "DeviceSetup provisioning complete with WG classifier Strong — routing to WhiteGlove path",
+ new Dictionary
+ {
+ { "wgScore", wgClassification.Score },
+ { "wgConfidence", wgClassification.Confidence.ToString() },
+ { "wgFactors", wgClassification.ContributingFactors },
+ { "whiteGloveStartDetected", wgSignals.IsWhiteGloveStartDetected },
+ { "saveWhiteGloveSuccessResult", wgSignals.HasSaveWhiteGloveSuccessResult },
+ { "fooUserDetected", wgSignals.IsFooUserDetected },
+ { "autopilotMode", _autopilotMode },
+ { "skipUserStatusPage", _skipUserStatusPage }
+ });
+
+ try { CollectDeviceInfoAtFinalizingSetup("whiteglove_provisioning_complete"); }
+ catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (whiteglove): {ex.Message}"); }
+
+ OnWhiteGloveCompleted(this, EventArgs.Empty);
+ ShadowProcessTrigger("whiteglove_complete");
+ return;
+ }
+
+ // Device-only deployment: this is our primary completion signal.
+ // No user session → no Hello, no desktop arrival, possibly no Shell-Core ESP exit.
+ _logger.Info($"EnrollmentTracker: Device-only deployment (autopilotMode={_autopilotMode}, skipUserStatusPage={_skipUserStatusPage}) + DeviceSetup provisioning complete — " +
+ "transitioning to FinalizingSetup and attempting completion");
+
+ // Mark ESP as seen and final exit (provisioning success implies ESP device phase completed)
+ bool needsSignal = false;
+ lock (_stateLock)
+ {
+ if (!_espEverSeen)
+ {
+ _espEverSeen = true;
+ _stateData.EspEverSeen = true;
+ }
+ if (!_espFinalExitSeen)
+ {
+ _espFinalExitSeen = true;
+ _stateData.EspFinalExitSeen = true;
+ _stateData.EspFinalExitUtc = DateTime.UtcNow;
+ needsSignal = true;
+ }
+ }
+ if (needsSignal)
+ {
+ _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate
+ RecordSignal("self_deploying_esp_final_exit");
+ }
+
+ // Emit FinalizingSetup phase event
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.FinalizingSetup,
+ Message = "ESP phase: FinalizingSetup (Self-Deploying — DeviceSetup provisioning complete, no user session expected)",
+ Data = new Dictionary
+ {
+ { "espPhase", "FinalizingSetup" },
+ { "autoDetected", true },
+ { "triggeredBy", "self_deploying_provisioning_complete" },
+ { "autopilotMode", _autopilotMode },
+ { "skipUserStatusPage", _skipUserStatusPage }
+ },
+ ImmediateUpload = true
+ });
+
+ try { CollectDeviceInfoAtFinalizingSetup("self_deploying_provisioning_complete"); }
+ catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (self_deploying): {ex.Message}"); }
+
+ // Attempt completion — Hello guard is bypassed for device-only deployments (IsDeviceOnlyDeployment)
+ TryEmitEnrollmentComplete("self_deploying_provisioning_complete");
+
+ // Shadow state machine: track device_setup_provisioning_complete trigger
+ ShadowProcessTrigger("device_setup_provisioning_complete");
+ }
+
+ ///
+ /// Called when WhiteGlove (Pre-Provisioning) completes successfully (event-handler signature).
+ /// Subscribed to EspAndHelloTracker.WhiteGloveCompleted. Delegates to CompleteWhiteGlove
+ /// which contains the actual emission pipeline.
+ ///
+ private void OnWhiteGloveCompleted(object sender, EventArgs e)
+ {
+ // Shell-Core Event 62407 "WhiteGlove_Success" has fired — record it so the
+ // WhiteGloveClassifier can see this hard-positive signal (weight +80) on any
+ // subsequent classification call in the same session.
+ lock (_stateLock) { _shellCoreWhiteGloveFired = true; }
+
+ var wgSource = sender?.GetType().Name ?? "unknown";
+ CompleteWhiteGlove(wgSource, decisionAlreadyEmitted: false);
+ }
+
+ ///
+ /// Core WhiteGlove completion pipeline: emits the whiteglove_complete terminal event,
+ /// the decision_process_completion audit record (unless the caller already emitted one),
+ /// stops the summary timer, and updates the shadow state machine. MonitoringService handles
+ /// the actual agent shutdown upon seeing whiteglove_complete.
+ /// Idempotent: fire-once per session instance. A second invocation (e.g. Shell-Core
+ /// watcher fires after the signal-correlated detector already matched) is a no-op.
+ ///
+ /// Decision source identifier (caller type or descriptor).
+ ///
+ /// Set to true when the caller already emitted a decision_process_completion for this
+ /// terminal (e.g. the WG-redirect guard inside TryEmitEnrollmentComplete). Prevents the
+ /// double-decision pattern that loses the upstream decisionChain under a second record.
+ ///
+ private void CompleteWhiteGlove(string source, bool decisionAlreadyEmitted)
+ {
+ lock (_stateLock)
+ {
+ if (_signalCorrelatedWhiteGloveTriggered)
+ {
+ _logger.Debug("EnrollmentTracker: whiteglove_complete already emitted, skipping duplicate");
+ return;
+ }
+ _signalCorrelatedWhiteGloveTriggered = true;
+ }
+
+ _logger.Info($"EnrollmentTracker: WhiteGlove pre-provisioning completed (source: {source})");
+
+ // Decision audit: only emit if the caller hasn't already recorded the decision that led here.
+ // Keeps the invariant "one terminal event = one decision_process_completion".
+ if (!decisionAlreadyEmitted)
+ {
+ EmitDecisionProcess("whiteglove_complete", source, "pending_part1", new List
+ {
+ $"trigger={source}",
+ (_espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false) ? "registry_confirmed" : "registry_not_confirmed",
+ (_espAndHelloTracker?.IsWhiteGloveStartDetected ?? false) ? "event509_seen" : "event509_not_seen",
+ _aadJoinedWithUser ? "user_joined" : "no_user_joined",
+ _fooUserDetected ? "foouser_detected" : "no_foouser"
+ });
+ }
+
+ // Stop summary timer — no more app tracking needed
+ _summaryTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
+ _summaryTimerActive = false;
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "whiteglove_complete",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Complete,
+ Message = "WhiteGlove (Pre-Provisioning) completed \u2014 device entering pending state",
+ ImmediateUpload = true
+ });
+
+ // WICHTIG: Kein WriteEnrollmentCompleteMarker()!
+ // Das Marker-File wuerde verhindern, dass der Agent beim naechsten Start wieder laeuft.
+ // WICHTIG: Kein _imeLogTracker?.DeleteState()!
+ // Der State wird fuer Part 2 benoetigt falls der Tracker weiterlaufen muss.
+
+ // Shadow state machine: track whiteglove_complete trigger
+ ShadowProcessTrigger("whiteglove_complete");
+ }
+
+ ///
+ /// Signal-correlated WhiteGlove detection (Ebene 2.6). Called periodically from the
+ /// summary timer (every 30 s). Evaluates the sealing pattern and, on a stable match
+ /// lasting ≥10 min, triggers the existing OnWhiteGloveCompleted pipeline — which emits
+ /// whiteglove_complete, persists the Part 1 marker, and starts graceful shutdown.
+ ///
+ /// Pattern (ALL required):
+ /// - All device-targeted apps installed, no errors (via PackageStates)
+ /// - DeviceSetupCategory.Status is succeeded
+ /// - A system reboot has been observed since session start
+ /// - AAD join status shows Not Joined
+ /// - AccountSetup category has no activity (no resolved subcategories)
+ /// - No desktop arrival event has been seen
+ /// - No Hello signals (Hello not completed, wizard not started)
+ ///
+ public void CheckSignalCorrelatedWhiteGlove()
+ {
+ // Fast-exit: guards that don't require I/O.
+ lock (_stateLock)
+ {
+ if (_signalCorrelatedWhiteGloveTriggered)
+ return;
+ if (_desktopArrived || _enrollmentCompleteEmitted)
+ return;
+ // Reboot observation is required — without it we are still in Part 1 pre-reboot.
+ if (!_systemRebootObserved)
+ return;
+ // AAD Not Joined is required — if the user already signed in, this is not sealing.
+ if (_aadJoinedWithUser)
+ return;
+ }
+
+ // EspAndHelloTracker provides the authoritative provisioning signals.
+ if (_espAndHelloTracker == null)
+ return;
+
+ bool deviceSetupOk = _espAndHelloTracker.DeviceSetupCategorySucceeded;
+ bool hasAccountSetupActivity = _espAndHelloTracker.HasAccountSetupActivity;
+ bool helloInFlight = _espAndHelloTracker.IsHelloCompleted; // true once Hello resolved (either success or skip)
+
+ // ImeLogTracker provides the app-tracking state.
+ var packageStates = _imeLogTracker?.PackageStates;
+ bool appsDone = packageStates != null
+ && packageStates.CountAll > 0
+ && packageStates.CountCompleted >= packageStates.CountAll
+ && packageStates.ErrorCount == 0;
+
+ bool allConditionsMet = deviceSetupOk && appsDone && !hasAccountSetupActivity && !helloInFlight;
+
+ DateTime? triggerAt = null;
+ bool shouldTrigger = false;
+
+ lock (_stateLock)
+ {
+ if (!allConditionsMet)
+ {
+ // Stability window broken — reset.
+ if (_signalCorrelatedWhiteGloveStableSince != null)
+ {
+ _logger.Debug("EnrollmentTracker: signal-correlated WhiteGlove conditions no longer stable, resetting stability window");
+ _signalCorrelatedWhiteGloveStableSince = null;
+ }
+ return;
+ }
+
+ if (_signalCorrelatedWhiteGloveStableSince == null)
+ {
+ _signalCorrelatedWhiteGloveStableSince = DateTime.UtcNow;
+ _logger.Info($"EnrollmentTracker: signal-correlated WhiteGlove conditions met — starting {SignalCorrelatedWhiteGloveStabilityMinutes}min stability window");
+ return;
+ }
+
+ var stableDuration = DateTime.UtcNow - _signalCorrelatedWhiteGloveStableSince.Value;
+ if (stableDuration.TotalMinutes >= SignalCorrelatedWhiteGloveStabilityMinutes)
+ {
+ shouldTrigger = true;
+ triggerAt = _signalCorrelatedWhiteGloveStableSince.Value;
+ }
+ }
+
+ if (shouldTrigger)
+ {
+ _logger.Info($"EnrollmentTracker: signal-correlated WhiteGlove stable since {triggerAt:HH:mm:ss} UTC — invoking OnWhiteGloveCompleted");
+ try
+ {
+ OnWhiteGloveCompleted(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Signal-correlated WhiteGlove invocation failed", ex);
+ }
+ }
+ }
+
+ ///
+ /// Called when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, WhiteGlove_Failed, etc.).
+ /// Terminal failures emit enrollment_failed immediately.
+ /// Recoverable failures (e.g. ESPProgress_Timeout) get a grace period before failure.
+ ///
+ ///
+ /// Public entry point for secondary ESP-failure detectors (StallProbeCollector) to report
+ /// a terminal failure finding. Reuses the same OnEspFailureDetected pathway as the
+ /// Shell-Core watcher so the downstream logic (grace period, enrollment_failed emission)
+ /// stays unified.
+ ///
+ public void ReportEspFailureFromExternalSource(string failureType)
+ {
+ OnEspFailureDetected(this, failureType);
+ }
+
+ private void OnEspFailureDetected(object sender, string failureType)
+ {
+ _logger.Info($"EnrollmentTracker: ESP failure detected: {failureType}");
+
+ if (RecoverableEspFailureTypes.Contains(failureType))
+ {
+ // Recoverable failure — start grace period timer
+ _logger.Info($"EnrollmentTracker: '{failureType}' is recoverable — starting {EspFailureGracePeriodSeconds}s grace period");
+
+ string previousPending;
+ lock (_stateLock)
+ {
+ previousPending = _pendingEspFailureType;
+ _pendingEspFailureType = failureType;
+ }
+
+ // Cancel existing timer if any (e.g. second timeout event)
+ if (_espFailureTimer != null)
+ _logger.Debug($"EnrollmentTracker: resetting existing grace period timer (previous pending: '{previousPending}')");
+ _espFailureTimer?.Dispose();
+ _espFailureTimer = new Timer(
+ OnEspFailureGracePeriodExpired,
+ null,
+ TimeSpan.FromSeconds(EspFailureGracePeriodSeconds),
+ TimeSpan.FromMilliseconds(-1));
+ }
+ else
+ {
+ // Terminal failure — emit enrollment_failed immediately
+ _logger.Info($"EnrollmentTracker: '{failureType}' is terminal — emitting enrollment_failed immediately");
+ EmitEnrollmentFailed(failureType, "esp_failure");
+
+ // Shadow state machine: track terminal ESP failure
+ var ctx = SnapshotCompletionContext();
+ ctx.Source = failureType;
+ ShadowProcessTrigger("esp_failure_terminal", ctx);
+ }
+ }
+
+ ///
+ /// Called when the ESP failure grace period expires without recovery.
+ /// Emits enrollment_failed.
+ ///
+ private void OnEspFailureGracePeriodExpired(object state)
+ {
+ string failureType;
+ lock (_stateLock)
+ {
+ failureType = _pendingEspFailureType ?? "unknown";
+ _pendingEspFailureType = null;
+ }
+ _logger.Warning($"EnrollmentTracker: ESP failure grace period ({EspFailureGracePeriodSeconds}s) expired for '{failureType}' — emitting enrollment_failed");
+
+ EmitEnrollmentFailed(failureType, "esp_failure_grace_expired");
+
+ // Shadow state machine: track grace period expiry
+ var ctx = SnapshotCompletionContext();
+ ctx.Source = failureType;
+ ShadowProcessTrigger("esp_failure_grace_expired", ctx);
+ }
+
+ ///
+ /// Cancels any pending ESP failure grace period timer (called when recovery is detected).
+ ///
+ private void CancelPendingEspFailure()
+ {
+ if (_espFailureTimer != null)
+ {
+ string pending;
+ lock (_stateLock) { pending = _pendingEspFailureType; }
+ _logger.Info($"EnrollmentTracker: ESP recovery detected — cancelling pending failure for '{pending}'");
+ _espFailureTimer.Dispose();
+ _espFailureTimer = null;
+ lock (_stateLock) { _pendingEspFailureType = null; }
+ }
+ }
+
+ ///
+ /// Safety-net callback: fires 7 minutes after waiting_for_hello was set.
+ /// If the normal Hello timeout chain (30s + 300s) failed for any reason,
+ /// this forces enrollment_complete instead of waiting for the 6h max lifetime timer.
+ ///
+ private void OnWaitingForHelloSafetyTimeout(object state)
+ {
+ bool isWaiting, alreadyEmitted;
+ lock (_stateLock)
+ {
+ isWaiting = _isWaitingForHello;
+ alreadyEmitted = _enrollmentCompleteEmitted;
+ if (isWaiting && !alreadyEmitted)
+ _isWaitingForHello = false;
+ }
+
+ if (!isWaiting || alreadyEmitted)
+ {
+ _logger.Debug($"EnrollmentTracker: Hello safety timeout fired but not applicable (waiting={isWaiting}, emitted={alreadyEmitted})");
+ return;
+ }
+
+ _logger.Warning($"EnrollmentTracker: waiting_for_hello safety timeout ({WaitingForHelloSafetyTimeoutSeconds}s) expired — forcing completion");
+
+ // Force-resolve Hello so TryEmitEnrollmentComplete's hello check passes
+ if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
+ {
+ _espAndHelloTracker.ForceMarkHelloCompleted("safety_timeout");
+ }
+
+ TryEmitEnrollmentComplete("ime_hello_safety_timeout");
+
+ // Shadow state machine: track hello safety timeout
+ ShadowProcessTrigger("hello_safety_timeout");
+ }
+
+ ///
+ /// Timer callback: fires after EspSettleTimeoutSeconds (30s) when waiting for ESP provisioning
+ /// categories to resolve. Completes enrollment regardless of whether categories settled.
+ /// The snapshot captured in TryEmitEnrollmentComplete will reflect the final state.
+ ///
+ private void OnEspSettleTimerExpired(object state)
+ {
+ bool isWaiting, alreadyEmitted;
+ lock (_stateLock)
+ {
+ isWaiting = _isWaitingForEspSettle;
+ alreadyEmitted = _enrollmentCompleteEmitted;
+ if (isWaiting && !alreadyEmitted)
+ _isWaitingForEspSettle = false;
+ }
+
+ if (!isWaiting || alreadyEmitted)
+ {
+ _logger.Debug($"EnrollmentTracker: ESP settle timer expired but not applicable " +
+ $"(waiting={isWaiting}, emitted={alreadyEmitted})");
+ return;
+ }
+
+ _logger.Info($"EnrollmentTracker: ESP settle timer expired ({EspSettleTimeoutSeconds}s) " +
+ "— proceeding to completion with current ESP provisioning state");
+ RecordSignal("esp_provisioning_settled");
+ TryEmitEnrollmentComplete("ime_pattern");
+
+ // Shadow state machine: track ESP settle timeout
+ ShadowProcessTrigger("esp_settle_timeout");
+ }
+
+ ///
+ /// Safety-net callback for device-only ESP (SkipUserStatusPage=true) completion.
+ /// If the normal Hello timer chain (30s wait + 300s completion) failed for any reason
+ /// (exception, timer disposal, race condition), this forces enrollment_complete instead
+ /// of waiting for the 6h max lifetime timer.
+ ///
+ private void OnDeviceOnlyCompletionSafetyTimeout(object state)
+ {
+ bool alreadyEmitted;
+ lock (_stateLock) { alreadyEmitted = _enrollmentCompleteEmitted; }
+
+ if (alreadyEmitted)
+ {
+ _logger.Debug("EnrollmentTracker: device-only completion safety timeout fired but enrollment already completed — ignoring");
+ return;
+ }
+
+ _logger.Warning($"EnrollmentTracker: device-only ESP completion safety timeout ({DeviceOnlyCompletionSafetyTimeoutSeconds}s) expired — forcing completion");
+
+ // Force-resolve Hello if still pending so the Hello guard in TryEmitEnrollmentComplete passes
+ if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
+ {
+ _espAndHelloTracker.ForceMarkHelloCompleted("device_only_safety_timeout");
+ }
+
+ TryEmitEnrollmentComplete("device_only_esp_safety_timeout");
+
+ bool emittedAfterAttempt;
+ lock (_stateLock) { emittedAfterAttempt = _enrollmentCompleteEmitted; }
+ if (!emittedAfterAttempt)
+ {
+ _logger.Error("EnrollmentTracker: device-only ESP safety timeout — TryEmitEnrollmentComplete still did not fire (unexpected guard block)");
+ }
+
+ // Shadow state machine: track device-only safety timeout
+ ShadowProcessTrigger("device_only_safety_timeout");
+ }
+
+ ///
+ /// Emits an enrollment_failed event. The MonitoringService handles shutdown identically to enrollment_complete.
+ ///
+ private void EmitEnrollmentFailed(string failureType, string failureSource)
+ {
+ // Emit decision process before the terminal event
+ EmitDecisionProcess("enrollment_failed", failureSource, failureType, new List
+ {
+ $"failure_type={failureType}",
+ $"failure_source={failureSource}",
+ _espEverSeen ? $"esp_seen(finalExit={_espFinalExitSeen})" : "esp_not_seen",
+ _desktopArrived ? "desktop_arrived" : "desktop_not_arrived"
+ });
+
+ // Stop timers
+ _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _summaryTimerActive = false;
+ _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debugStateTimerActive = false;
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "enrollment_failed",
+ Severity = EventSeverity.Error,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Complete,
+ Message = $"Autopilot enrollment failed: {failureType}",
+ Data = new Dictionary
+ {
+ { "failureType", failureType },
+ { "failureSource", failureSource },
+ { "signalsSeen", SnapshotSignalsSeen() },
+ { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }
+ },
+ ImmediateUpload = true
+ });
+
+ // Write final status dump before cleanup
+ WriteFinalStatus("failed", failureType);
+
+ // Clean up persisted tracker state
+ _imeLogTracker?.DeleteState();
+ _statePersistence.Delete();
+ }
+
+ ///
+ /// Called when ESP exit or Hello wizard start is detected (via HelloDetector Shell-Core events)
+ /// Triggers transition to FinalizingSetup phase
+ ///
+ private void OnFinalizingSetupPhaseTriggered(object sender, string reason)
+ {
+ _logger.Info($"EnrollmentTracker: FinalizingSetup phase trigger received - reason: {reason}");
+
+ // If ESP exiting, check which phase we're in
+ if (reason == "esp_exiting")
+ {
+ string lastPhase;
+ bool desktopArr;
+ bool? skipUser;
+ lock (_stateLock)
+ {
+ _espEverSeen = true;
+ lastPhase = _lastEspPhase;
+ desktopArr = _desktopArrived;
+ skipUser = _skipUserStatusPage;
+ }
+
+ // Defense-in-depth: if lastEspPhase is null (state lost after reboot) but the
+ // ESP provisioning registry shows AccountSetup activity, treat as AccountSetup.
+ // This catches the edge case where neither persistence nor backfill recovered the phase.
+ bool accountSetupFromProvisioning = lastPhase == null
+ && (_espAndHelloTracker?.HasAccountSetupActivity == true);
+
+ if (string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)
+ || desktopArr
+ || accountSetupFromProvisioning)
+ {
+ // Final ESP exit: AccountSetup phase detected, desktop arrived (backup), or provisioning registry confirms AccountSetup
+ var phaseInfo = string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)
+ ? "AccountSetup"
+ : accountSetupFromProvisioning
+ ? "AccountSetup (provisioning registry fallback)"
+ : $"{lastPhase ?? "unknown"} (desktop arrival backup)";
+ _logger.Info($"EnrollmentTracker: ESP final exit from {phaseInfo} — marking _espFinalExitSeen, starting Hello wait timer");
+
+ lock (_stateLock)
+ {
+ _espFinalExitSeen = true;
+ _stateData.EspFinalExitSeen = true;
+ _stateData.EspFinalExitUtc = DateTime.UtcNow;
+ }
+ _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate
+ RecordSignal("esp_final_exit");
+
+ // Emit phase change event to FinalizingSetup
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.FinalizingSetup,
+ Message = $"ESP phase: FinalizingSetup ({phaseInfo} completed, waiting for final steps)",
+ Data = new Dictionary
+ {
+ { "espPhase", "FinalizingSetup" },
+ { "autoDetected", true },
+ { "triggeredBy", reason },
+ { "previousPhase", lastPhase ?? "unknown" },
+ { "desktopArrivedBackup", desktopArr && !string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) }
+ },
+ ImmediateUpload = true
+ });
+
+ try { CollectDeviceInfoAtFinalizingSetup(reason); }
+ catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (esp_final_exit): {ex.Message}"); }
+
+ // Start Hello wait timer (waits for Hello wizard to start or timeout)
+ _espAndHelloTracker?.StartHelloWaitTimer();
+
+ // If Hello was already resolved (e.g., via EventLog backfill or Event 300/301
+ // during AccountSetup), the composite signal can fire immediately.
+ TryEmitEnrollmentComplete("esp_hello_composite");
+ }
+ else if (skipUser == true)
+ {
+ // WhiteGlove guard — canonical decision via WhiteGloveClassifier, identical to
+ // the other three WG decision sites. HasSaveWhiteGloveSuccessResult alone is
+ // not sufficient: after WhiteGlove Part 1 the registry flag remains set and a
+ // subsequent user-enrollment ESP exit (Part 2) would falsely re-route to WG.
+ // The classifier's AadJoinedWithUser hard-excluder blocks that path correctly.
+ var wgSignals = SnapshotWhiteGloveSignals();
+ var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
+ EmitWhiteGloveClassification(wgClassification, "EspExiting_SkipUserTrue", wgSignals);
+
+ if (wgClassification.ShouldRouteToWhiteGlovePart1)
+ {
+ _logger.Info($"EnrollmentTracker: ESP exiting with SkipUserStatusPage=true and WG classifier Strong " +
+ $"({wgClassification.Reason}) — routing to WhiteGlove completion instead of device_only_esp_registry");
+
+ RecordSignal("whiteglove_guard_activated");
+ EmitTraceEvent("whiteglove_guard_esp_exiting",
+ "ESP exiting with WG classifier Strong — routing to WhiteGlove path",
+ new Dictionary
+ {
+ { "wgScore", wgClassification.Score },
+ { "wgConfidence", wgClassification.Confidence.ToString() },
+ { "wgFactors", wgClassification.ContributingFactors },
+ { "whiteGloveStartDetected", wgSignals.IsWhiteGloveStartDetected },
+ { "saveWhiteGloveSuccessResult", wgSignals.HasSaveWhiteGloveSuccessResult },
+ { "fooUserDetected", wgSignals.IsFooUserDetected },
+ { "aadJoinedWithUser", wgSignals.AadJoinedWithUser },
+ { "previousPhase", lastPhase ?? "unknown" }
+ });
+
+ try { CollectDeviceInfoAtFinalizingSetup("whiteglove_esp_exiting"); }
+ catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (whiteglove_esp_exiting): {ex.Message}"); }
+
+ OnWhiteGloveCompleted(this, EventArgs.Empty);
+ ShadowProcessTrigger("whiteglove_complete");
+ return;
+ }
+
+ // Registry definitively says no AccountSetup expected → immediate device-only classification
+ _logger.Info($"EnrollmentTracker: ESP phase exiting from '{lastPhase ?? "unknown"}' — SkipUserStatusPage=true, classified as device-only ESP (registry)");
+
+ lock (_stateLock)
+ {
+ _espFinalExitSeen = true;
+ _stateData.EspFinalExitSeen = true;
+ _stateData.EspFinalExitUtc = DateTime.UtcNow;
+ }
+ _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate
+ RecordSignal("device_only_esp_registry");
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.FinalizingSetup,
+ Message = "ESP phase: FinalizingSetup (device-only ESP — SkipUserStatusPage confirmed via registry)",
+ Data = new Dictionary
+ {
+ { "espPhase", "FinalizingSetup" },
+ { "autoDetected", true },
+ { "triggeredBy", "device_only_esp_registry" },
+ { "previousPhase", lastPhase ?? "unknown" },
+ { "skipUserStatusPage", true }
+ },
+ ImmediateUpload = true
+ });
+
+ try { CollectDeviceInfoAtFinalizingSetup("device_only_esp_registry"); }
+ catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (device_only_esp_registry): {ex.Message}"); }
+
+ _espAndHelloTracker?.StartHelloWaitTimer();
+ TryEmitEnrollmentComplete("device_only_esp_registry");
+
+ // Safety net: if enrollment_complete didn't fire immediately (Hello pending),
+ // start a safety timer to force completion. Without this, a failed Hello timer
+ // chain would leave the session hanging until the 6h max lifetime timer.
+ bool emittedYet;
+ lock (_stateLock) { emittedYet = _enrollmentCompleteEmitted; }
+ if (!emittedYet)
+ {
+ _logger.Info($"EnrollmentTracker: device-only ESP completion pending — starting safety timer ({DeviceOnlyCompletionSafetyTimeoutSeconds}s)");
+ _deviceOnlyCompletionSafetyTimer?.Dispose();
+ _deviceOnlyCompletionSafetyTimer = new Timer(
+ OnDeviceOnlyCompletionSafetyTimeout,
+ null,
+ TimeSpan.FromSeconds(DeviceOnlyCompletionSafetyTimeoutSeconds),
+ TimeSpan.FromMilliseconds(-1));
+ }
+ }
+ else
+ {
+ // Registry keys unknown or SkipUserStatusPage=false → fallback to timer-based detection
+ var fallbackReason = skipUser == null ? "registry keys not found" : "SkipUserStatusPage=false";
+ _logger.Info($"EnrollmentTracker: ESP phase exiting from '{lastPhase ?? "unknown"}' — {fallbackReason}, starting device-only ESP detection timer ({DeviceOnlyEspTimerMinutes}min)");
+
+ // Start device-only ESP detection timer
+ _deviceOnlyEspTimer?.Dispose();
+ _deviceOnlyEspTimer = new Timer(
+ OnDeviceOnlyEspTimerExpired,
+ null,
+ TimeSpan.FromMinutes(DeviceOnlyEspTimerMinutes),
+ TimeSpan.FromMilliseconds(-1));
+ }
+ }
+ else if (reason == "hello_wizard_started")
+ {
+ // Hello wizard started - transition to FinalizingSetup regardless of previous phase
+ _logger.Info("EnrollmentTracker: Hello wizard started - transitioning to FinalizingSetup phase");
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.FinalizingSetup,
+ Message = "ESP phase: FinalizingSetup (Hello wizard started)",
+ Data = new Dictionary
+ {
+ { "espPhase", "FinalizingSetup" },
+ { "autoDetected", true },
+ { "triggeredBy", reason }
+ },
+ ImmediateUpload = true
+ });
+
+ try { CollectDeviceInfoAtFinalizingSetup(reason); }
+ catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (hello_wizard_started): {ex.Message}"); }
+ }
+
+ // Shadow state machine: track esp_exiting trigger (covers all branches above)
+ if (reason == "esp_exiting")
+ ShadowProcessTrigger("esp_exiting");
+ }
+
+ // ===== State Persistence =====
+
+ private void LoadState()
+ {
+ var loaded = _statePersistence.Load();
+ if (loaded == null)
+ {
+ _logger.Debug("EnrollmentTracker: no persisted state found (fresh enrollment)");
+ return;
+ }
+
+ lock (_stateLock)
+ {
+ _espEverSeen = loaded.EspEverSeen;
+ _espFinalExitSeen = loaded.EspFinalExitSeen;
+ _desktopArrived = loaded.DesktopArrived;
+ _lastEspPhase = loaded.LastEspPhase;
+ _isWaitingForHello = loaded.IsWaitingForHello;
+ _isWaitingForEspSettle = loaded.IsWaitingForEspSettle;
+ _enrollmentCompleteEmitted = loaded.EnrollmentCompleteEmitted;
+ _enrollmentType = loaded.EnrollmentType ?? _enrollmentType;
+ _flowHandler = Flows.EnrollmentFlowFactory.FromWireFormat(_enrollmentType);
+ _skipUserStatusPage = loaded.SkipUserStatusPage;
+ _skipDeviceStatusPage = loaded.SkipDeviceStatusPage;
+ _autopilotMode = loaded.AutopilotMode;
+ _aadJoinedWithUser = loaded.AadJoinedWithUser;
+ _fooUserDetected = loaded.FooUserDetected;
+ // Restore hybrid join flag from persisted state; re-detect as fallback
+ _isHybridJoin = loaded.IsHybridJoin || DetectHybridJoinStatic();
+ _stateData = loaded;
+ }
+
+ // Shadow state machine: restore state from persistence
+ try
+ {
+ EnrollmentCompletionState smState;
+ if (!string.IsNullOrEmpty(loaded.CompletionState)
+ && Enum.TryParse(loaded.CompletionState, out smState))
+ {
+ // New format: explicit state stored
+ _logger.Verbose($"EnrollmentTracker [SHADOW]: restoring explicit state '{loaded.CompletionState}' from persistence");
+ }
+ else
+ {
+ // Old format: reconstruct from boolean flags
+ bool isDeviceOnly = CompletionGuards.IsDeviceOnlyDeployment(
+ loaded.AutopilotMode, loaded.SkipUserStatusPage, loaded.AadJoinedWithUser);
+ smState = CompletionStateMachine.ReconstructStateFromFlags(
+ loaded.EnrollmentCompleteEmitted, loaded.IsWaitingForHello,
+ loaded.IsWaitingForEspSettle, loaded.EspFinalExitSeen,
+ loaded.DesktopArrived, loaded.EspEverSeen, isDeviceOnly);
+ _logger.Verbose($"EnrollmentTracker [SHADOW]: reconstructed state '{smState}' from boolean flags");
+ }
+ _completionSm.RestoreState(smState,
+ loaded.EspEverSeen, loaded.EspFinalExitSeen, loaded.DesktopArrived,
+ loaded.IsWaitingForHello, loaded.IsWaitingForEspSettle, deferredSource: null);
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"EnrollmentTracker [SHADOW ERROR]: state restoration failed: {ex.Message}");
+ }
+
+ _logger.Info($"EnrollmentTracker: state restored — espEverSeen={_espEverSeen}, espFinalExitSeen={_espFinalExitSeen}, desktopArrived={_desktopArrived}, lastEspPhase={_lastEspPhase}, enrollmentCompleteEmitted={_enrollmentCompleteEmitted}");
+
+ // Restart Hello wait timer if needed after crash recovery
+ if ((_desktopArrived || _espFinalExitSeen) && _espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
+ {
+ _logger.Info("EnrollmentTracker: restarting Hello wait timer after state recovery");
+ _espAndHelloTracker.StartHelloWaitTimer();
+ }
+
+ // Restart safety timer if we were waiting for Hello — use remaining time, not full duration
+ if (_isWaitingForHello && !_enrollmentCompleteEmitted)
+ {
+ var remaining = WaitingForHelloSafetyTimeoutSeconds;
+ if (_stateData.WaitingForHelloStartedUtc.HasValue)
+ {
+ var elapsed = (DateTime.UtcNow - _stateData.WaitingForHelloStartedUtc.Value).TotalSeconds;
+ remaining = Math.Max(0, WaitingForHelloSafetyTimeoutSeconds - (int)elapsed);
+ }
+
+ if (remaining <= 0)
+ {
+ _logger.Warning("EnrollmentTracker: waiting_for_hello safety timeout already expired during crash recovery — forcing completion now");
+ _isWaitingForHello = false;
+ _waitingForHelloSafetyTimer?.Dispose();
+ _waitingForHelloSafetyTimer = new Timer(
+ OnWaitingForHelloSafetyTimeout,
+ null,
+ TimeSpan.Zero,
+ TimeSpan.FromMilliseconds(-1));
+ }
+ else
+ {
+ _logger.Info($"EnrollmentTracker: restarting waiting_for_hello safety timer after state recovery ({remaining}s remaining)");
+ _waitingForHelloSafetyTimer?.Dispose();
+ _waitingForHelloSafetyTimer = new Timer(
+ OnWaitingForHelloSafetyTimeout,
+ null,
+ TimeSpan.FromSeconds(remaining),
+ TimeSpan.FromMilliseconds(-1));
+ }
+ }
+
+ // Restart ESP settle timer if we were waiting — use remaining time, not full duration
+ if (_isWaitingForEspSettle && !_enrollmentCompleteEmitted)
+ {
+ var remaining = EspSettleTimeoutSeconds;
+ if (_stateData.WaitingForEspSettleStartedUtc.HasValue)
+ {
+ var elapsed = (DateTime.UtcNow - _stateData.WaitingForEspSettleStartedUtc.Value).TotalSeconds;
+ remaining = Math.Max(0, EspSettleTimeoutSeconds - (int)elapsed);
+ }
+
+ if (remaining <= 0)
+ {
+ _logger.Warning("EnrollmentTracker: ESP settle timeout already expired during crash recovery — completing now");
+ _isWaitingForEspSettle = false;
+ _waitingForEspSettleTimer?.Dispose();
+ _waitingForEspSettleTimer = new Timer(
+ OnEspSettleTimerExpired,
+ null,
+ TimeSpan.Zero,
+ TimeSpan.FromMilliseconds(-1));
+ }
+ else
+ {
+ _logger.Info($"EnrollmentTracker: restarting ESP settle timer after state recovery ({remaining}s remaining)");
+ _waitingForEspSettleTimer?.Dispose();
+ _waitingForEspSettleTimer = new Timer(
+ OnEspSettleTimerExpired,
+ null,
+ TimeSpan.FromSeconds(remaining),
+ TimeSpan.FromMilliseconds(-1));
+ }
+ }
+ }
+
+ private void RecordSignal(string signal)
+ {
+ bool added;
+ int count;
+ lock (_stateLock)
+ {
+ if (!_stateData.SignalsSeen.Contains(signal))
+ {
+ _stateData.SignalsSeen.Add(signal);
+ added = true;
+ count = _stateData.SignalsSeen.Count;
+ }
+ else
+ {
+ added = false;
+ count = _stateData.SignalsSeen.Count;
+ }
+ _stateDirty = true;
+ }
+
+ if (added)
+ _logger.Verbose($"EnrollmentTracker: signal recorded: '{signal}' (total: {count})");
+ else
+ _logger.Debug($"EnrollmentTracker: signal '{signal}' already recorded — deduplicated");
+ }
+
+ ///
+ /// Returns a thread-safe snapshot of _stateData.SignalsSeen for use in event data.
+ ///
+ private List SnapshotSignalsSeen()
+ {
+ lock (_stateLock)
+ {
+ return new List(_stateData.SignalsSeen);
+ }
+ }
+
+ // ===== Unified Completion Logic =====
+
+ ///
+ /// Central guard method for enrollment_complete emission. All completion paths route through here.
+ /// An _enrollmentCompleteEmitted flag prevents double emission.
+ /// Emits a throttled completion_check event on every call for observability.
+ ///
+ private void TryEmitEnrollmentComplete(string source)
+ {
+ if (string.IsNullOrEmpty(source))
+ {
+ _logger.Debug("EnrollmentTracker: TryEmitEnrollmentComplete called with empty source — ignoring");
+ return;
+ }
+
+ // Atomically check all guards and claim the completion flag under a single lock.
+ // This prevents the critical double-emission race where two threads (e.g. OnHelloCompleted
+ // and OnWaitingForHelloSafetyTimeout) both read _enrollmentCompleteEmitted=false simultaneously.
+ bool helloResolved;
+ bool espGateBlocked;
+ bool hybridRebootGateBlocked;
+ bool alreadyEmitted;
+ string enrollmentType;
+ bool espEverSeen, espFinalExitSeen, desktopArrived;
+ bool isDeviceOnly, isSelfDeploying;
+ int? autopilotMode;
+ List signalsSeen;
+
+ lock (_stateLock)
+ {
+ if (_enrollmentCompleteEmitted)
+ {
+ alreadyEmitted = true;
+ helloResolved = false;
+ espGateBlocked = false;
+ hybridRebootGateBlocked = false;
+ enrollmentType = _enrollmentType;
+ espEverSeen = _espEverSeen;
+ espFinalExitSeen = _espFinalExitSeen;
+ desktopArrived = _desktopArrived;
+ isDeviceOnly = IsDeviceOnlyDeployment;
+ isSelfDeploying = IsSelfDeploying;
+ autopilotMode = _autopilotMode;
+ signalsSeen = null;
+ }
+ else
+ {
+ alreadyEmitted = false;
+ enrollmentType = _enrollmentType;
+ espEverSeen = _espEverSeen;
+ espFinalExitSeen = _espFinalExitSeen;
+ desktopArrived = _desktopArrived;
+ isDeviceOnly = IsDeviceOnlyDeployment;
+ isSelfDeploying = IsSelfDeploying;
+ autopilotMode = _autopilotMode;
+
+ helloResolved = _espAndHelloTracker == null
+ || _espAndHelloTracker.IsHelloCompleted
+ || !_espAndHelloTracker.IsPolicyConfigured
+ || isDeviceOnly;
+
+ espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello")
+ && _flowHandler.AppliesEspGateOnDesktopArrival && espEverSeen && !espFinalExitSeen;
+
+ // GUARD 3: Hybrid Join reboot gate
+ // In hybrid join, ESP exit may be for a mid-enrollment reboot (domain user
+ // login required). Block esp_hello_composite unless we have stronger confirmation:
+ // - IME user session completed (strongest signal: IME says enrollment is done)
+ // - Agent restarted after ESP exit (reboot happened, we survived it)
+ hybridRebootGateBlocked = false;
+ if (_isHybridJoin && source == "esp_hello_composite")
+ {
+ bool hasImeCompletion = _stateData.ImePatternSeenUtc.HasValue;
+ bool agentRestartedAfterEspExit = _stateData.EspFinalExitUtc.HasValue
+ && _agentStartTimeUtc > _stateData.EspFinalExitUtc.Value;
+
+ hybridRebootGateBlocked = !hasImeCompletion && !agentRestartedAfterEspExit;
+ }
+
+ // If all guards pass, claim the flag atomically
+ if (helloResolved && !espGateBlocked && !hybridRebootGateBlocked)
+ {
+ _enrollmentCompleteEmitted = true;
+ _stateData.EnrollmentCompleteEmitted = true;
+ signalsSeen = new List(_stateData.SignalsSeen);
+
+ // Stop recurring/watchdog timers under the SAME lock that claims
+ // the emission flag. Concurrent timer callbacks already past their
+ // own _enrollmentCompleteEmitted check will no-op further work;
+ // callbacks not yet past it will read flag=true and return.
+ // Change(Inf,Inf) is idempotent and deadlock-safe inside the lock;
+ // Dispose would wait on in-flight callbacks and is risky here.
+ _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _summaryTimerActive = false;
+ _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debugStateTimerActive = false;
+ _deviceOnlyCompletionSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ }
+ else
+ {
+ signalsSeen = null;
+ }
+ }
+ }
+
+ // All logging and event emission happens OUTSIDE the lock
+ if (alreadyEmitted)
+ {
+ _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — already emitted, skipping");
+ return;
+ }
+
+ _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — evaluating guards " +
+ $"[espEverSeen={espEverSeen}, espFinalExitSeen={espFinalExitSeen}, desktopArrived={desktopArrived}, " +
+ $"helloCompleted={_espAndHelloTracker?.IsHelloCompleted ?? false}, helloPolicyConfigured={_espAndHelloTracker?.IsPolicyConfigured ?? false}, " +
+ $"enrollmentType={enrollmentType}, autopilotMode={autopilotMode}, isSelfDeploying={isSelfDeploying}, isDeviceOnlyDeployment={isDeviceOnly}, " +
+ $"isHybridJoin={_isHybridJoin}]");
+
+ if (!helloResolved)
+ {
+ _logger.Info($"EnrollmentTracker: Completion source '{source}' ready but Hello still pending — not completing yet");
+ EmitCompletionCheck(source, "hello_pending", "hello_not_resolved");
+ return;
+ }
+
+ if (espGateBlocked)
+ {
+ _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — ESP still active");
+ EmitCompletionCheck(source, "blocked", "esp_active");
+ return;
+ }
+
+ if (hybridRebootGateBlocked)
+ {
+ _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — hybrid join reboot gate " +
+ $"(waiting for IME completion or agent restart after ESP exit)");
+ EmitCompletionCheck(source, "blocked", "hybrid_reboot_gate");
+ return;
+ }
+
+ // GUARD 4: WhiteGlove Part 1 redirect — canonical classifier decision.
+ // All four WG decision sites go through WhiteGloveClassifier (this guard,
+ // OnDeviceSetupProvisioningComplete, OnFinalizingSetupPhaseTriggered esp_exiting,
+ // and the StateMachine), so decisions converge on the same rule.
+ // ShouldRouteToWhiteGlovePart1==true only on Strong confidence; Weak is
+ // observable telemetry but falls through to the regular completion path.
+ var wgSignals = SnapshotWhiteGloveSignals();
+ var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
+ EmitWhiteGloveClassification(wgClassification, "TryEmitEnrollmentComplete", wgSignals);
+
+ if (wgClassification.ShouldRouteToWhiteGlovePart1)
+ {
+ _logger.Info($"EnrollmentTracker: all guards passed and WG classifier Strong — " +
+ $"redirecting to WhiteGlove Part 1 (source={source}, {wgClassification.Reason})");
+
+ EmitDecisionProcess("whiteglove_complete", source, "redirected_wg_part1", new List
+ {
+ $"original_source={source}",
+ $"wg_score={wgClassification.Score}",
+ $"wg_confidence={wgClassification.Confidence}",
+ }.Concat(wgClassification.ContributingFactors ?? new List())
+ .Concat(new[] { "redirect: enrollment_complete -> whiteglove_complete" })
+ .ToList(),
+ classification: wgClassification);
+
+ // Decision is already recorded above — CompleteWhiteGlove must not emit a second one.
+ CompleteWhiteGlove(source, decisionAlreadyEmitted: true);
+ return;
+ }
+
+ _logger.Info($"EnrollmentTracker: all guards passed for source '{source}' — emitting enrollment_complete " +
+ $"(wg_classifier: {wgClassification.Reason})");
+
+ // Emit decision process before the terminal event
+ EmitDecisionProcess("enrollment_complete", source, "succeeded", new List
+ {
+ $"source={source}",
+ helloResolved ? "hello_resolved" : "hello_bypassed",
+ espEverSeen ? $"esp_seen(finalExit={espFinalExitSeen})" : "esp_not_seen",
+ desktopArrived ? "desktop_arrived" : "desktop_not_arrived",
+ isDeviceOnly ? "device_only" : "user_driven",
+ _isHybridJoin ? "hybrid_join" : "aad_join",
+ wgSignals.HasSaveWhiteGloveSuccessResult ? "wg_registry_succeeded" : "no_wg_registry",
+ $"wg_score={wgClassification.Score}",
+ $"wg_confidence={wgClassification.Confidence}"
+ }, classification: wgClassification);
+
+ // Timers were already stopped inside the lock when _enrollmentCompleteEmitted was claimed.
+
+ var helloOutcome = _espAndHelloTracker?.HelloOutcome ?? "unknown";
+
+ // Snapshot timestamps under lock for consistent event data
+ Dictionary signalTimestamps;
+ lock (_stateLock)
+ {
+ signalTimestamps = new Dictionary();
+ if (_stateData.EspFirstSeenUtc.HasValue)
+ signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
+ if (_stateData.DesktopArrivedUtc.HasValue)
+ signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
+ if (_stateData.EspFinalExitUtc.HasValue)
+ signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
+ if (_stateData.HelloResolvedUtc.HasValue)
+ signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
+ if (_stateData.ImePatternSeenUtc.HasValue)
+ signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
+ if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue)
+ signalTimestamps["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o");
+ }
+
+ // Capture ESP provisioning snapshot for the enrollment_complete event (all sources)
+ Dictionary espProvisioningData = null;
+ if (_espAndHelloTracker != null)
+ {
+ var espSnapshot = _espAndHelloTracker.GetProvisioningSnapshot();
+ if (espSnapshot != null)
+ {
+ espProvisioningData = new Dictionary
+ {
+ { "categoriesSeen", espSnapshot.CategoriesSeen },
+ { "categoriesResolved", espSnapshot.CategoriesResolved },
+ { "allResolved", espSnapshot.AllResolved },
+ { "categoryOutcomes", espSnapshot.CategoryOutcomes },
+ { "subcategoryStates", espSnapshot.SubcategoryStates }
+ };
+ }
+ }
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "enrollment_complete",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Complete,
+ Message = $"Autopilot enrollment completed successfully (source: {source})",
+ Data = new Dictionary
+ {
+ { "completionSource", source },
+ { "helloOutcome", helloOutcome },
+ { "autopilotMode", autopilotMode?.ToString() ?? "unknown" },
+ { "isSelfDeploying", isSelfDeploying },
+ { "isDeviceOnlyDeployment", isDeviceOnly },
+ { "signalsSeen", signalsSeen },
+ { "signalTimestamps", signalTimestamps },
+ { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds },
+ { "espProvisioningStatus", espProvisioningData }
+ },
+ ImmediateUpload = true
+ });
+
+ // Write final status dump and enrollment complete marker
+ WriteFinalStatus("completed", source);
+ WriteEnrollmentCompleteMarker();
+
+ // Clean up persisted tracker state so next enrollment starts fresh
+ _imeLogTracker?.DeleteState();
+ _statePersistence.Delete();
+ }
+
+ ///
+ /// Emits a throttled completion_check event for observability.
+ /// Max 1 event per minute per source to avoid flooding.
+ ///
+ private void EmitCompletionCheck(string source, string result, string reason)
+ {
+ // Throttle: max 1x per minute per source
+ var now = DateTime.UtcNow;
+ if (_lastCompletionCheckBySource.TryGetValue(source, out var lastEmit) && (now - lastEmit).TotalSeconds < 60)
+ return;
+ _lastCompletionCheckBySource[source] = now;
+
+ Dictionary checkData;
+ lock (_stateLock)
+ {
+ checkData = new Dictionary
+ {
+ { "source", source },
+ { "result", result },
+ { "reason", reason },
+ { "espEverSeen", _espEverSeen },
+ { "espFinalExitSeen", _espFinalExitSeen },
+ { "desktopArrived", _desktopArrived },
+ { "helloResolved", _espAndHelloTracker?.IsHelloCompleted ?? false },
+ { "helloPolicyConfigured", _espAndHelloTracker?.IsPolicyConfigured ?? false },
+ { "enrollmentType", _enrollmentType },
+ { "lastEspPhase", _lastEspPhase ?? "none" },
+ { "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" },
+ { "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" },
+ { "autopilotMode", _autopilotMode?.ToString() ?? "unknown" },
+ { "isSelfDeploying", IsSelfDeploying },
+ { "isDeviceOnlyDeployment", IsDeviceOnlyDeployment },
+ { "whiteGloveStartDetected", _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false },
+ { "saveWhiteGloveSuccessResult", _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false },
+ { "fooUserDetected", _fooUserDetected }
+ };
+ }
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "completion_check",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Completion source '{source}' evaluated — {result}",
+ ImmediateUpload = true,
+ Data = checkData
+ });
+ }
+
+ ///
+ /// Emits a decision_process_completion event immediately before a terminal event
+ /// (enrollment_complete, whiteglove_complete, enrollment_failed).
+ /// Contains the full state machine snapshot, all signal timestamps, and the decision chain
+ /// that led to the outcome — enabling post-hoc analysis without reconstructing from individual events.
+ ///
+ private void EmitDecisionProcess(string terminalEvent, string source, string outcome,
+ List decisionChain = null,
+ WhiteGloveClassification classification = null)
+ {
+ Dictionary data;
+ lock (_stateLock)
+ {
+ // Full state machine snapshot
+ data = new Dictionary
+ {
+ // Decision metadata
+ { "terminalEvent", terminalEvent },
+ { "completionSource", source },
+ { "outcome", outcome },
+ { "decisionChain", decisionChain ?? new List() },
+
+ // Core state variables
+ { "enrollmentType", _enrollmentType },
+ { "autopilotMode", _autopilotMode?.ToString() ?? "unknown" },
+ { "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" },
+ { "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" },
+ { "isHybridJoin", _isHybridJoin },
+ { "isSelfDeploying", IsSelfDeploying },
+ { "isDeviceOnlyDeployment", IsDeviceOnlyDeployment },
+
+ // ESP state
+ { "espEverSeen", _espEverSeen },
+ { "espFinalExitSeen", _espFinalExitSeen },
+ { "lastEspPhase", _lastEspPhase ?? "none" },
+
+ // Desktop & Hello state
+ { "desktopArrived", _desktopArrived },
+ { "helloResolved", _espAndHelloTracker?.IsHelloCompleted ?? false },
+ { "helloPolicyConfigured", _espAndHelloTracker?.IsPolicyConfigured ?? false },
+ { "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" },
+
+ // WhiteGlove signals
+ { "whiteGloveStartDetected", _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false },
+ { "saveWhiteGloveSuccessResult", _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false },
+ { "fooUserDetected", _fooUserDetected },
+ { "aadJoinedWithUser", _aadJoinedWithUser },
+
+ // Signal audit trail with timestamps
+ { "signalsSeen", new List(_stateData.SignalsSeen) },
+
+ // Agent context
+ { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds },
+ { "enrollmentCompleteAlreadyEmitted", _enrollmentCompleteEmitted },
+ { "whiteGloveAlreadyTriggered", _signalCorrelatedWhiteGloveTriggered }
+ };
+
+ // Signal timestamps
+ var ts = new Dictionary();
+ if (_stateData.EspFirstSeenUtc.HasValue)
+ ts["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
+ if (_stateData.DesktopArrivedUtc.HasValue)
+ ts["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
+ if (_stateData.EspFinalExitUtc.HasValue)
+ ts["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
+ if (_stateData.HelloResolvedUtc.HasValue)
+ ts["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
+ if (_stateData.ImePatternSeenUtc.HasValue)
+ ts["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
+ if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue)
+ ts["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o");
+ data["signalTimestamps"] = ts;
+
+ // WhiteGlove classifier output — same fields as the whiteglove_classification
+ // event so aggregate MCP queries can correlate terminal decisions with the
+ // scoring history across a session.
+ if (classification != null)
+ {
+ data["wgScore"] = classification.Score;
+ data["wgConfidence"] = classification.Confidence.ToString();
+ data["wgFactors"] = classification.ContributingFactors ?? new List();
+ }
+ }
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "decision_process_completion",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Decision: {terminalEvent} via {source} — {outcome}",
+ ImmediateUpload = true,
+ Data = data
+ });
+
+ _logger.Info($"EnrollmentTracker: decision_process_completion emitted — {terminalEvent} via {source} ({outcome})");
+ }
+
+ // =====================================================================
+ // WhiteGlove classification helpers — see WhiteGloveClassifier for the
+ // scoring rules and thresholds. The three completion call-sites
+ // (OnDeviceSetupProvisioningComplete, TryEmitEnrollmentComplete,
+ // CompletionStateMachine.HandleDeviceSetupProvisioningComplete) all
+ // classify through the same classifier, so decisions converge.
+ // =====================================================================
+
+ ///
+ /// Snapshot current WhiteGlove-relevant state under _stateLock and return a
+ /// consumable for .
+ ///
+ private WhiteGloveSignals SnapshotWhiteGloveSignals()
+ {
+ lock (_stateLock)
+ {
+ var hasAccountSetup = _espAndHelloTracker?.HasAccountSetupActivity ?? false;
+ var hasSaveWg = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false;
+ var wgStart = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false;
+
+ bool agentRestartedAfterEsp = _stateData.EspFinalExitUtc.HasValue
+ && _agentStartTimeUtc > _stateData.EspFinalExitUtc.Value;
+
+ return new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired,
+ HasSaveWhiteGloveSuccessResult = hasSaveWg,
+ IsWhiteGloveStartDetected = wgStart,
+ IsFooUserDetected = _fooUserDetected,
+ AgentRestartedAfterEspExit = agentRestartedAfterEsp,
+
+ AadJoinedWithUser = _aadJoinedWithUser,
+ DesktopArrived = _desktopArrived,
+ HasAccountSetupActivity = hasAccountSetup,
+
+ IsDeviceOnlyDeployment = IsDeviceOnlyDeployment,
+
+ EspFinalExitUtc = _stateData.EspFinalExitUtc,
+ DeviceSetupProvisioningCompleteUtc = _stateData.DeviceSetupProvisioningCompleteUtc,
+ };
+ }
+ }
+
+ ///
+ /// Emits the observability-only whiteglove_classification event with the
+ /// full scoring breakdown. Enables MCP query_table queries like
+ /// "distribution of wgConfidence over last 24h" or "drill-down into Weak sessions"
+ /// without any backend schema change — all fields live inside the existing data bag.
+ ///
+ private void EmitWhiteGloveClassification(
+ WhiteGloveClassification c,
+ string callSite,
+ WhiteGloveSignals signals)
+ {
+ if (c == null) return;
+
+ var data = new Dictionary
+ {
+ { "wgScore", c.Score },
+ { "wgConfidence", c.Confidence.ToString() },
+ { "callSite", callSite },
+ { "shouldRouteToWg", c.ShouldRouteToWhiteGlovePart1 },
+ { "factors", c.ContributingFactors ?? new List() },
+ { "signals", new Dictionary
+ {
+ { "shellCoreWg", signals.ShellCoreWhiteGloveSuccess },
+ { "hasSaveWg", signals.HasSaveWhiteGloveSuccessResult },
+ { "event509", signals.IsWhiteGloveStartDetected },
+ { "fooUser", signals.IsFooUserDetected },
+ { "agentRestartAfterEsp", signals.AgentRestartedAfterEspExit },
+ { "aadJoined", signals.AadJoinedWithUser },
+ { "desktopArrived", signals.DesktopArrived },
+ { "accountSetupActive", signals.HasAccountSetupActivity },
+ { "deviceOnly", signals.IsDeviceOnlyDeployment },
+ }
+ },
+ };
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "whiteglove_classification",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"WG classification at {callSite}: {c.Confidence} (score={c.Score})",
+ ImmediateUpload = false,
+ Data = data,
+ });
+ }
+
+ ///
+ /// Called by CollectorCoordinator when the
+ /// detects a transient provisioning placeholder user (foouser@/autopilot@). Updates
+ /// _fooUserDetected so the WhiteGloveClassifier sees the positive indicator
+ /// on the next classification call. Does NOT re-trigger completion — placeholder
+ /// alone is not a terminal signal.
+ ///
+ public void NotifyAadPlaceholderUserDetected(string userEmail)
+ {
+ bool alreadyDetected;
+ lock (_stateLock)
+ {
+ if (_enrollmentCompleteEmitted) return;
+
+ alreadyDetected = _fooUserDetected;
+ if (!alreadyDetected)
+ {
+ _fooUserDetected = true;
+ _stateData.FooUserDetected = true;
+ }
+ }
+
+ if (alreadyDetected)
+ {
+ _logger.Debug("EnrollmentTracker: NotifyAadPlaceholderUserDetected — already detected, ignoring");
+ return;
+ }
+
+ _statePersistence.Save(_stateData);
+ RecordSignal("aad_placeholder_user_detected_late");
+ _logger.Info("EnrollmentTracker: late placeholder user detected — recording for classifier");
+
+ var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds;
+ EmitTraceEvent("aad_placeholder_user_detected_late",
+ "Transient AAD provisioning placeholder user appeared in JoinInfo registry",
+ new Dictionary
+ {
+ { "secondsSinceAgentStart", secondsSinceStart },
+ { "emailPattern", userEmail?.Split('@')[0] ?? "unknown" },
+ });
+ }
+
+ ///
+ /// Called by CollectorCoordinator when the
+ /// detects a real AAD user join (non-placeholder) during the session. Updates
+ /// _aadJoinedWithUser atomically so subsequent WhiteGloveClassifier calls
+ /// correctly see the real user (driving IsDeviceOnlyDeployment to false and
+ /// triggering the classifier's hard-exclude).
+ ///
+ ///
+ /// This is NOT a completion trigger. In a user-driven v1 flow the real AAD user
+ /// appears at the START of AccountSetup (when the user signs in), not at the end —
+ /// firing completion here would short-circuit ESP. The signal only flips classifier
+ /// state away from device-only/WhiteGlove; completion continues to flow through the
+ /// standard paths (ime_pattern / esp_hello_composite / desktop_hello), each of which
+ /// enforces its own ESP and Hello gates.
+ ///
+ public void NotifyAadUserJoinedLate(string userEmail)
+ {
+ bool alreadyJoined;
+ bool alreadyEmitted;
+ lock (_stateLock)
+ {
+ alreadyEmitted = _enrollmentCompleteEmitted;
+ alreadyJoined = _aadJoinedWithUser;
+ if (!alreadyJoined && !alreadyEmitted)
+ {
+ _aadJoinedWithUser = true;
+ _stateData.AadJoinedWithUser = true;
+ }
+ }
+
+ if (alreadyEmitted)
+ {
+ _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — enrollment already emitted, ignoring");
+ return;
+ }
+ if (alreadyJoined)
+ {
+ _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — already joined, ignoring");
+ return;
+ }
+
+ _statePersistence.Save(_stateData);
+ RecordSignal("aad_user_joined_late");
+ _logger.Info($"EnrollmentTracker: late AAD user joined ({userEmail}) — classifier state updated");
+
+ var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds;
+ EmitTraceEvent("aad_user_joined_late",
+ "Real AAD user appeared in JoinInfo registry after session started",
+ new Dictionary
+ {
+ { "secondsSinceAgentStart", secondsSinceStart },
+ { "userEmailDomain", userEmail?.Contains("@") == true ? userEmail.Substring(userEmail.IndexOf('@')) : "unknown" },
+ });
+ }
+
+ ///
+ /// Called by MonitoringService when Desktop Arrival is detected (explorer.exe under a real user).
+ /// Corrects phase if needed, starts Hello wait timer in no-ESP scenarios, and attempts completion.
+ ///
+ public void NotifyDesktopArrived()
+ {
+ bool alreadyArrived;
+ lock (_stateLock)
+ {
+ alreadyArrived = _desktopArrived;
+ if (!alreadyArrived)
+ {
+ _desktopArrived = true;
+ _stateData.DesktopArrived = true;
+ _stateData.DesktopArrivedUtc = DateTime.UtcNow;
+ }
+ }
+
+ if (alreadyArrived)
+ {
+ _logger.Debug("EnrollmentTracker: NotifyDesktopArrived called but already arrived — ignoring");
+ return;
+ }
+
+ _statePersistence.Save(_stateData); // Immediate persist — desktop arrival is completion-critical
+ RecordSignal("desktop_arrived");
+ _logger.Info("EnrollmentTracker: Desktop arrival notified");
+
+ // Phase correction and SkipUserStatusPage handling when ESP was seen.
+ // SkipUserStatusPage check is prioritized over phase correction because in WhiteGlove Part 2,
+ // _lastEspPhase may already be "AccountSetup" (persisted from Part 1) which would otherwise
+ // skip the SkipUser block and leave _espFinalExitSeen=false, blocking the desktop-arrival gate.
+ bool espSeen;
+ string previousPhase;
+ bool? skipUserPage;
+ bool espFinalExit;
+ lock (_stateLock)
+ {
+ espSeen = _espEverSeen;
+ previousPhase = _lastEspPhase ?? "unknown";
+ skipUserPage = _skipUserStatusPage;
+ espFinalExit = _espFinalExitSeen;
+ }
+
+ if (espSeen)
+ {
+ if (skipUserPage == true && !espFinalExit)
+ {
+ lock (_stateLock)
+ {
+ _lastEspPhase = "FinalizingSetup";
+ _stateData.LastEspPhase = "FinalizingSetup";
+ _espFinalExitSeen = true;
+ _stateData.EspFinalExitSeen = true;
+ _stateData.EspFinalExitUtc = DateTime.UtcNow;
+ _hasAutoSwitchedToAppsPhase = false;
+ }
+ _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate
+ RecordSignal("desktop_arrived_skip_user");
+ _logger.Info($"EnrollmentTracker: Desktop arrival with SkipUserStatusPage=true — skipping AccountSetup, transitioning to FinalizingSetup (was: {previousPhase})");
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "DesktopArrivalDetector",
+ Phase = EnrollmentPhase.FinalizingSetup,
+ Message = $"ESP phase: FinalizingSetup (SkipUserStatusPage — desktop arrival, was: {previousPhase})",
+ Data = new Dictionary
+ {
+ { "espPhase", "FinalizingSetup" },
+ { "autoDetected", true },
+ { "correctedBy", "desktop_arrival" },
+ { "previousPhase", previousPhase },
+ { "skipUserStatusPage", true }
+ },
+ ImmediateUpload = true
+ });
+
+ try { CollectDeviceInfoAtFinalizingSetup("desktop_arrived_skip_user"); }
+ catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (desktop_arrived_skip_user): {ex.Message}"); }
+ }
+ else if (!string.Equals(previousPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ // Normal flow: desktop arrival confirms AccountSetup phase
+ lock (_stateLock) { _lastEspPhase = "AccountSetup"; }
+ _logger.Info($"EnrollmentTracker: Desktop arrival confirmed AccountSetup phase (was: {previousPhase}) — phase corrected on timeline");
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "DesktopArrivalDetector",
+ Phase = EnrollmentPhase.AccountSetup,
+ Message = $"ESP phase: AccountSetup (auto-detected from desktop arrival, was: {previousPhase})",
+ Data = new Dictionary
+ {
+ { "espPhase", "AccountSetup" },
+ { "autoDetected", true },
+ { "correctedBy", "desktop_arrival" },
+ { "previousPhase", previousPhase }
+ },
+ ImmediateUpload = true
+ });
+ }
+ }
+
+ // Start Hello wait timer ONLY when ESP is NOT actively running.
+ // During active ESP (AccountSetup runs in background with desktop visible),
+ // the Hello timer must wait until ESP exits (started in OnFinalizingSetupPhaseTriggered).
+ // Without this guard, Hello timeout-resolves while ESP still installs apps → premature completion.
+ // Device-only deployment: no user session, Hello is irrelevant — skip timer entirely.
+ bool espSeenNow, espFinalExitNow, isDeviceOnlyNow;
+ lock (_stateLock)
+ {
+ espSeenNow = _espEverSeen;
+ espFinalExitNow = _espFinalExitSeen;
+ isDeviceOnlyNow = IsDeviceOnlyDeployment;
+ }
+
+ if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted
+ && (!espSeenNow || espFinalExitNow)
+ && !isDeviceOnlyNow)
+ {
+ _logger.Info("EnrollmentTracker: Desktop arrived with Hello pending (no active ESP) — starting Hello wait timer");
+ _espAndHelloTracker.StartHelloWaitTimer();
+ }
+
+ TryEmitEnrollmentComplete("desktop_arrival");
+
+ // Shadow state machine: track desktop_arrived trigger
+ ShadowProcessTrigger("desktop_arrived");
+ }
+
+ ///
+ /// Called when the device-only ESP timer expires. If no AccountSetup phase started
+ /// and desktop is available, classify as device-only ESP and mark final exit.
+ ///
+ private void OnDeviceOnlyEspTimerExpired(object state)
+ {
+ string lastPhaseSnap;
+ bool desktopArrSnap;
+ lock (_stateLock)
+ {
+ lastPhaseSnap = _lastEspPhase;
+ desktopArrSnap = _desktopArrived;
+ }
+
+ // AccountSetup started meanwhile? Timer is obsolete
+ if (string.Equals(lastPhaseSnap, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Debug("EnrollmentTracker: Device-only ESP timer expired but AccountSetup detected — ignoring");
+ return;
+ }
+
+ if (desktopArrSnap)
+ {
+ _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min — classified as device-only ESP, desktop is active");
+ lock (_stateLock)
+ {
+ _espFinalExitSeen = true;
+ _stateData.EspFinalExitSeen = true;
+ _stateData.EspFinalExitUtc = DateTime.UtcNow;
+ }
+ _statePersistence.Save(_stateData); // Immediate persist — device-only ESP final exit
+ RecordSignal("device_only_esp_final_exit");
+ _espAndHelloTracker?.StartHelloWaitTimer();
+ }
+ else
+ {
+ _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min and no desktop yet — waiting for Desktop Arrival or Lifetime Timer");
+ }
+
+ // Shadow state machine: track device-only ESP timer expiry
+ ShadowProcessTrigger("device_only_esp_timer_expired");
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs
new file mode 100644
index 00000000..df6a69ec
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs
@@ -0,0 +1,381 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
+{
+ ///
+ /// Partial: Device info collection at key lifecycle points, app tracking summaries,
+ /// debug state timer, and final status file writing.
+ ///
+ public partial class EnrollmentTracker
+ {
+ private void CollectDeviceInfoAtEnrollmentStart()
+ {
+ lock (_stateLock)
+ {
+ if (_enrollmentStartDeviceInfoCollected)
+ return;
+ _enrollmentStartDeviceInfoCollected = true;
+ }
+
+ if (!_isBootstrapMode)
+ {
+ _logger.Debug("EnrollmentTracker: skipping enrollment-start device info re-collection (not bootstrap mode)");
+ return;
+ }
+
+ _logger.Info("EnrollmentTracker: first ESP phase detected — re-collecting enrollment-dependent device info (bootstrap mode)");
+ Task.Run(() =>
+ {
+ try
+ {
+ _deviceInfoCollector.CollectAtEnrollmentStart();
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: failed to re-collect device info at enrollment start: {ex.Message}");
+ }
+ });
+ }
+
+ private void CollectDeviceInfoAtFinalizingSetup(string triggerReason)
+ {
+ bool alreadyCollected;
+ lock (_stateLock)
+ {
+ alreadyCollected = _finalDeviceInfoCollected;
+ if (!alreadyCollected)
+ _finalDeviceInfoCollected = true;
+ }
+
+ if (alreadyCollected)
+ {
+ _logger.Debug($"EnrollmentTracker: final device info already collected, skipping (trigger: {triggerReason})");
+ return;
+ }
+ _logger.Info($"EnrollmentTracker: triggering final device info collection at FinalizingSetup (trigger: {triggerReason})");
+ CollectDeviceInfoAtEnd();
+ }
+
+ private void SummaryTimerCallback(object state)
+ {
+ // Only emit if state-breakdown counters changed since last emission (backstop for missed events)
+ EmitAppTrackingSummaryIfChanged();
+
+ // Signal-correlated WhiteGlove detection (Ebene 2.6) — event-driven in the sense
+ // that it only triggers when the observed signal set matches; zero-cost when the
+ // session does not look like a sealing candidate.
+ try { CheckSignalCorrelatedWhiteGlove(); }
+ catch (Exception ex) { _logger.Error("CheckSignalCorrelatedWhiteGlove failed", ex); }
+
+ // Periodic state save (piggybacks on the existing 30s timer)
+ bool dirty;
+ EnrollmentStateData stateSnapshot;
+ lock (_stateLock)
+ {
+ dirty = _stateDirty;
+ if (dirty)
+ {
+ _stateDirty = false;
+ stateSnapshot = _stateData;
+ }
+ else
+ {
+ stateSnapshot = null;
+ }
+ }
+
+ if (dirty)
+ _statePersistence.Save(stateSnapshot);
+ }
+
+ ///
+ /// Periodic debug state snapshot (every 60s). Writes to agent log only (not as event) to aid
+ /// post-mortem debugging of stuck enrollments without consuming Azure Table Storage.
+ ///
+ private void DebugStateTimerCallback(object state)
+ {
+ try
+ {
+ string lastPhaseDbg;
+ bool espFinalExitDbg, desktopDbg;
+ lock (_stateLock)
+ {
+ lastPhaseDbg = _lastEspPhase;
+ espFinalExitDbg = _espFinalExitSeen;
+ desktopDbg = _desktopArrived;
+ }
+
+ var states = _imeLogTracker?.PackageStates;
+ if (states == null || states.CountAll == 0) return;
+
+ var active = states.Where(x => x.IsActive).Select(x => $"{x.Name ?? x.Id}({x.InstallationState})");
+ var errors = states.Where(x => x.IsError).Select(x => $"{x.Name ?? x.Id}");
+
+ _logger.Debug($"[StateSnapshot] Apps: {states.CountCompleted}/{states.CountAll} completed, " +
+ $"{states.ErrorCount} errors (device:{states.Count(x => x.IsError && x.Targeted == AppTargeted.Device)}, " +
+ $"user:{states.Count(x => x.IsError && x.Targeted == AppTargeted.User)}), " +
+ $"active: [{string.Join(", ", active)}], " +
+ $"failed: [{string.Join(", ", errors)}], " +
+ $"phase: {lastPhaseDbg ?? "none"}, espExit: {espFinalExitDbg}, desktop: {desktopDbg}");
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"[StateSnapshot] Error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Emits app_tracking_summary only if state-breakdown counters changed since last emission.
+ /// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop).
+ ///
+ private void EmitAppTrackingSummaryIfChanged()
+ {
+ var states = _imeLogTracker?.PackageStates;
+ if (states == null || states.CountAll == 0) return;
+
+ var hash = GetSummaryHash(states);
+
+ EventSeverity severity;
+ lock (_stateLock)
+ {
+ if (hash == _lastEmittedSummaryHash) return;
+ _lastEmittedSummaryHash = hash;
+
+ var compactHash = $"{states.CountCompleted}_{states.ErrorCount}";
+ severity = (compactHash != _lastEmittedSummaryCompactHash)
+ ? (states.HasError ? EventSeverity.Warning : EventSeverity.Info)
+ : EventSeverity.Debug;
+ _lastEmittedSummaryCompactHash = compactHash;
+ }
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "app_tracking_summary",
+ Severity = severity,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
+ (states.HasError ? $" ({states.ErrorCount} errors)" : ""),
+ Data = states.GetSummaryData(),
+ ImmediateUpload = (severity != EventSeverity.Debug)
+ });
+ }
+
+ ///
+ /// Emits app_tracking_summary unconditionally (for final summary on completion).
+ ///
+ private void EmitAppTrackingSummary()
+ {
+ var states = _imeLogTracker?.PackageStates;
+ if (states == null || states.CountAll == 0) return;
+
+ lock (_stateLock) { _lastEmittedSummaryHash = GetSummaryHash(states); }
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "app_tracking_summary",
+ Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
+ (states.HasError ? $" ({states.ErrorCount} errors)" : ""),
+ Data = states.GetSummaryData(),
+ ImmediateUpload = true
+ });
+ }
+
+ private static string GetSummaryHash(AppPackageStateList states)
+ {
+ return $"{states.CountAll}_{states.CountCompleted}_{states.ErrorCount}_" +
+ $"{states.Count(x => x.InstallationState == AppInstallationState.Downloading)}_" +
+ $"{states.Count(x => x.InstallationState == AppInstallationState.Installing)}_" +
+ $"{states.Count(x => x.InstallationState == AppInstallationState.Installed)}";
+ }
+
+ public void Dispose()
+ {
+ Stop();
+
+ // Unsubscribe from EspAndHelloTracker events
+ if (_espAndHelloTracker != null)
+ {
+ _espAndHelloTracker.HelloCompleted -= OnHelloCompleted;
+ _espAndHelloTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
+ _espAndHelloTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
+ _espAndHelloTracker.EspFailureDetected -= OnEspFailureDetected;
+ _espAndHelloTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete;
+ }
+
+ _espFailureTimer?.Dispose();
+ _deviceOnlyEspTimer?.Dispose();
+ _waitingForHelloSafetyTimer?.Dispose();
+ _waitingForEspSettleTimer?.Dispose();
+ _deviceOnlyCompletionSafetyTimer?.Dispose();
+ _summaryTimer?.Dispose();
+ _debugStateTimer?.Dispose();
+ _imeLogTracker?.Dispose();
+ }
+
+ ///
+ /// Writes an enrollment complete marker to the state directory.
+ /// This marker is checked on agent restart to handle cleanup retry if scheduled task fails.
+ ///
+ private void WriteEnrollmentCompleteMarker()
+ {
+ try
+ {
+ var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
+ Directory.CreateDirectory(stateDirectory);
+
+ var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker");
+ File.WriteAllText(markerPath, $"Enrollment completed at {DateTime.UtcNow:O}");
+
+ _logger.Info($"Enrollment complete marker written: {markerPath}");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Failed to write enrollment complete marker: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Writes a comprehensive final-status.json with all app states, signals, and error summary.
+ /// Automatically included in diagnostics uploads for post-mortem analysis.
+ ///
+ private void WriteFinalStatus(string outcome, string source)
+ {
+ try
+ {
+ var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
+ Directory.CreateDirectory(stateDirectory);
+
+ var appStates = _imeLogTracker?.PackageStates;
+
+ var signalTimestamps = new Dictionary();
+ if (_stateData.EspFirstSeenUtc.HasValue)
+ signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
+ if (_stateData.EspFinalExitUtc.HasValue)
+ signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
+ if (_stateData.DesktopArrivedUtc.HasValue)
+ signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
+ if (_stateData.HelloResolvedUtc.HasValue)
+ signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
+ if (_stateData.ImePatternSeenUtc.HasValue)
+ signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
+ if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue)
+ signalTimestamps["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o");
+
+ // Build per-phase package states: snapshots from completed phases + current phase
+ var phaseSnapshots = _imeLogTracker?.PhasePackageSnapshots;
+ var packageStatesByPhase = new Dictionary();
+
+ // Add snapshots from completed phases (e.g. DeviceSetup apps captured before phase transition)
+ if (phaseSnapshots != null)
+ {
+ foreach (var kvp in phaseSnapshots)
+ packageStatesByPhase[kvp.Key] = kvp.Value;
+ }
+
+ // Add current phase (the phase active at completion, e.g. AccountSetup)
+ var currentPhaseName = _lastEspPhase ?? "Unknown";
+ var currentPhaseList = appStates?.ToFinalStatusList() ?? new List>();
+ packageStatesByPhase[currentPhaseName] = currentPhaseList;
+
+ // Aggregate app summary across ALL phases (snapshots + current)
+ var currentPhaseTotal = appStates?.CountAll ?? 0;
+ var currentPhaseCompleted = appStates?.CountCompleted ?? 0;
+ var currentPhaseErrors = appStates?.ErrorCount ?? 0;
+ var currentPhaseDeviceErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.Device) ?? 0;
+ var currentPhaseUserErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.User) ?? 0;
+
+ var allPhaseTotals = new Dictionary();
+ var totalAppsAllPhases = currentPhaseTotal;
+ var completedAppsAllPhases = currentPhaseCompleted;
+ var errorCountAllPhases = currentPhaseErrors;
+ var deviceErrorsAllPhases = currentPhaseDeviceErrors;
+ var userErrorsAllPhases = currentPhaseUserErrors;
+
+ allPhaseTotals[currentPhaseName] = currentPhaseTotal;
+
+ if (phaseSnapshots != null)
+ {
+ foreach (var kvp in phaseSnapshots)
+ {
+ var phaseApps = kvp.Value;
+ allPhaseTotals[kvp.Key] = phaseApps.Count;
+ totalAppsAllPhases += phaseApps.Count;
+ foreach (var app in phaseApps)
+ {
+ object stateVal;
+ var isError = app.TryGetValue("state", out stateVal) &&
+ string.Equals(stateVal?.ToString(), "Error", StringComparison.OrdinalIgnoreCase);
+ var isCompleted = app.TryGetValue("state", out stateVal) &&
+ (stateVal?.ToString() == "Installed" || stateVal?.ToString() == "Skipped" ||
+ stateVal?.ToString() == "Postponed" || stateVal?.ToString() == "Error");
+ if (isCompleted) completedAppsAllPhases++;
+ if (isError)
+ {
+ errorCountAllPhases++;
+ object targetVal;
+ if (app.TryGetValue("targeted", out targetVal))
+ {
+ if (string.Equals(targetVal?.ToString(), "Device", StringComparison.OrdinalIgnoreCase))
+ deviceErrorsAllPhases++;
+ else if (string.Equals(targetVal?.ToString(), "User", StringComparison.OrdinalIgnoreCase))
+ userErrorsAllPhases++;
+ }
+ }
+ }
+ }
+ }
+
+ var status = new Dictionary
+ {
+ { "timestamp", DateTime.UtcNow.ToString("o") },
+ { "outcome", outcome },
+ { "completionSource", source },
+ { "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" },
+ { "enrollmentType", _enrollmentType ?? "unknown" },
+ { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds },
+ { "signalsSeen", SnapshotSignalsSeen() },
+ { "signalTimestamps", signalTimestamps },
+ { "appSummary", new Dictionary
+ {
+ { "totalApps", totalAppsAllPhases },
+ { "completedApps", completedAppsAllPhases },
+ { "errorCount", errorCountAllPhases },
+ { "deviceErrors", deviceErrorsAllPhases },
+ { "userErrors", userErrorsAllPhases },
+ { "appsByPhase", allPhaseTotals }
+ }
+ },
+ { "packageStatesByPhase", packageStatesByPhase }
+ };
+
+ var json = System.Text.Json.JsonSerializer.Serialize(status,
+ new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
+ var path = Path.Combine(stateDirectory, "final-status.json");
+ File.WriteAllText(path, json);
+ _logger.Info($"Final status written: {path}");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Failed to write final status: {ex.Message}");
+ }
+ }
+ }
+}
+
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs
new file mode 100644
index 00000000..278708a1
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs
@@ -0,0 +1,768 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
+{
+ ///
+ /// Partial: IME log event handlers (ESP phase, app state, script completion, etc.).
+ ///
+ public partial class EnrollmentTracker
+ {
+ ///
+ /// Wraps _emitEvent and automatically injects the current ImeLogTracker patternId into event Data.
+ /// Use this for all events emitted from ImeLogTracker callback handlers.
+ ///
+ private void EmitImeTrackerEvent(EnrollmentEvent evt)
+ {
+ var logTimestamp = _imeLogTracker?.LastMatchedLogTimestamp;
+ if (logTimestamp.HasValue)
+ evt.Timestamp = logTimestamp.Value;
+
+ var patternId = _imeLogTracker?.LastMatchedPatternId;
+ if (!string.IsNullOrEmpty(patternId))
+ {
+ if (evt.Data == null)
+ evt.Data = new Dictionary();
+ evt.Data["patternId"] = patternId;
+ }
+ _emitEvent(evt);
+ }
+
+ private void HandleEspPhaseChanged(string phase)
+ {
+ string lastPhase;
+ bool tracksEspPhases;
+ lock (_stateLock)
+ {
+ tracksEspPhases = _flowHandler.TracksEspPhases;
+ lastPhase = _lastEspPhase;
+ }
+
+ // Flows that do not track ESP phases (DevPrep/WDP) ignore these events entirely.
+ if (!tracksEspPhases)
+ {
+ _logger.Debug($"EnrollmentTracker: skipping ESP phase event — flow does not track ESP (phase: {phase})");
+ return;
+ }
+
+ // ESP resumed after final exit — only possible during hybrid join reboot recovery.
+ // In hybrid join, ESP exits for a mid-enrollment reboot (domain user login required),
+ // then the same phase re-appears in IME after the agent restarts. Reset completion state
+ // so the agent waits for the real final exit instead of self-destructing prematurely.
+ // Non-hybrid scenarios can see the same phase re-reported by IME (e.g., during user
+ // session completion) which is NOT a real resumption — skip the reset entirely.
+ bool espFinalExitSeen;
+ lock (_stateLock) { espFinalExitSeen = _espFinalExitSeen; }
+
+ if (_isHybridJoin && espFinalExitSeen && string.Equals(phase, lastPhase, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Info($"EnrollmentTracker: ESP phase '{phase}' re-detected after final exit — " +
+ "ESP has resumed (hybrid join reboot recovery), resetting completion state");
+ lock (_stateLock)
+ {
+ _espFinalExitSeen = false;
+ _stateData.EspFinalExitSeen = false;
+ _stateData.EspFinalExitUtc = null;
+ }
+ _statePersistence.Save(_stateData); // Immediate persist — ESP resumed resets final exit, must survive crash
+ RecordSignal("esp_resumed");
+ _espAndHelloTracker?.ResetForEspResumption();
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_resumed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.AccountSetup,
+ Message = $"ESP phase '{phase}' re-detected after final exit — enrollment resumed after reboot",
+ Data = new Dictionary
+ {
+ { "espPhase", phase },
+ { "isHybridJoin", _isHybridJoin }
+ },
+ ImmediateUpload = true
+ });
+
+ // Shadow state machine: track ESP resumed
+ ShadowProcessTrigger("esp_resumed");
+ return;
+ }
+
+ // Only emit event if the phase has actually changed
+ if (string.Equals(phase, lastPhase, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Debug($"EnrollmentTracker: ESP phase unchanged ({phase}), skipping event");
+ return;
+ }
+
+ // AccountSetup verification: during WhiteGlove pre-provisioning the ESP briefly enters
+ // AccountSetup before WhiteGlove success, but no real user has signed in (only defaultuser0
+ // exists). Suppress the phase event to avoid a fake AccountSetup on the timeline.
+ // The ImeLogTracker has already updated its internal phase tracking (ignore lists, phase order)
+ // which is correct — we only suppress the event emission to the backend.
+ if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ var hasReal = HasRealUserProfile();
+ _logger.Debug($"EnrollmentTracker: AccountSetup verification — HasRealUserProfile={hasReal}");
+ if (!hasReal)
+ {
+ _logger.Info("EnrollmentTracker: AccountSetup suppressed — no real user profile (likely WhiteGlove)");
+ EmitTraceEvent("AccountSetup_suppressed",
+ "ESP reported AccountSetup but no real user profile found — likely WhiteGlove pre-provisioning",
+ new Dictionary { { "espPhase", phase }, { "previousPhase", lastPhase ?? "null" } });
+ return;
+ }
+ }
+
+ _logger.Info($"EnrollmentTracker: ESP phase changed from '{lastPhase ?? "null"}' to '{phase}'");
+ lock (_stateLock)
+ {
+ _lastEspPhase = phase;
+ _hasAutoSwitchedToAppsPhase = false; // Reset when ESP phase changes
+ _espEverSeen = true;
+ _stateData.EspEverSeen = true;
+ _stateData.LastEspPhase = phase;
+ if (_stateData.EspFirstSeenUtc == null)
+ _stateData.EspFirstSeenUtc = DateTime.UtcNow;
+ }
+ _statePersistence.Save(_stateData); // Immediate persist — ESP phase + espEverSeen must survive reboot
+ RecordSignal($"esp_phase_{phase}");
+
+ // ESP phase change means ESP is progressing — cancel any pending failure grace period
+ CancelPendingEspFailure();
+
+ // Cancel device-only ESP timer if AccountSetup phase detected
+ if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ if (_deviceOnlyEspTimer != null)
+ {
+ _logger.Info("EnrollmentTracker: AccountSetup detected — cancelling device-only ESP timer");
+ _deviceOnlyEspTimer.Dispose();
+ _deviceOnlyEspTimer = null;
+ }
+ }
+
+ // Map ESP phase to EnrollmentPhase (phase change events)
+ var enrollmentPhase = EnrollmentPhase.DeviceSetup;
+ if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ enrollmentPhase = EnrollmentPhase.AccountSetup;
+
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "ImeLogTracker",
+ Phase = enrollmentPhase,
+ Message = $"ESP phase: {phase}",
+ Data = new Dictionary { { "espPhase", phase } },
+ ImmediateUpload = true
+ });
+
+ // Re-collect enrollment-dependent device info (AAD join, profile, ESP config)
+ // that may have been empty at startup (bootstrap / pre-enrollment scenario).
+ CollectDeviceInfoAtEnrollmentStart();
+
+ // Check for ConfigMgr client at phase transitions (may arrive after agent startup)
+ Task.Run(() => CheckConfigMgrClient(
+ enrollmentPhase == EnrollmentPhase.AccountSetup ? "account_setup" : "device_setup"));
+
+ // Start summary timer when we detect ESP phase
+ if (!_summaryTimerActive)
+ {
+ _summaryTimerActive = true;
+ _summaryTimer?.Change(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
+ }
+ if (!_debugStateTimerActive)
+ {
+ _debugStateTimerActive = true;
+ _debugStateTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
+ }
+
+ // Shadow state machine: track ESP phase change
+ ShadowProcessTrigger("esp_phase_changed");
+ }
+
+ private void HandleImeAgentVersion(string version)
+ {
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "ime_agent_version",
+ Severity = EventSeverity.Info,
+ Source = "ImeLogTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"IME Agent version: {version}",
+ Data = new Dictionary { { "agentVersion", version } }
+ });
+ }
+
+ private void HandleImeStarted()
+ {
+ _logger.Info("EnrollmentTracker: IME started event");
+ }
+
+ private void HandleAppStateChanged(AppPackageState app, AppInstallationState oldState, AppInstallationState newState)
+ {
+ _logger.Verbose($"EnrollmentTracker: app state change '{app.Name ?? app.Id}': {oldState} -> {newState}");
+
+ // Auto-switch to app installation phase when first app activity detected
+ // If we're in DeviceSetup and an app starts downloading/installing, switch to AppsDevice
+ // If we're in AccountSetup and an app starts downloading/installing, switch to AppsUser
+ bool hasAutoSwitched;
+ string currentPhase;
+ lock (_stateLock)
+ {
+ hasAutoSwitched = _hasAutoSwitchedToAppsPhase;
+ currentPhase = _lastEspPhase;
+ }
+
+ if (!hasAutoSwitched &&
+ (newState == AppInstallationState.Downloading || newState == AppInstallationState.Installing) &&
+ oldState < AppInstallationState.Downloading)
+ {
+ if (currentPhase != null)
+ {
+ if (string.Equals(currentPhase, "DeviceSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ // Switch from DeviceSetup to AppsDevice
+ _logger.Info($"EnrollmentTracker: First app activity detected during DeviceSetup, switching to AppsDevice phase");
+ lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.AppsDevice,
+ Message = "ESP phase: AppsDevice (auto-detected from app activity)",
+ Data = new Dictionary { { "espPhase", "AppsDevice" }, { "autoDetected", true } },
+ ImmediateUpload = true
+ });
+ }
+ else if (string.Equals(currentPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ // Switch from AccountSetup to AppsUser
+ _logger.Info($"EnrollmentTracker: First app activity detected during AccountSetup, switching to AppsUser phase");
+ lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.AppsUser,
+ Message = "ESP phase: AppsUser (auto-detected from app activity)",
+ Data = new Dictionary { { "espPhase", "AppsUser" }, { "autoDetected", true } },
+ ImmediateUpload = true
+ });
+ }
+ else if (string.Equals(currentPhase, "FinalizingSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ // SkipUserStatusPage flow: apps starting after FinalizingSetup are background user apps
+ _logger.Info($"EnrollmentTracker: First app activity detected during FinalizingSetup (SkipUserStatusPage), switching to AppsUser phase");
+ lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.AppsUser,
+ Message = "ESP phase: AppsUser (auto-detected from app activity)",
+ Data = new Dictionary { { "espPhase", "AppsUser" }, { "autoDetected", true } },
+ ImmediateUpload = true
+ });
+ }
+ }
+ }
+
+ // Only emit strategic events for significant state transitions
+ string eventType;
+ var severity = EventSeverity.Info;
+ var phase = EnrollmentPhase.Unknown; // Apps set to Unknown, will be sorted chronologically into active phase
+
+ switch (newState)
+ {
+ case AppInstallationState.Downloading:
+ // Emit strategic event once when download starts
+ if (oldState < AppInstallationState.Downloading)
+ {
+ eventType = "app_download_started";
+ }
+ else
+ {
+ // Emit debug event for download progress updates
+ // Skip if no real download data (bytesTotal too small or zero)
+ if (app.BytesTotal > 1024) // At least 1 KB to be a real download
+ {
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "download_progress",
+ Severity = EventSeverity.Debug,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = $"{app.Name ?? app.Id}: {app.ProgressPercent}%",
+ Data = app.ToEventData()
+ });
+ }
+ return; // Skip main event emission below
+ }
+ break;
+
+ case AppInstallationState.Installing:
+ // Only emit the strategic event once when install actually starts.
+ // Progress-only updates (oldState == Installing, just progress/bytes changed)
+ // are skipped — same pattern as Downloading above.
+ if (oldState == AppInstallationState.Installing)
+ return;
+ eventType = "app_install_started";
+ break;
+
+ case AppInstallationState.Installed:
+ eventType = "app_install_completed";
+ // Emit download_progress event for download manager (shows as completed)
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "download_progress",
+ Severity = EventSeverity.Debug,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = $"{app.Name ?? app.Id}: completed",
+ Data = new Dictionary(app.ToEventData())
+ {
+ ["status"] = "completed"
+ }
+ });
+ break;
+
+ case AppInstallationState.Skipped:
+ eventType = "app_install_skipped";
+ break;
+
+ case AppInstallationState.Error:
+ eventType = "app_install_failed";
+ severity = EventSeverity.Error;
+ // Emit download_progress event for download manager (shows as failed)
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "download_progress",
+ Severity = EventSeverity.Debug,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = $"{app.Name ?? app.Id}: failed",
+ Data = new Dictionary(app.ToEventData())
+ {
+ ["status"] = "failed"
+ }
+ });
+ break;
+
+ case AppInstallationState.Postponed:
+ eventType = "app_install_postponed";
+ severity = EventSeverity.Warning;
+ break;
+
+ default:
+ return; // Don't emit for Unknown, NotInstalled, InProgress
+ }
+
+ // Build a descriptive message: include error detail if available
+ var message = $"{app.Name ?? app.Id}: {newState}";
+ if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(app.ErrorDetail))
+ message = $"{app.Name ?? app.Id}: {app.ErrorDetail}";
+
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = eventType,
+ Severity = severity,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = message,
+ Data = app.ToEventData(),
+ ImmediateUpload = true
+ });
+
+ // Emit summary immediately if state-breakdown counters changed (instant UI updates)
+ EmitAppTrackingSummaryIfChanged();
+ }
+
+ private void HandlePoliciesDiscovered(string policiesJson)
+ {
+ _logger.Info($"EnrollmentTracker: policies discovered, tracking {_imeLogTracker.PackageStates.Count} apps");
+ }
+
+ private void HandleAllAppsCompleted()
+ {
+ _logger.Info("EnrollmentTracker: all apps completed");
+
+ // Stop timers
+ _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _summaryTimerActive = false;
+ _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debugStateTimerActive = false;
+
+ // Emit final summary
+ EmitAppTrackingSummary();
+
+ // Note: Phase transition to FinalizingSetup is now handled by Shell-Core events
+ // (ESP exit or Hello wizard start) for more robust detection.
+ // We no longer automatically transition here when apps complete.
+ string phaseSnap;
+ lock (_stateLock) { phaseSnap = _lastEspPhase; }
+ if (phaseSnap != null)
+ {
+ _logger.Info($"EnrollmentTracker: All apps completed while in phase '{phaseSnap}'");
+ _logger.Info("EnrollmentTracker: Waiting for ESP exit or Hello wizard events to transition to FinalizingSetup");
+ }
+ }
+
+ private void HandleDoTelemetryReceived(AppPackageState app)
+ {
+ _logger.Info($"EnrollmentTracker: DO telemetry received for {app.Name ?? app.Id}");
+
+ var phase = EnrollmentPhase.Unknown;
+
+ // Dedicated do_telemetry event for backend aggregation
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "do_telemetry",
+ Severity = EventSeverity.Info,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = $"{app.Name ?? app.Id}: DO complete - {app.DoPercentPeerCaching}% peers, mode={app.DoDownloadMode}",
+ Data = app.ToEventData(),
+ ImmediateUpload = true
+ });
+
+ // Also emit download_progress so the UI picks up DO stats
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "download_progress",
+ Severity = EventSeverity.Debug,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = $"{app.Name ?? app.Id}: DO telemetry received",
+ Data = app.ToEventData(),
+ ImmediateUpload = true
+ });
+ }
+
+ private void HandleScriptCompleted(ScriptExecutionState script)
+ {
+ if (script == null || string.IsNullOrEmpty(script.PolicyId))
+ {
+ _logger.Debug($"EnrollmentTracker: HandleScriptCompleted — skipped (script={script != null}, policyId='{script?.PolicyId}')");
+ return;
+ }
+
+ var isSuccess = string.Equals(script.Result, "Success", StringComparison.OrdinalIgnoreCase)
+ || (script.ExitCode.HasValue && script.ExitCode.Value == 0 && string.IsNullOrEmpty(script.Result));
+
+ var eventType = isSuccess
+ ? Constants.EventTypes.ScriptCompleted
+ : Constants.EventTypes.ScriptFailed;
+
+ var severity = isSuccess ? EventSeverity.Info : EventSeverity.Warning;
+
+ // Build human-readable message
+ string shortId = script.PolicyId.Length >= 8 ? script.PolicyId.Substring(0, 8) : script.PolicyId;
+ string message;
+ if (string.Equals(script.ScriptType, "remediation", StringComparison.OrdinalIgnoreCase))
+ {
+ var complianceStr = string.Equals(script.ComplianceResult, "True", StringComparison.OrdinalIgnoreCase)
+ ? "Compliant" : "Non-compliant";
+ message = $"Remediation {script.ScriptPart ?? "detection"} {shortId}: {complianceStr} (exit: {script.ExitCode ?? -1})";
+ }
+ else
+ {
+ message = $"Platform script {shortId}: {script.Result ?? "Unknown"} (exit: {script.ExitCode ?? -1})";
+ }
+
+ // Add stderr hint to message if present
+ if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
+ message += " - stderr present";
+
+ var data = new Dictionary
+ {
+ ["policyId"] = script.PolicyId,
+ ["scriptType"] = script.ScriptType ?? "platform",
+ ["exitCode"] = script.ExitCode ?? -1
+ };
+
+ if (!string.IsNullOrEmpty(script.RunContext))
+ data["runContext"] = script.RunContext;
+ if (!string.IsNullOrEmpty(script.Result))
+ data["result"] = script.Result;
+ if (!string.IsNullOrEmpty(script.Stdout) && script.Stdout.Trim().Length > 0)
+ data["stdout"] = script.Stdout;
+ if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
+ data["stderr"] = script.Stderr;
+ if (!string.IsNullOrEmpty(script.ComplianceResult))
+ data["complianceResult"] = script.ComplianceResult;
+ if (!string.IsNullOrEmpty(script.ScriptPart))
+ data["scriptPart"] = script.ScriptPart;
+
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = eventType,
+ Severity = severity,
+ Source = Constants.EventSources.IME,
+ Phase = EnrollmentPhase.Unknown,
+ Message = message,
+ Data = data
+ });
+
+ _logger.Info($"EnrollmentTracker: {message}");
+ }
+
+ private void HandleImeSessionChange(string changeType)
+ {
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "ime_session_change",
+ Severity = EventSeverity.Debug,
+ Source = "ImeLogTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"IME session change: {changeType}",
+ Data = new Dictionary { { "changeType", changeType ?? "" } }
+ });
+ }
+
+ private void HandleUserSessionCompleted()
+ {
+ _logger.Info("EnrollmentTracker: User session completed (detected from IME log)");
+ _stateData.ImePatternSeenUtc = DateTime.UtcNow;
+ RecordSignal("ime_pattern");
+
+ // Emit timeline event so admins can see exactly when IME finished its user part
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "ime_user_session_completed",
+ Severity = EventSeverity.Info,
+ Source = "ImeLogTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = "IME user session completed",
+ Data = new Dictionary { { "detectedAt", DateTime.UtcNow.ToString("o") } }
+ });
+
+ // User session completed successfully — cancel any pending ESP failure
+ CancelPendingEspFailure();
+
+ // Stop timers if running
+ _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _summaryTimerActive = false;
+ _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debugStateTimerActive = false;
+
+ // Device-only deployment (Self-Deploying or SkipUserStatusPage=true): skip Hello wait
+ // entirely — no interactive user session means Hello provisioning cannot complete.
+ bool isDeviceOnlySnap;
+ int? autopilotModeSnap;
+ bool? skipUserSnap;
+ lock (_stateLock)
+ {
+ isDeviceOnlySnap = IsDeviceOnlyDeployment;
+ autopilotModeSnap = _autopilotMode;
+ skipUserSnap = _skipUserStatusPage;
+ }
+
+ if (isDeviceOnlySnap)
+ {
+ _logger.Info($"EnrollmentTracker: Device-only deployment (autopilotMode={autopilotModeSnap}, skipUserStatusPage={skipUserSnap}) — skipping Hello wait, proceeding to completion");
+ TryEmitEnrollmentComplete("ime_pattern");
+ ShadowProcessTrigger("ime_user_session_completed");
+ return;
+ }
+
+ // Check if Windows Hello is configured but not yet completed
+ if (_espAndHelloTracker != null)
+ {
+ bool helloPolicyConfigured = _espAndHelloTracker.IsPolicyConfigured;
+ bool helloCompleted = _espAndHelloTracker.IsHelloCompleted;
+
+ if (helloPolicyConfigured && !helloCompleted)
+ {
+ // Hello is configured but not finished yet - DO NOT mark enrollment as complete
+ _logger.Info("EnrollmentTracker: Windows Hello policy is configured but provisioning has not completed yet.");
+ _logger.Info("EnrollmentTracker: Waiting for Hello provisioning to finish before marking enrollment as complete.");
+
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "waiting_for_hello",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.FinalizingSetup,
+ Message = "User apps completed - waiting for Windows Hello provisioning to finish",
+ ImmediateUpload = true
+ });
+
+ // Set flag so we know we're waiting
+ lock (_stateLock)
+ {
+ _isWaitingForHello = true;
+ _stateData.IsWaitingForHello = true;
+ _stateData.WaitingForHelloStartedUtc = DateTime.UtcNow;
+ _stateDirty = true;
+ }
+ _statePersistence.Save(_stateData); // Immediate persist — summary timer was just stopped
+
+ // Defense-in-depth: Ensure Hello wait timer is running.
+ // StartHelloWaitTimer() has internal guards (returns if timer already running,
+ // if Hello already completed, or if wizard already started). Safe to call multiple times.
+ _espAndHelloTracker?.StartHelloWaitTimer();
+
+ // Safety net: if Hello never resolves (timer chain fails, agent crash without restart),
+ // force enrollment_complete after 7 minutes instead of waiting for the 6h max lifetime timer.
+ _waitingForHelloSafetyTimer?.Dispose();
+ _waitingForHelloSafetyTimer = new Timer(
+ OnWaitingForHelloSafetyTimeout,
+ null,
+ TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds),
+ TimeSpan.FromMilliseconds(-1));
+
+ ShadowProcessTrigger("ime_user_session_completed");
+ return;
+ }
+ }
+
+ // ESP provisioning settle check: if ESP categories are still resolving in the registry,
+ // wait up to 30s for Windows to finalize the outcomes before completing.
+ // This catches cases where the agent started late and the ESP status hasn't settled yet.
+ if (_espAndHelloTracker != null)
+ {
+ var snapshot = _espAndHelloTracker.GetProvisioningSnapshot();
+ if (snapshot != null && snapshot.CategoriesSeen > 0 && !snapshot.AllResolved)
+ {
+ var unresolvedCategories = snapshot.CategoryOutcomes
+ .Where(kvp => kvp.Value == "in_progress")
+ .Select(kvp => kvp.Key)
+ .ToList();
+
+ _logger.Info($"EnrollmentTracker: ESP provisioning categories not yet resolved " +
+ $"({string.Join(", ", unresolvedCategories)}) — waiting up to {EspSettleTimeoutSeconds}s for settlement");
+
+ EmitImeTrackerEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_provisioning_settle_started",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"ESP provisioning settle wait started — {unresolvedCategories.Count} category(ies) unresolved: {string.Join(", ", unresolvedCategories)}",
+ Data = new Dictionary
+ {
+ { "unresolvedCategories", unresolvedCategories },
+ { "settleTimeoutSeconds", EspSettleTimeoutSeconds },
+ { "categoriesSeen", snapshot.CategoriesSeen },
+ { "categoriesResolved", snapshot.CategoriesResolved }
+ },
+ ImmediateUpload = true
+ });
+
+ lock (_stateLock)
+ {
+ _isWaitingForEspSettle = true;
+ _stateData.IsWaitingForEspSettle = true;
+ _stateData.WaitingForEspSettleStartedUtc = DateTime.UtcNow;
+ _stateDirty = true;
+ }
+ _statePersistence.Save(_stateData);
+
+ _waitingForEspSettleTimer?.Dispose();
+ _waitingForEspSettleTimer = new Timer(
+ OnEspSettleTimerExpired,
+ null,
+ TimeSpan.FromSeconds(EspSettleTimeoutSeconds),
+ TimeSpan.FromMilliseconds(-1));
+
+ ShadowProcessTrigger("ime_user_session_completed");
+ return;
+ }
+ }
+
+ TryEmitEnrollmentComplete("ime_pattern");
+
+ // Shadow state machine: track IME user session completed
+ ShadowProcessTrigger("ime_user_session_completed");
+ }
+
+ ///
+ /// Checks whether a real user profile exists in C:\Users\ (beyond system accounts).
+ /// Used to verify AccountSetup phase: during WhiteGlove pre-provisioning only defaultuser0
+ /// exists; in user-driven enrollment the signed-in user's profile is already created.
+ ///
+ private bool HasRealUserProfile()
+ {
+ try
+ {
+ var usersDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ // Navigate to the Users root (parent of the current profile)
+ usersDir = Path.GetDirectoryName(usersDir); // e.g. C:\Users
+ if (string.IsNullOrEmpty(usersDir) || !Directory.Exists(usersDir))
+ usersDir = @"C:\Users";
+
+ var excludedNames = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "defaultuser0", "defaultuser1", "Default", "Default User",
+ "Public", "All Users", "Administrator", "Guest"
+ };
+
+ foreach (var dir in Directory.GetDirectories(usersDir))
+ {
+ var name = Path.GetFileName(dir);
+ if (excludedNames.Contains(name))
+ continue;
+ if (name.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ _logger.Debug($"EnrollmentTracker: HasRealUserProfile found '{name}'");
+ return true;
+ }
+
+ return false;
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: HasRealUserProfile check failed: {ex.Message}");
+ return false; // Fail safe: treat as no real user (suppress AccountSetup)
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.cs
new file mode 100644
index 00000000..3e7a071e
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.cs
@@ -0,0 +1,814 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Win32;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
+{
+ ///
+ /// Central enrollment tracking orchestrator.
+ /// Collects consolidated device info events at startup and manages ImeLogTracker
+ /// for smart app installation tracking with strategic event emission.
+ ///
+ public partial class EnrollmentTracker : IDisposable
+ {
+ private readonly string _sessionId;
+ private readonly string _tenantId;
+ private readonly Action _emitEvent;
+ private readonly AgentLogger _logger;
+ private readonly string _imeLogFolder;
+ private readonly List _imeLogPatterns;
+ private readonly SystemSignals.EspAndHelloTracker _espAndHelloTracker;
+
+ private ImeLogTracker _imeLogTracker;
+ private Timer _summaryTimer;
+ private bool _summaryTimerActive;
+ private Timer _debugStateTimer;
+ private bool _debugStateTimerActive;
+
+ // ===== Thread-safety =====
+ // All mutable state fields below are accessed from multiple threads:
+ // - Timer callbacks (SummaryTimer, DebugStateTimer, safety timers) → ThreadPool
+ // - ImeLogTracker event handlers (HandleEspPhaseChanged, HandleAppStateChanged, etc.) → FileSystemWatcher thread
+ // - EspAndHelloTracker event handlers (OnHelloCompleted, OnFinalizingSetupPhaseTriggered, etc.) → COM thread
+ // - MonitoringService (NotifyDesktopArrived) → desktop detection thread
+ // - Task.Run (CollectDeviceInfo) → ThreadPool
+ //
+ // A single lock protects all shared fields. Lock sections contain ONLY field reads/writes —
+ // never _emitEvent, _logger, timer operations, or external callbacks. This ensures:
+ // - No deadlocks (no nested locking, no I/O under lock)
+ // - Minimal contention (microsecond-level critical sections)
+ // - Consistent multi-field reads (e.g., TryEmitEnrollmentComplete guard checks)
+ private readonly object _stateLock = new object();
+
+ private string _lastEspPhase; // Track last ESP phase to prevent duplicate events
+ private bool _hasAutoSwitchedToAppsPhase; // Track if we've already auto-switched to apps phase for current ESP phase
+ private string _enrollmentType = "v1"; // "v1" = Autopilot Classic/ESP, "v2" = Windows Device Preparation
+ private IEnrollmentFlowHandler _flowHandler = EnrollmentFlowFactory.FromWireFormat("v1"); // Kept in sync with _enrollmentType via SetEnrollmentType()
+ private bool _isWaitingForHello = false; // Track if we're waiting for Hello to complete before sending enrollment_complete
+ private readonly bool _isBootstrapMode; // Agent started via bootstrap token (pre-MDM)
+ private bool _sendTraceEvents = true; // Send Trace-severity events to backend for decision auditing
+
+ // TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
+ private string _lastShadowDiscrepancySignature;
+ private bool _enrollmentStartDeviceInfoCollected = false; // Re-collect enrollment-dependent info once at first ESP phase
+ private bool _finalDeviceInfoCollected = false; // Ensure final device info is emitted only once
+ private string _lastEmittedSummaryHash; // Track last emitted state-breakdown to avoid redundant summary events
+ private string _lastEmittedSummaryCompactHash; // Track completed+error counts only — changes here elevate severity to Info
+
+ // ESP failure handling (Phase 1)
+ private Timer _espFailureTimer;
+ private string _pendingEspFailureType;
+ private const int EspFailureGracePeriodSeconds = 60;
+ private static readonly HashSet RecoverableEspFailureTypes = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "ESPProgress_Timeout"
+ };
+
+ // Unified completion logic (Phase 3A)
+ private bool _espEverSeen;
+ private bool _espFinalExitSeen;
+ private bool _enrollmentCompleteEmitted;
+ private bool _desktopArrived;
+ private bool _isHybridJoin;
+ private readonly DateTime _agentStartTimeUtc = DateTime.UtcNow;
+
+ // Device info readiness — guards against race where completion signals arrive before CollectDeviceInfo finishes
+ private volatile bool _deviceInfoCollected;
+ private string _pendingCompletionSource;
+
+ // Device-Only ESP detection (Phase 3C)
+ private Timer _deviceOnlyEspTimer;
+ private const int DeviceOnlyEspTimerMinutes = 5;
+
+ // ESP configuration from registry (FirstSync\SkipUserStatusPage / SkipDeviceStatusPage)
+ // null = unknown (registry keys not present), true = skip, false = show
+ private bool? _skipUserStatusPage;
+ private bool? _skipDeviceStatusPage;
+
+ // Autopilot deployment mode: 0=UserDriven, 1/2/3+=not yet confirmed mapping, null=unknown
+ private int? _autopilotMode;
+ private bool IsSelfDeploying => _autopilotMode == 1;
+
+ // True when AAD join status shows a joined device with a real user email.
+ // Detected via CollectAadJoinStatus() in DeviceInfoCollector.
+ private bool _aadJoinedWithUser;
+ // Soft indicator: AAD userEmail matches a transient provisioning placeholder pattern
+ // (foouser@, autopilot@). These accounts appear during Autopilot pre-provisioning
+ // (WhiteGlove) and MUST NOT be treated as a real AAD-joined user.
+ private bool _fooUserDetected;
+
+ // Set to true the moment the Shell-Core Event-62407 "WhiteGlove_Success" handler fires.
+ // Distinct from (which is the
+ // fire-once guard inside CompleteWhiteGlove and covers any completion path). Feeds
+ // the WhiteGloveClassifier as the strongest positive signal.
+ private bool _shellCoreWhiteGloveFired;
+
+ // Signal-correlated WhiteGlove detection (Ebene 2.6):
+ // Feeds the bestehenden OnWhiteGloveCompleted() callback when the Shell-Core detector
+ // does not match but the session shows the WhiteGlove sealing pattern (all Device-apps done,
+ // DeviceSetup category succeeded, system reboot observed, AAD Not Joined, no AccountSetup
+ // progress, no desktop arrival). Requires ≥10 min stability to avoid false-positives.
+ private DateTime? _signalCorrelatedWhiteGloveStableSince;
+ private bool _signalCorrelatedWhiteGloveTriggered;
+ private bool _systemRebootObserved;
+ private const int SignalCorrelatedWhiteGloveStabilityMinutes = 10;
+
+ ///
+ /// Called by MonitoringService when a system_reboot_detected event is emitted
+ /// (agent restart after an unclean exit / machine reboot). Used as an input signal
+ /// for the signal-correlated WhiteGlove detection heuristic.
+ ///
+ public void NotifySystemRebootDetected()
+ {
+ lock (_stateLock)
+ {
+ _systemRebootObserved = true;
+ }
+ }
+
+ ///
+ /// Called after a successful RegisterSession. Compares the backend's authoritative
+ /// validator verdict against the agent's registry-based enrollment-type detection
+ /// and, on mismatch, emits an enrollment_type_mismatch warning event and
+ /// switches the active flow handler to the backend's verdict.
+ ///
+ /// and
+ /// are treated as type-neutral (no claim about Classic vs DevPrep) and do not trigger a switch.
+ ///
+ public void ReconcileWithBackendValidator(ValidatorType validatedBy)
+ {
+ EnrollmentType backendType = validatedBy switch
+ {
+ ValidatorType.AutopilotV1 => EnrollmentType.Classic,
+ ValidatorType.DeviceAssociation => EnrollmentType.DevicePreparation,
+ _ => EnrollmentType.Unknown
+ };
+
+ if (backendType == EnrollmentType.Unknown)
+ return;
+
+ string currentWire;
+ lock (_stateLock) { currentWire = _enrollmentType; }
+ var currentType = EnrollmentTypeExtensions.FromWireFormat(currentWire);
+
+ if (currentType == backendType)
+ {
+ _logger.Verbose($"EnrollmentTracker: backend validator '{validatedBy}' matches registry-detected enrollment type '{currentWire}'.");
+ return;
+ }
+
+ var newWire = backendType.ToWireFormat();
+ _logger.Warning($"EnrollmentTracker: enrollment type mismatch — registry='{currentWire}', backend='{newWire}' (validator={validatedBy}). Switching to backend verdict.");
+
+ lock (_stateLock)
+ {
+ _enrollmentType = newWire;
+ _flowHandler = EnrollmentFlowFactory.Create(backendType);
+ _stateData.EnrollmentType = newWire;
+ _stateDirty = true;
+ }
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "enrollment_type_mismatch",
+ Severity = EventSeverity.Warning,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Registry-detected enrollment type '{currentWire}' differs from backend validator verdict '{newWire}' ({validatedBy}). Using backend verdict.",
+ Data = new Dictionary
+ {
+ { "registryEnrollmentType", currentWire },
+ { "backendEnrollmentType", newWire },
+ { "backendValidator", validatedBy.ToString() }
+ }
+ });
+ }
+
+ // True when no interactive user session is expected.
+ // Self-Deploying (AutopilotMode=1) is always device-only.
+ // SkipUserStatusPage=true alone does NOT mean device-only — admins commonly skip
+ // the user ESP page in user-driven enrollments. Only classify as device-only when
+ // SkipUserStatusPage=true AND no user has AAD-joined (no aad_join_status with userEmail).
+ private bool IsDeviceOnlyDeployment => IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser);
+
+ ///
+ /// Creates a read-only snapshot of current state for the shadow state machine.
+ /// Must be called OUTSIDE _stateLock (takes its own lock internally).
+ ///
+ private CompletionContext SnapshotCompletionContext()
+ {
+ lock (_stateLock)
+ {
+ return new CompletionContext
+ {
+ EnrollmentType = _enrollmentType,
+ IsHybridJoin = _isHybridJoin,
+ AutopilotMode = _autopilotMode,
+ SkipUserStatusPage = _skipUserStatusPage,
+ AadJoinedWithUser = _aadJoinedWithUser,
+ EspEverSeen = _espEverSeen,
+ EspFinalExitSeen = _espFinalExitSeen,
+ DesktopArrived = _desktopArrived,
+ LastEspPhase = _lastEspPhase,
+ DeviceInfoCollected = _deviceInfoCollected,
+ HasHelloTracker = _espAndHelloTracker != null,
+ IsHelloCompleted = _espAndHelloTracker?.IsHelloCompleted ?? false,
+ IsHelloPolicyConfigured = _espAndHelloTracker?.IsPolicyConfigured ?? false,
+ HasUnresolvedEspCategories = false, // Will be set by caller when available
+ HasAccountSetupActivity = _espAndHelloTracker?.HasAccountSetupActivity ?? false,
+ WhiteGloveStartDetected = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false,
+ HasSaveWhiteGloveSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false,
+ ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired,
+ IsFooUserDetected = _fooUserDetected,
+ AgentStartTimeUtc = _agentStartTimeUtc,
+ EspFinalExitUtc = _stateData.EspFinalExitUtc,
+ ImePatternSeenUtc = _stateData.ImePatternSeenUtc
+ };
+ }
+ }
+
+ ///
+ /// Fires the shadow state machine trigger and compares the result with the actual state.
+ /// Logs discrepancies as warnings. Does NOT change actual behavior.
+ /// Also dual-writes the CompletionState to _stateData for persistence.
+ ///
+ private void ShadowProcessTrigger(string trigger, CompletionContext ctx = null)
+ {
+ try
+ {
+ ctx = ctx ?? SnapshotCompletionContext();
+ var result = _completionSm.ProcessTrigger(trigger, ctx);
+
+ // Dual-write: persist the explicit state alongside the boolean flags
+ lock (_stateLock)
+ {
+ _stateData.CompletionState = _completionSm.CurrentState.ToString();
+ }
+
+ // Compare terminal state: shadow SM reached terminal vs actual _enrollmentCompleteEmitted.
+ // Both sides must use the same definition of "terminal": the actual flag is set for
+ // enrollment_complete, enrollment_failed AND the WG-redirect path — so shadow must match
+ // that by accepting Completed, Failed and WhiteGloveCompleted as terminal states.
+ bool actualTerminal;
+ lock (_stateLock) { actualTerminal = _enrollmentCompleteEmitted; }
+ bool shadowTerminal = _completionSm.CurrentState.IsTerminal();
+
+ if (actualTerminal != shadowTerminal)
+ {
+ _logger.Warning($"EnrollmentTracker [SHADOW DISCREPANCY]: trigger='{trigger}', " +
+ $"shadowState={_completionSm.CurrentState}, actualEmitted={actualTerminal}, " +
+ $"shadowCompleted={shadowTerminal}");
+
+ // TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
+ // Emit a backend event (throttled to 1x per unique signature per session) so we can query
+ // mismatches without relying on client-side logs. Severity=Warning so it ships independent of SendTraceEvents.
+ EmitShadowDiscrepancyEvent(trigger, ctx, actualTerminal, shadowTerminal);
+ }
+ else if (result.Transitioned)
+ {
+ _logger.Verbose($"EnrollmentTracker [SHADOW]: trigger='{trigger}', " +
+ $"{result.PreviousState} -> {result.NewState}");
+ }
+ }
+ catch (Exception ex)
+ {
+ // Shadow mode must never crash the agent
+ _logger.Debug($"EnrollmentTracker [SHADOW ERROR]: trigger='{trigger}', error={ex.Message}");
+ }
+ }
+
+ // Safety-net timer for waiting_for_hello state
+ private Timer _waitingForHelloSafetyTimer;
+ private const int WaitingForHelloSafetyTimeoutSeconds = 420; // 7 min — longer than Hello chain (330s)
+
+ // ESP provisioning settle wait: when IME pattern fires but ESP categories are still unresolved,
+ // wait up to 30s for Windows to finalize the ESP provisioning status in the registry.
+ private bool _isWaitingForEspSettle = false;
+ private Timer _waitingForEspSettleTimer;
+ private const int EspSettleTimeoutSeconds = 30;
+
+ // Safety-net timer for device-only ESP completion (SkipUserStatusPage=true)
+ private Timer _deviceOnlyCompletionSafetyTimer;
+ private const int DeviceOnlyCompletionSafetyTimeoutSeconds = 420; // 7 min — same as Hello safety
+
+ // State persistence for crash recovery
+ private readonly EnrollmentStatePersistence _statePersistence;
+ private EnrollmentStateData _stateData = new EnrollmentStateData();
+ private bool _stateDirty;
+
+ // Completion check throttling (max 1x/min per source)
+ private readonly Dictionary _lastCompletionCheckBySource = new Dictionary();
+
+ // Shadow state machine: runs in parallel with existing logic for behavioral verification.
+ // Logs discrepancies but does NOT affect actual enrollment completion.
+ private readonly CompletionStateMachine _completionSm = new CompletionStateMachine();
+
+ // Default IME log folder
+ private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs";
+
+ private Telemetry.DeviceInfo.DeviceInfoCollector _deviceInfoCollector;
+
+ // ConfigMgr (SCCM) co-management detection — fire-once guard
+ private bool _configMgrDetected;
+
+ ///
+ /// Detects whether the Autopilot profile indicates Hybrid Azure AD Join
+ /// by reading CloudAssignedDomainJoinMethod from the AutopilotPolicyCache registry key.
+ /// Safe to call before EnrollmentTracker is instantiated.
+ /// Returns true if CloudAssignedDomainJoinMethod == 1.
+ ///
+ public static bool DetectHybridJoinStatic()
+ {
+ try
+ {
+ using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"))
+ {
+ if (key != null)
+ {
+ var domainJoinMethod = key.GetValue("CloudAssignedDomainJoinMethod")?.ToString();
+ return domainJoinMethod == "1";
+ }
+ }
+ }
+ catch { }
+
+ return false;
+ }
+
+ ///
+ /// Detects whether this is an Autopilot v1 (Classic ESP) or v2 (Windows Device Preparation) enrollment
+ /// by reading the Autopilot registry keys. Safe to call before EnrollmentTracker is instantiated.
+ /// Returns "v2" if WDP indicators are present, "v1" otherwise.
+ ///
+ public static string DetectEnrollmentTypeStatic()
+ {
+ try
+ {
+ using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings"))
+ {
+ if (key != null)
+ {
+ // CloudAssignedDeviceRegistration=2 signals WDP provisioning flow
+ var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString();
+ if (deviceReg == "2")
+ return "v2";
+
+ // CloudAssignedEspEnabled=0 means no ESP, characteristic of WDP
+ var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString();
+ if (espEnabled == "0")
+ return "v2";
+ }
+ }
+ }
+ catch { }
+
+ return "v1";
+ }
+
+ public EnrollmentTracker(
+ string sessionId,
+ string tenantId,
+ Action emitEvent,
+ AgentLogger logger,
+ List imeLogPatterns,
+ string imeLogFolderOverride = null,
+ bool simulationMode = false,
+ double speedFactor = 50,
+ string imeMatchLogPath = null,
+ SystemSignals.EspAndHelloTracker espAndHelloTracker = null,
+ bool isBootstrapMode = false,
+ bool sendTraceEvents = true)
+ {
+ _isBootstrapMode = isBootstrapMode;
+ _sendTraceEvents = sendTraceEvents;
+ _isHybridJoin = DetectHybridJoinStatic();
+ _sessionId = sessionId;
+ _tenantId = tenantId;
+ _emitEvent = emitEvent;
+ _logger = logger;
+ _imeLogPatterns = imeLogPatterns ?? new List();
+ _imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder;
+ _espAndHelloTracker = espAndHelloTracker;
+ _deviceInfoCollector = new Telemetry.DeviceInfo.DeviceInfoCollector(sessionId, tenantId, emitEvent, logger);
+
+ // State persistence for crash recovery
+ var stateDirectory = @"%ProgramData%\AutopilotMonitor\State";
+ _statePersistence = new EnrollmentStatePersistence(stateDirectory, _logger);
+
+ // Create ImeLogTracker with state persistence directory
+ _imeLogTracker = new ImeLogTracker(_imeLogFolder, _imeLogPatterns, _logger, matchLogPath: imeMatchLogPath, stateDirectory: stateDirectory);
+ _imeLogTracker.SimulationMode = simulationMode;
+ _imeLogTracker.SpeedFactor = speedFactor;
+
+ // Wire up callbacks
+ _imeLogTracker.OnEspPhaseChanged = HandleEspPhaseChanged;
+ _imeLogTracker.OnImeAgentVersion = HandleImeAgentVersion;
+ _imeLogTracker.OnImeStarted = HandleImeStarted;
+ _imeLogTracker.OnAppStateChanged = HandleAppStateChanged;
+ _imeLogTracker.OnPoliciesDiscovered = HandlePoliciesDiscovered;
+ _imeLogTracker.OnAllAppsCompleted = HandleAllAppsCompleted;
+ _imeLogTracker.OnUserSessionCompleted = HandleUserSessionCompleted;
+ _imeLogTracker.OnImeSessionChange = HandleImeSessionChange;
+ _imeLogTracker.OnDoTelemetryReceived = HandleDoTelemetryReceived;
+ _imeLogTracker.OnScriptCompleted = HandleScriptCompleted;
+
+ // Subscribe to EspAndHelloTracker completion event if available
+ if (_espAndHelloTracker != null)
+ {
+ _espAndHelloTracker.HelloCompleted += OnHelloCompleted;
+ _espAndHelloTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
+ _espAndHelloTracker.WhiteGloveCompleted += OnWhiteGloveCompleted;
+ _espAndHelloTracker.EspFailureDetected += OnEspFailureDetected;
+ _espAndHelloTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
+
+ // Backfill recent ESP exit events that may have fired before we subscribed.
+ // The EspAndHelloTracker is started by CollectorCoordinator before the EnrollmentTracker
+ // is created, so esp_exiting events detected during that window are lost. Backfill recovers them.
+ _espAndHelloTracker.BackfillRecentEspExitEvents();
+ }
+ }
+
+ ///
+ /// Updates the SendTraceEvents setting from remote config at runtime.
+ ///
+ public void UpdateSendTraceEvents(bool value) => _sendTraceEvents = value;
+
+ ///
+ /// Emits a Trace-severity event that captures an agent decision for backend-side troubleshooting.
+ /// Always logged locally; only sent to the backend when SendTraceEvents is enabled.
+ ///
+ private void EmitTraceEvent(string decision, string reason, Dictionary context = null)
+ {
+ var data = new Dictionary
+ {
+ { "decision", decision },
+ { "reason", reason }
+ };
+ if (context != null)
+ {
+ foreach (var kvp in context)
+ data[kvp.Key] = kvp.Value;
+ }
+
+ _logger.Trace($"EnrollmentTracker: {decision} — {reason}");
+
+ if (_sendTraceEvents)
+ {
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "agent_trace",
+ Severity = EventSeverity.Trace,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"{decision}: {reason}",
+ Data = data
+ });
+ }
+ }
+
+ // TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
+ ///
+ /// Emits a shadow_discrepancy event when the shadow CompletionStateMachine disagrees
+ /// with the actual _enrollmentCompleteEmitted flag. Throttled to one event per unique
+ /// {trigger|shadowState|actualEmitted} signature per session to avoid flooding the backend
+ /// while still surfacing every distinct divergence constellation.
+ ///
+ private void EmitShadowDiscrepancyEvent(string trigger, CompletionContext ctx, bool actualTerminal, bool shadowTerminal)
+ {
+ var shadowState = _completionSm.CurrentState.ToString();
+ var signature = $"{trigger}|{shadowState}|{actualTerminal}";
+ if (signature == _lastShadowDiscrepancySignature)
+ return;
+ _lastShadowDiscrepancySignature = signature;
+
+ var data = new Dictionary
+ {
+ { "trigger", trigger ?? "(null)" },
+ { "shadowState", shadowState },
+ { "actualEmitted", actualTerminal },
+ { "shadowCompleted", shadowTerminal },
+ { "enrollmentType", ctx?.EnrollmentType },
+ { "autopilotMode", ctx?.AutopilotMode },
+ { "skipUserStatusPage", ctx?.SkipUserStatusPage },
+ { "aadJoinedWithUser", ctx?.AadJoinedWithUser ?? false },
+ { "isHybridJoin", ctx?.IsHybridJoin ?? false },
+ { "isDeviceOnly", ctx?.IsDeviceOnly ?? false },
+ { "espEverSeen", ctx?.EspEverSeen ?? false },
+ { "espFinalExitSeen", ctx?.EspFinalExitSeen ?? false },
+ { "desktopArrived", ctx?.DesktopArrived ?? false },
+ { "lastEspPhase", ctx?.LastEspPhase },
+ { "hasHelloTracker", ctx?.HasHelloTracker ?? false },
+ { "isHelloCompleted", ctx?.IsHelloCompleted ?? false },
+ { "isHelloPolicyConfigured", ctx?.IsHelloPolicyConfigured ?? false },
+ { "hasUnresolvedEspCategories", ctx?.HasUnresolvedEspCategories ?? false }
+ };
+
+ try
+ {
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = Constants.EventTypes.ShadowDiscrepancy,
+ Severity = EventSeverity.Warning,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Shadow SM mismatch: trigger={trigger}, shadow={shadowState}, emitted={actualTerminal}",
+ Data = data
+ });
+ }
+ catch (Exception ex)
+ {
+ // Diagnostic event must never crash the agent
+ _logger.Debug($"EnrollmentTracker [SHADOW EVENT ERROR]: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Starts the enrollment tracker: collects device info and starts IME log tracking.
+ ///
+ public void Start()
+ {
+ _logger.Info("EnrollmentTracker: starting");
+
+ // Load persisted state for crash recovery
+ LoadState();
+
+ // Start IME log tracking immediately — this is the critical enrollment watcher.
+ // Device info collection (WMI + registry) runs in the background so it doesn't
+ // delay the ImeLogTracker from catching early enrollment events.
+ _imeLogTracker.Start();
+
+ // Collect and emit device info asynchronously (3 WMI queries + 5+ registry reads)
+ Task.Run(() =>
+ {
+ try
+ {
+ CollectDeviceInfo();
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: background device info collection failed: {ex.Message}");
+ }
+ });
+
+ // Check for ConfigMgr co-management client (separate task, must not delay device info)
+ Task.Run(() =>
+ {
+ try { CheckConfigMgrClient("startup"); }
+ catch (Exception ex) { _logger.Debug($"EnrollmentTracker: startup ConfigMgr check failed: {ex.Message}"); }
+ });
+
+ // TODO: Überdenken ob ein 30s timer hier wirklich immer gut ist, hab einen Fall gesehen mit
+ // laaanger Wartephase in WhiteGlove weil ein 24H2 feature update reinkam und installiert wurde
+
+ // Start periodic summary timer (30s, starts when app tracking begins)
+ _summaryTimer = new Timer(SummaryTimerCallback, null, Timeout.Infinite, Timeout.Infinite);
+ _debugStateTimer = new Timer(DebugStateTimerCallback, null, Timeout.Infinite, Timeout.Infinite);
+
+ _logger.Info("EnrollmentTracker: started");
+ }
+
+ ///
+ /// Stops the enrollment tracker
+ ///
+ public void Stop()
+ {
+ _logger.Info("EnrollmentTracker: stopping");
+
+ _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _imeLogTracker?.Stop();
+
+ _logger.Info("EnrollmentTracker: stopped");
+ }
+
+ ///
+ /// Updates IME log patterns (hot-reload from config change)
+ ///
+ public void UpdatePatterns(List newPatterns)
+ {
+ if (newPatterns != null)
+ {
+ _logger.Info("EnrollmentTracker: updating IME log patterns (hot-reload)");
+ _imeLogTracker?.CompilePatterns(newPatterns);
+ }
+ }
+
+ ///
+ /// Access to the ImeLogTracker (for simulator to reference package states)
+ ///
+ public ImeLogTracker ImeTracker => _imeLogTracker;
+
+ // ===== Device Info Collection =====
+
+ private void CollectDeviceInfo()
+ {
+ var result = _deviceInfoCollector.CollectAll();
+
+ bool wasDeviceOnly;
+ lock (_stateLock)
+ {
+ wasDeviceOnly = IsDeviceOnlyDeployment;
+ _enrollmentType = result.enrollmentType;
+ _flowHandler = EnrollmentFlowFactory.FromWireFormat(result.enrollmentType);
+ _skipUserStatusPage = result.skipUserStatusPage;
+ _skipDeviceStatusPage = result.skipDeviceStatusPage;
+ _autopilotMode = result.autopilotMode;
+ _aadJoinedWithUser = result.hasAadJoinedUser;
+ _fooUserDetected = result.isFooUserDetected;
+ _stateData.EnrollmentType = _enrollmentType;
+ _stateData.SkipUserStatusPage = _skipUserStatusPage;
+ _stateData.SkipDeviceStatusPage = _skipDeviceStatusPage;
+ _stateData.AutopilotMode = _autopilotMode;
+ _stateData.AadJoinedWithUser = _aadJoinedWithUser;
+ _stateData.FooUserDetected = _fooUserDetected;
+ _stateData.IsHybridJoin = _isHybridJoin;
+ _stateDirty = true;
+ }
+
+ // Snapshot values under lock for trace events (emitted outside lock)
+ bool isDeviceOnly, isSelfDeploying, aadJoinedWithUser;
+ int? autopilotMode;
+ bool? skipUserStatusPage;
+ string enrollmentType;
+ lock (_stateLock)
+ {
+ isDeviceOnly = IsDeviceOnlyDeployment;
+ isSelfDeploying = IsSelfDeploying;
+ aadJoinedWithUser = _aadJoinedWithUser;
+ autopilotMode = _autopilotMode;
+ skipUserStatusPage = _skipUserStatusPage;
+ enrollmentType = _enrollmentType;
+ }
+
+ _logger.Info($"EnrollmentTracker: enrollment type detected: {enrollmentType ?? "unknown"} " +
+ $"(autopilotMode={autopilotMode}, skipUserStatusPage={skipUserStatusPage}, skipDeviceStatusPage={result.skipDeviceStatusPage}, aadJoinedWithUser={aadJoinedWithUser})");
+
+ // Emit trace when AAD join with user is detected and reclassifies away from device-only
+ if (aadJoinedWithUser && wasDeviceOnly && !isDeviceOnly)
+ {
+ EmitTraceEvent("user_session_detected_via_aad_join",
+ "AAD join with user detected — user session active, using standard completion paths",
+ new Dictionary
+ {
+ { "autopilotMode", autopilotMode },
+ { "skipUserStatusPage", skipUserStatusPage },
+ { "aadJoinedWithUser", true },
+ { "isDeviceOnlyDeployment", false }
+ });
+ }
+
+ if (isDeviceOnly)
+ {
+ var reason = isSelfDeploying
+ ? "AutopilotMode=1 (Self-Deploying)"
+ : $"SkipUserStatusPage=true (autopilotMode={autopilotMode})";
+ EmitTraceEvent("device_only_deployment_detected",
+ $"{reason} — Hello guard will be bypassed, provisioning status used as completion signal",
+ new Dictionary
+ {
+ { "autopilotMode", autopilotMode },
+ { "skipUserStatusPage", skipUserStatusPage },
+ { "enrollmentType", enrollmentType },
+ { "aadJoinedWithUser", aadJoinedWithUser },
+ { "isSelfDeploying", isSelfDeploying },
+ { "isDeviceOnlyDeployment", true }
+ });
+ }
+
+ // Mark device info as collected and re-evaluate any deferred completion signal
+ _deviceInfoCollected = true;
+
+ string pendingSource;
+ lock (_stateLock) { pendingSource = _pendingCompletionSource; _pendingCompletionSource = null; }
+ if (pendingSource != null)
+ {
+ _logger.Info($"EnrollmentTracker: re-evaluating deferred completion signal '{pendingSource}' now that device info is available " +
+ $"(isDeviceOnly={isDeviceOnly}, autopilotMode={autopilotMode}, skipUserStatusPage={skipUserStatusPage})");
+ if (pendingSource == "device_setup_provisioning_complete")
+ OnDeviceSetupProvisioningComplete(this, EventArgs.Empty);
+ else
+ TryEmitEnrollmentComplete(pendingSource);
+ }
+
+ // Shadow state machine: track device info collected
+ ShadowProcessTrigger("device_info_collected");
+ }
+
+ ///
+ /// Collects device info that may change during enrollment (e.g., BitLocker enabled via policy).
+ /// Called at enrollment complete to capture final state.
+ ///
+ private void CollectDeviceInfoAtEnd()
+ => _deviceInfoCollector.CollectAtEnd();
+
+ ///
+ /// Checks for ConfigMgr (SCCM) co-management client presence on the device.
+ /// Fire-once: emits configmgr_client_detected event on first detection only.
+ /// Called at startup, DeviceSetup, and AccountSetup — always via Task.Run to avoid blocking.
+ ///
+ private void CheckConfigMgrClient(string trigger)
+ {
+ if (_configMgrDetected) return;
+
+ try
+ {
+ bool registryFound = false;
+ bool directoryFound = false;
+ string ccmVersion = null;
+ string siteCode = null;
+ string serviceState = "not_found";
+
+ // 1. Registry: HKLM\SOFTWARE\Microsoft\CCM
+ using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\CCM"))
+ {
+ if (key != null)
+ registryFound = true;
+ }
+
+ // Version from CCMSetup
+ using (var setupKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\CCMSetup"))
+ ccmVersion = setupKey?.GetValue("LastValidVersion")?.ToString();
+
+ // SiteCode from SMS Mobile Client
+ using (var smsKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\SMS\Mobile Client"))
+ siteCode = smsKey?.GetValue("AssignedSiteCode")?.ToString();
+
+ // 2. Directory: C:\Windows\CCM
+ directoryFound = Directory.Exists(@"C:\Windows\CCM");
+
+ // 3. Service: CcmExec (via ServiceController, native .NET Framework 4.8)
+ try
+ {
+ using (var sc = new System.ServiceProcess.ServiceController("CcmExec"))
+ serviceState = sc.Status.ToString();
+ }
+ catch (InvalidOperationException) { } // service not installed
+
+ // Compute confidence score — directory alone is too weak (could be remnant)
+ int confidence = 0;
+ if (directoryFound) confidence += 10;
+ if (registryFound) confidence += 20;
+ if (ccmVersion != null) confidence += 15;
+ if (siteCode != null) confidence += 15;
+ if (serviceState != "not_found") confidence += 20;
+ if (serviceState == "Running") confidence += 20;
+
+ // Nothing found — no event
+ if (confidence == 0)
+ return;
+
+ _configMgrDetected = true;
+
+ var versionSuffix = ccmVersion != null ? $" (v{ccmVersion})" : "";
+ var siteSuffix = siteCode != null ? $", site {siteCode}" : "";
+ var confidenceLabel = confidence >= 50 ? "high" : "low";
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "configmgr_client_detected",
+ Severity = EventSeverity.Info,
+ Source = "Agent",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"ConfigMgr (SCCM) client detected ({confidenceLabel} confidence: {confidence}/100) — co-managed device{versionSuffix}{siteSuffix}",
+ Data = new Dictionary
+ {
+ { "ccmRegistryFound", registryFound },
+ { "ccmDirectoryFound", directoryFound },
+ { "ccmServiceState", serviceState },
+ { "ccmVersion", ccmVersion ?? "unknown" },
+ { "siteCode", siteCode ?? "unknown" },
+ { "confidenceScore", confidence },
+ { "detectedAt", trigger }
+ }
+ });
+
+ _logger.Info($"ConfigMgr client detected (trigger={trigger}, confidence={confidence}, service={serviceState}, version={ccmVersion ?? "?"})");
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"EnrollmentTracker: ConfigMgr check failed: {ex.Message}");
+ }
+ }
+
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs
new file mode 100644
index 00000000..b14bbf1a
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs
@@ -0,0 +1,11 @@
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
+{
+ public sealed class ClassicAutopilotFlow : IEnrollmentFlowHandler
+ {
+ public EnrollmentType SupportedType => EnrollmentType.Classic;
+ public bool TracksEspPhases => true;
+ public bool AppliesEspGateOnDesktopArrival => true;
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs
new file mode 100644
index 00000000..885760ea
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs
@@ -0,0 +1,11 @@
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
+{
+ public sealed class DevicePreparationFlow : IEnrollmentFlowHandler
+ {
+ public EnrollmentType SupportedType => EnrollmentType.DevicePreparation;
+ public bool TracksEspPhases => false;
+ public bool AppliesEspGateOnDesktopArrival => false;
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs
new file mode 100644
index 00000000..f5cc018b
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs
@@ -0,0 +1,21 @@
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
+{
+ public static class EnrollmentFlowFactory
+ {
+ ///
+ /// Returns the flow handler for the given enrollment type.
+ /// falls back to the Classic flow
+ /// (safe default — Classic is the long-standing behavior).
+ ///
+ public static IEnrollmentFlowHandler Create(EnrollmentType type) => type switch
+ {
+ EnrollmentType.DevicePreparation => new DevicePreparationFlow(),
+ _ => new ClassicAutopilotFlow()
+ };
+
+ public static IEnrollmentFlowHandler FromWireFormat(string wireFormat)
+ => Create(EnrollmentTypeExtensions.FromWireFormat(wireFormat));
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs
new file mode 100644
index 00000000..46feed49
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs
@@ -0,0 +1,30 @@
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
+{
+ ///
+ /// Encapsulates the policy decisions that differ between the Classic Autopilot (v1)
+ /// flow and the Windows Autopilot Device Preparation (v2) flow.
+ ///
+ /// Today the flow handler only owns policy bits; tracker ownership remains in
+ /// CollectorCoordinator. When DevPrep-specific trackers land, they will
+ /// move into the corresponding flow handler.
+ ///
+ public interface IEnrollmentFlowHandler
+ {
+ EnrollmentType SupportedType { get; }
+
+ ///
+ /// True when the flow relies on ESP-phase signals for progress and completion.
+ /// Classic: true. DevPrep (WDP): false (no ESP exists).
+ ///
+ bool TracksEspPhases { get; }
+
+ ///
+ /// True when completion triggered by desktop-arrival / desktop-hello must be
+ /// blocked while ESP has been observed but has not reached its final exit.
+ /// Classic: true (AccountSetup may still be running). DevPrep: false.
+ ///
+ bool AppliesEspGateOnDesktopArrival { get; }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageState.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageState.cs
new file mode 100644
index 00000000..a1c336a3
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageState.cs
@@ -0,0 +1,548 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
+{
+ ///
+ /// Installation state of an app package during enrollment.
+ /// Order matters: states are compared numerically for upgrade-only transitions.
+ ///
+ public enum AppInstallationState
+ {
+ Unknown = 0,
+ NotInstalled = 1,
+ InProgress = 2,
+ Downloading = 3,
+ Installing = 4,
+ Installed = 5,
+ Skipped = 6,
+ Postponed = 7,
+ Error = 8
+ }
+
+ ///
+ /// How the app should run
+ ///
+ public enum AppRunAs
+ {
+ Unknown = -1,
+ User = 0,
+ System = 1
+ }
+
+ ///
+ /// App install intent from Intune policy
+ ///
+ public enum AppIntent
+ {
+ Unknown = -1,
+ NotTargeted = 0,
+ Available = 1,
+ Install = 3,
+ Uninstall = 4
+ }
+
+ ///
+ /// How the app is targeted
+ ///
+ [Flags]
+ public enum AppTargeted
+ {
+ Unknown = 128,
+ Dependency = 0,
+ User = 1,
+ Device = 2
+ }
+
+ ///
+ /// Win32 app state values from IME
+ ///
+ public enum Win32AppState
+ {
+ Unknown = 0,
+ NotInstalled = 1,
+ InProgress = 2,
+ Completed = 3,
+ Error = 4
+ }
+
+ ///
+ /// Represents the state of a single app package during enrollment.
+ /// Adapted from EspOverlay PackageState with state transition protection.
+ ///
+ public class AppPackageState : IComparable
+ {
+ public static bool SortErrorsToTop = false;
+
+ public string Id { get; }
+ public int ListPos { get; }
+ public string Name { get; private set; }
+ public AppRunAs RunAs { get; private set; } = AppRunAs.Unknown;
+ public AppIntent Intent { get; private set; } = AppIntent.Unknown;
+ public AppTargeted Targeted { get; private set; } = AppTargeted.Unknown;
+ public HashSet DependsOn { get; private set; } = new HashSet();
+ public AppInstallationState InstallationState { get; private set; } = AppInstallationState.Unknown;
+ public long InstallationStateLastChangedTicks { get; private set; } = 0;
+ public bool DownloadingOrInstallingSeen { get; private set; } = false;
+ public int? ProgressPercent { get; private set; } = null;
+ public long BytesDownloaded { get; private set; } = 0;
+ public long BytesTotal { get; private set; } = 0;
+ public string ErrorPatternId { get; private set; }
+ public string ErrorDetail { get; private set; }
+ public string ErrorCode { get; private set; }
+
+ // Installer result codes (captured from lpExitCode / hResultFromWin32 log lines)
+ public string ExitCode { get; private set; }
+ public string HResultFromWin32 { get; private set; }
+
+ // App metadata (captured from IME log patterns — used by App Dashboard / reports)
+ /// Product version detected after install (e.g. "11.2.1787.0"). From ReportingManager DetectedIdentityVersion.
+ public string AppVersion { get; private set; }
+ /// App installer type: "Win32", "MSI", "WinGet", "Store", "LOB". Derived from [WinGetApp]/MSI markers.
+ public string AppType { get; private set; }
+ /// Install attempt number (1 = first try). Increments on each "Execute retry N" line per check-in cycle.
+ public int AttemptNumber { get; private set; }
+ /// Detection rule result: "Detected" or "NotDetected". From DetectionActionHandler log lines.
+ public string DetectionResult { get; private set; }
+
+ // Delivery Optimization telemetry (populated from [DO TEL] log entries)
+ public long DoFileSize { get; private set; }
+ public long DoTotalBytesDownloaded { get; private set; }
+ public long DoBytesFromPeers { get; private set; }
+ public int DoPercentPeerCaching { get; private set; }
+ public long DoBytesFromLanPeers { get; private set; }
+ public long DoBytesFromGroupPeers { get; private set; }
+ public long DoBytesFromInternetPeers { get; private set; }
+ public int DoDownloadMode { get; private set; } = -1;
+ public string DoDownloadDuration { get; private set; }
+ public long DoBytesFromHttp { get; private set; }
+ public bool HasDoTelemetry { get; private set; }
+
+ private static readonly Dictionary Win32StateMap =
+ new Dictionary
+ {
+ { Win32AppState.Unknown, AppInstallationState.Unknown },
+ { Win32AppState.InProgress, AppInstallationState.InProgress },
+ { Win32AppState.Completed, AppInstallationState.Installed },
+ { Win32AppState.Error, AppInstallationState.Error }
+ };
+
+ private static readonly Dictionary SortOrderOverrides =
+ new Dictionary
+ {
+ { AppInstallationState.Skipped, (int)AppInstallationState.Installed },
+ { AppInstallationState.Postponed, (int)AppInstallationState.Installed },
+ { AppInstallationState.Error, (int)AppInstallationState.Installed }
+ };
+
+ public AppPackageState(string id, int listPos)
+ {
+ Id = id;
+ ListPos = listPos;
+ }
+
+ ///
+ /// Restores an AppPackageState from persisted data (used on agent restart).
+ ///
+ internal static AppPackageState Restore(
+ string id, int listPos, string name,
+ AppRunAs runAs, AppIntent intent, AppTargeted targeted,
+ HashSet dependsOn,
+ AppInstallationState installationState, bool downloadingOrInstallingSeen,
+ int? progressPercent, long bytesDownloaded, long bytesTotal,
+ string errorPatternId = null, string errorDetail = null, string errorCode = null,
+ string exitCode = null, string hresultFromWin32 = null,
+ long doFileSize = 0, long doTotalBytesDownloaded = 0,
+ long doBytesFromPeers = 0, int doPercentPeerCaching = 0,
+ long doBytesFromLanPeers = 0, long doBytesFromGroupPeers = 0,
+ long doBytesFromInternetPeers = 0, int doDownloadMode = -1,
+ string doDownloadDuration = null, long doBytesFromHttp = 0,
+ bool hasDoTelemetry = false,
+ string appVersion = null, string appType = null,
+ int attemptNumber = 0, string detectionResult = null)
+ {
+ var pkg = new AppPackageState(id, listPos)
+ {
+ Name = name,
+ RunAs = runAs,
+ Intent = intent,
+ Targeted = targeted,
+ DependsOn = dependsOn ?? new HashSet(),
+ InstallationState = installationState,
+ DownloadingOrInstallingSeen = downloadingOrInstallingSeen,
+ ProgressPercent = progressPercent,
+ BytesDownloaded = bytesDownloaded,
+ BytesTotal = bytesTotal,
+ ErrorPatternId = errorPatternId,
+ ErrorDetail = errorDetail,
+ ErrorCode = errorCode,
+ ExitCode = exitCode,
+ HResultFromWin32 = hresultFromWin32,
+ InstallationStateLastChangedTicks = Stopwatch.GetTimestamp(),
+ DoFileSize = doFileSize,
+ DoTotalBytesDownloaded = doTotalBytesDownloaded,
+ DoBytesFromPeers = doBytesFromPeers,
+ DoPercentPeerCaching = doPercentPeerCaching,
+ DoBytesFromLanPeers = doBytesFromLanPeers,
+ DoBytesFromGroupPeers = doBytesFromGroupPeers,
+ DoBytesFromInternetPeers = doBytesFromInternetPeers,
+ DoDownloadMode = doDownloadMode,
+ DoDownloadDuration = doDownloadDuration,
+ DoBytesFromHttp = doBytesFromHttp,
+ HasDoTelemetry = hasDoTelemetry,
+ AppVersion = appVersion,
+ AppType = appType,
+ AttemptNumber = attemptNumber,
+ DetectionResult = detectionResult
+ };
+ return pkg;
+ }
+
+ ///
+ /// Updates the app name. Only updates if the new name is not a truncated version of the current name.
+ ///
+ public bool UpdateName(string newName)
+ {
+ if (string.IsNullOrEmpty(newName) || string.Equals(Name, newName))
+ return false;
+
+ // Avoid overwriting with a truncated version
+ if (Name != null && newName.Length > 0 && Name.StartsWith(newName))
+ return false;
+
+ Name = newName;
+ return true;
+ }
+
+ ///
+ /// Updates RunAs property
+ ///
+ public bool UpdateRunAs(AppRunAs newRunAs)
+ {
+ if (RunAs == newRunAs) return false;
+ RunAs = newRunAs;
+ return true;
+ }
+
+ ///
+ /// Updates Intent property
+ ///
+ public bool UpdateIntent(AppIntent newIntent)
+ {
+ if (Intent == newIntent) return false;
+ Intent = newIntent;
+ return true;
+ }
+
+ ///
+ /// Updates Targeted property
+ ///
+ public bool UpdateTargeted(AppTargeted newTargeted)
+ {
+ if (Targeted == newTargeted) return false;
+ Targeted = newTargeted;
+ return true;
+ }
+
+ ///
+ /// Updates DependsOn set
+ ///
+ public bool UpdateDependsOn(HashSet newDependsOn)
+ {
+ if (newDependsOn == null) newDependsOn = new HashSet();
+ if (DependsOn.SetEquals(newDependsOn)) return false;
+ DependsOn = newDependsOn;
+ return true;
+ }
+
+ ///
+ /// Sets the error context for the app (called when an error pattern is matched).
+ ///
+ public void SetErrorContext(string patternId, string detail, string errorCode = null)
+ {
+ ErrorPatternId = patternId;
+ ErrorDetail = detail;
+ if (!string.IsNullOrEmpty(errorCode))
+ ErrorCode = errorCode;
+ }
+
+ ///
+ /// Captures the installer exit code (lpExitCode) for the app.
+ ///
+ public void UpdateExitCode(string code)
+ {
+ ExitCode = code;
+ }
+
+ ///
+ /// Captures the Win32 HRESULT (hResultFromWin32) for the app.
+ ///
+ public void UpdateHResult(string code)
+ {
+ HResultFromWin32 = code;
+ }
+
+ ///
+ /// Updates the product version detected for this app (e.g. "11.2.1787.0").
+ /// Only applies non-empty values so a later detection without version cannot wipe it.
+ ///
+ public bool UpdateAppVersion(string version)
+ {
+ if (string.IsNullOrWhiteSpace(version)) return false;
+ if (AppVersion == version) return false;
+ AppVersion = version.Trim();
+ return true;
+ }
+
+ ///
+ /// Updates the app installer type. Only sets when current is empty OR
+ /// when the incoming value is more specific than the existing one
+ /// (e.g. "WinGet"/"MSI" override the "Win32" default).
+ ///
+ public bool UpdateAppType(string type)
+ {
+ if (string.IsNullOrWhiteSpace(type)) return false;
+ if (AppType == type) return false;
+ // Do not let a generic "Win32" downgrade a previously determined "WinGet"/"MSI".
+ if (!string.IsNullOrEmpty(AppType) && type == "Win32"
+ && (AppType == "WinGet" || AppType == "MSI" || AppType == "Store" || AppType == "LOB"))
+ return false;
+ AppType = type;
+ return true;
+ }
+
+ ///
+ /// Updates the install attempt number. Keeps the highest observed value
+ /// so it is monotonic across log batches.
+ ///
+ public bool UpdateAttemptNumber(int attempt)
+ {
+ if (attempt <= AttemptNumber) return false;
+ AttemptNumber = attempt;
+ return true;
+ }
+
+ ///
+ /// Updates the detection rule result ("Detected" / "NotDetected").
+ /// Applies any non-empty value so the latest detection wins.
+ ///
+ public bool UpdateDetectionResult(string result)
+ {
+ if (string.IsNullOrWhiteSpace(result)) return false;
+ if (DetectionResult == result) return false;
+ DetectionResult = result.Trim();
+ return true;
+ }
+
+ ///
+ /// Updates Delivery Optimization telemetry data for this app package.
+ /// Called when a [DO TEL] log entry is matched and linked to this app.
+ ///
+ public void UpdateDoTelemetry(long fileSize, long totalBytesDownloaded,
+ long bytesFromPeers, int percentPeerCaching,
+ long bytesFromLanPeers, long bytesFromGroupPeers, long bytesFromInternetPeers,
+ int downloadMode, string downloadDuration, long bytesFromHttp)
+ {
+ DoFileSize = fileSize;
+ DoTotalBytesDownloaded = totalBytesDownloaded;
+ DoBytesFromPeers = bytesFromPeers;
+ DoPercentPeerCaching = percentPeerCaching;
+ DoBytesFromLanPeers = bytesFromLanPeers;
+ DoBytesFromGroupPeers = bytesFromGroupPeers;
+ DoBytesFromInternetPeers = bytesFromInternetPeers;
+ DoDownloadMode = downloadMode;
+ DoDownloadDuration = downloadDuration;
+ DoBytesFromHttp = bytesFromHttp;
+ HasDoTelemetry = true;
+ }
+
+ ///
+ /// Updates the installation state with transition protection.
+ /// Returns true if the state actually changed.
+ ///
+ public bool UpdateState(AppInstallationState newState, int? newProgressPercent = null, bool upgradeOnly = false, long bytesDownloaded = 0, long bytesTotal = 0)
+ {
+ // upgradeOnly: only allow "higher" states (used by Win32AppState mapping)
+ if (upgradeOnly && newState < InstallationState)
+ return false;
+
+ // Cannot downgrade from Installed to Skipped or Postponed
+ if (InstallationState == AppInstallationState.Installed &&
+ (newState == AppInstallationState.Skipped || newState == AppInstallationState.Postponed))
+ return false;
+
+ // Postponed cannot go back to Downloading or lower active states
+ if (InstallationState == AppInstallationState.Postponed && newState <= AppInstallationState.Downloading)
+ return false;
+
+ // If "Installed" without ever seeing download/install -> auto-downgrade to Skipped
+ // This handles apps with "inverse" detection rules (e.g., uninstall packages marked as
+ // installed when old software is NOT detected)
+ if (newState == AppInstallationState.Installed && !DownloadingOrInstallingSeen)
+ newState = AppInstallationState.Skipped;
+
+ // Compute the effective progress percent that will be applied
+ var effectiveProgressPercent = (newState == AppInstallationState.Installed && newProgressPercent == null) ? 100 : newProgressPercent;
+ // Compute effective bytes: only consider new values if non-zero, otherwise keep current
+ var effectiveBytesDownloaded = (bytesDownloaded > 0 || bytesTotal > 0) ? bytesDownloaded : BytesDownloaded;
+ var effectiveBytesTotal = (bytesDownloaded > 0 || bytesTotal > 0) ? bytesTotal : BytesTotal;
+ // After WinGet completion fix: if Installed and total > 0 and downloaded < total, downloaded becomes total
+ if (newState == AppInstallationState.Installed && effectiveBytesTotal > 0 && effectiveBytesDownloaded < effectiveBytesTotal)
+ effectiveBytesDownloaded = effectiveBytesTotal;
+
+ // Skip if no actual change
+ if (newState == InstallationState && effectiveProgressPercent == ProgressPercent && effectiveBytesDownloaded == BytesDownloaded && effectiveBytesTotal == BytesTotal)
+ return false;
+
+ var oldState = InstallationState;
+ InstallationState = newState;
+ InstallationStateLastChangedTicks = Stopwatch.GetTimestamp();
+ DownloadingOrInstallingSeen |= (InstallationState >= AppInstallationState.Downloading &&
+ InstallationState <= AppInstallationState.Installing);
+ ProgressPercent = effectiveProgressPercent;
+ BytesDownloaded = effectiveBytesDownloaded;
+ BytesTotal = effectiveBytesTotal;
+
+ return true;
+ }
+
+ ///
+ /// Updates state from a Win32AppState string or integer string (e.g., "2" or "InProgress").
+ /// Uses upgrade-only mode to prevent "InProgress" from destroying more detailed states.
+ ///
+ public bool UpdateStateFromWin32AppState(string win32AppStateStringOrIntString)
+ {
+ Win32AppState win32State;
+ if (Enum.TryParse(win32AppStateStringOrIntString, true, out win32State))
+ return UpdateStateFromWin32AppState(win32State);
+ return false;
+ }
+
+ ///
+ /// Updates state from Win32AppState enum value.
+ ///
+ public bool UpdateStateFromWin32AppState(Win32AppState win32State)
+ {
+ AppInstallationState newState;
+ if (Win32StateMap.TryGetValue(win32State, out newState))
+ return UpdateState(newState, upgradeOnly: true);
+ return false;
+ }
+
+ ///
+ /// Whether this app requires installation (Install or Uninstall intent)
+ ///
+ public bool IsRequired => Intent == AppIntent.Install || Intent == AppIntent.Uninstall;
+
+ ///
+ /// Whether the app is currently being processed (InProgress, Downloading, or Installing)
+ ///
+ public bool IsActive => InstallationState >= AppInstallationState.InProgress &&
+ InstallationState <= AppInstallationState.Installing;
+
+ ///
+ /// Whether the app has reached a terminal state (Installed, Skipped, Postponed, Error)
+ ///
+ public bool IsCompleted => InstallationState >= AppInstallationState.Installed;
+
+ ///
+ /// Whether the app is in an error state
+ ///
+ public bool IsError => InstallationState == AppInstallationState.Error;
+
+ ///
+ /// Comparison for sorting: active apps first, then by state change time, then by list position.
+ ///
+ public int CompareTo(AppPackageState other)
+ {
+ // First: descending on sort order (active apps at top)
+ var result = -(SortOrder.CompareTo(other.SortOrder));
+ if (result == 0)
+ {
+ // Then: by installation state last changed
+ result = InstallationStateLastChangedTicks.CompareTo(other.InstallationStateLastChangedTicks);
+ }
+ if (result == 0)
+ {
+ // Then: by original position in policy list
+ result = ListPos.CompareTo(other.ListPos);
+ }
+ return result;
+ }
+
+ private int SortOrder
+ {
+ get
+ {
+ if (SortErrorsToTop && InstallationState == AppInstallationState.Error)
+ return -1;
+
+ int sortOrder;
+ if (SortOrderOverrides.TryGetValue(InstallationState, out sortOrder))
+ return sortOrder;
+
+ return (int)InstallationState;
+ }
+ }
+
+ ///
+ /// Returns a summary dictionary for event data
+ ///
+ public Dictionary ToEventData()
+ {
+ var data = new Dictionary
+ {
+ { "appId", Id },
+ { "appName", Name ?? Id },
+ { "state", InstallationState.ToString() },
+ { "intent", Intent.ToString() },
+ { "targeted", Targeted.ToString() },
+ { "runAs", RunAs.ToString() },
+ { "progressPercent", ProgressPercent ?? 0 },
+ { "bytesDownloaded", BytesDownloaded },
+ { "bytesTotal", BytesTotal },
+ { "isError", IsError },
+ { "isCompleted", IsCompleted }
+ };
+
+ if (!string.IsNullOrEmpty(ErrorPatternId))
+ data["errorPatternId"] = ErrorPatternId;
+ if (!string.IsNullOrEmpty(ErrorDetail))
+ data["errorDetail"] = ErrorDetail;
+ if (!string.IsNullOrEmpty(ErrorCode))
+ data["errorCode"] = ErrorCode;
+
+ if (!string.IsNullOrEmpty(ExitCode))
+ data["exitCode"] = ExitCode;
+ if (!string.IsNullOrEmpty(HResultFromWin32))
+ data["hresultFromWin32"] = HResultFromWin32;
+
+ // App metadata fields (emitted whenever available so ingest can merge them).
+ if (!string.IsNullOrEmpty(AppVersion))
+ data["appVersion"] = AppVersion;
+ if (!string.IsNullOrEmpty(AppType))
+ data["appType"] = AppType;
+ if (AttemptNumber > 0)
+ data["attemptNumber"] = AttemptNumber;
+ if (!string.IsNullOrEmpty(DetectionResult))
+ data["detectionResult"] = DetectionResult;
+
+ if (HasDoTelemetry)
+ {
+ data["doFileSize"] = DoFileSize;
+ data["doTotalBytesDownloaded"] = DoTotalBytesDownloaded;
+ data["doBytesFromPeers"] = DoBytesFromPeers;
+ data["doPercentPeerCaching"] = DoPercentPeerCaching;
+ data["doBytesFromLanPeers"] = DoBytesFromLanPeers;
+ data["doBytesFromGroupPeers"] = DoBytesFromGroupPeers;
+ data["doBytesFromInternetPeers"] = DoBytesFromInternetPeers;
+ data["doDownloadMode"] = DoDownloadMode;
+ data["doDownloadDuration"] = DoDownloadDuration ?? "";
+ data["doBytesFromHttp"] = DoBytesFromHttp;
+ }
+
+ return data;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs
new file mode 100644
index 00000000..30a3d432
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs
@@ -0,0 +1,432 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
+{
+ ///
+ /// Manages a collection of app package states during enrollment.
+ /// Tracks current app, ignore list, and dependency cascading.
+ /// Adapted from EspOverlay PackageStatesList.
+ ///
+ public class AppPackageStateList : List
+ {
+ ///
+ /// The ID of the app currently being processed by IME
+ ///
+ public string CurrentPackageId { get; set; }
+
+ ///
+ /// Apps to ignore (e.g., user-targeted apps during device phase).
+ /// Case-insensitive because GUIDs may appear in different casing across IME log entries and policy JSON.
+ ///
+ public HashSet IgnoreList { get; } = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ private readonly AgentLogger _logger;
+
+ // States that should also cascade to dependents
+ private static readonly HashSet StatesToCascade =
+ new HashSet
+ {
+ AppInstallationState.Postponed,
+ AppInstallationState.Error
+ };
+
+ public AppPackageStateList(AgentLogger logger)
+ {
+ _logger = logger;
+ }
+
+ ///
+ /// Gets a package by ID, optionally creating it if not found.
+ /// Returns null if the ID is in the ignore list.
+ ///
+ public AppPackageState GetPackage(string id, bool createIfNotFound = false)
+ {
+ if (string.IsNullOrEmpty(id) || IgnoreList.Contains(id))
+ return null;
+
+ var result = this.SingleOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
+ if (result == null && createIfNotFound)
+ {
+ result = new AppPackageState(id, Count);
+ Add(result);
+ }
+ return result;
+ }
+
+ ///
+ /// Adds/updates packages from a JSON policy list (from IME "Get policies" log line).
+ /// Returns true if any package was updated.
+ ///
+ public bool AddUpdateFromJsonPolicies(List> jsonPolicies, bool ignoreUserTargeted)
+ {
+ if (jsonPolicies == null) return false;
+
+ var updated = false;
+ foreach (var policy in jsonPolicies)
+ {
+ string id;
+ object idObj;
+ if (!policy.TryGetValue("Id", out idObj) || (id = idObj?.ToString()) == null)
+ continue;
+
+ // Check RunAs - ignore user-targeted during device phase
+ if (ignoreUserTargeted)
+ {
+ var runAs = GetRunAsFromPolicy(policy);
+ if (runAs == AppRunAs.User)
+ {
+ updated |= AddToIgnoreList(id);
+ continue;
+ }
+ }
+
+ var pkg = GetPackage(id, createIfNotFound: true);
+ if (pkg == null) continue;
+
+ // Update name
+ object nameObj;
+ if (policy.TryGetValue("Name", out nameObj))
+ updated |= pkg.UpdateName(nameObj?.ToString());
+
+ // Update intent
+ object intentObj;
+ if (policy.TryGetValue("Intent", out intentObj))
+ {
+ AppIntent intent;
+ if (Enum.TryParse(intentObj.ToString(), true, out intent))
+ updated |= pkg.UpdateIntent(intent);
+ }
+
+ // Update targeted
+ object targetObj;
+ if (policy.TryGetValue("TargetType", out targetObj))
+ {
+ AppTargeted targeted;
+ if (Enum.TryParse(targetObj.ToString(), true, out targeted))
+ updated |= pkg.UpdateTargeted(targeted);
+ }
+
+ // Update RunAs from InstallEx
+ object installExObj;
+ if (policy.TryGetValue("InstallEx", out installExObj))
+ {
+ var runAs = ExtractRunAsFromInstallEx(installExObj);
+ if (runAs != AppRunAs.Unknown)
+ updated |= pkg.UpdateRunAs(runAs);
+ }
+
+ // Update dependencies from FlatDependencies
+ object flatDepsObj;
+ if (policy.TryGetValue("FlatDependencies", out flatDepsObj))
+ {
+ var deps = ExtractDependencies(flatDepsObj, id);
+ if (deps != null)
+ updated |= pkg.UpdateDependsOn(deps);
+ }
+ }
+
+ if (updated)
+ {
+ Sort();
+ LogStates();
+ }
+
+ return updated;
+ }
+
+ ///
+ /// Updates the name of a specific package
+ ///
+ public bool UpdateName(string id, string newName)
+ {
+ var pkg = GetPackage(id);
+ if (pkg == null) return false;
+ var updated = pkg.UpdateName(newName);
+ if (updated) LogStates();
+ return updated;
+ }
+
+ ///
+ /// Updates the installation state of a specific package.
+ /// Returns true if state actually changed.
+ /// Optionally cascades to dependent packages for error/postpone states.
+ ///
+ public bool UpdateState(string id, AppInstallationState newState, int? progressPercent = null)
+ {
+ var packageIds = new HashSet { id };
+
+ // Cascade error/postpone to dependents
+ if (StatesToCascade.Contains(newState))
+ packageIds.UnionWith(GetDependentIdsDeep(id));
+
+ var packages = packageIds.Select(x => GetPackage(x)).Where(x => x != null).ToList();
+
+ var result = false;
+ foreach (var pkg in packages)
+ result |= pkg.UpdateState(newState, progressPercent);
+
+ if (result)
+ {
+ AppPackageState.SortErrorsToTop = IsAllCompleted();
+ Sort();
+ }
+
+ return result;
+ }
+
+ ///
+ /// Updates state to Downloading with optional progress tracking
+ ///
+ public bool UpdateStateToDownloading(string id, string bytesDownloadedStr, string totalBytesStr)
+ {
+ int? progressPercent = null;
+ long bytesDownloaded = 0;
+ long bytesTotal = 0;
+
+ try
+ {
+ bytesDownloaded = Convert.ToInt64(bytesDownloadedStr);
+ bytesTotal = Convert.ToInt64(totalBytesStr);
+ if (bytesTotal != 0)
+ progressPercent = (int)Math.Round((double)bytesDownloaded / bytesTotal * 100);
+ }
+ catch { }
+
+ var pkg = GetPackage(id);
+ if (pkg != null &&
+ pkg.InstallationState != AppInstallationState.Skipped &&
+ pkg.InstallationState != AppInstallationState.Installed)
+ {
+ var result = pkg.UpdateState(AppInstallationState.Downloading, progressPercent, false, bytesDownloaded, bytesTotal);
+ if (result)
+ {
+ AppPackageState.SortErrorsToTop = IsAllCompleted();
+ Sort();
+ }
+ return result;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Updates state from a Win32AppState string
+ ///
+ public bool UpdateStateFromWin32AppState(string id, string win32AppStateString)
+ {
+ var pkg = GetPackage(id);
+ if (pkg == null) return false;
+ var updated = pkg.UpdateStateFromWin32AppState(win32AppStateString);
+ if (updated)
+ {
+ AppPackageState.SortErrorsToTop = IsAllCompleted();
+ Sort();
+ }
+ return updated;
+ }
+
+ ///
+ /// Sets the current package being processed
+ ///
+ public void SetCurrent(string id)
+ {
+ CurrentPackageId = id;
+ }
+
+ ///
+ /// Adds an ID to the ignore list
+ ///
+ public bool AddToIgnoreList(string id)
+ {
+ if (string.IsNullOrEmpty(id))
+ return false;
+
+ return IgnoreList.Add(id); // HashSet.Add returns false if already present (case-insensitive)
+ }
+
+ ///
+ /// Checks if all required apps are completed
+ ///
+ public bool IsAllCompleted()
+ {
+ if (!this.Any()) return false;
+
+ var allRequiredComplete = this.Where(x => x.IsRequired).All(x => x.IsCompleted);
+ if (allRequiredComplete)
+ {
+ // Mark dependency-only packages that IME never touched as Skipped
+ foreach (var pkg in this.Where(x => !x.IsRequired && x.InstallationState == AppInstallationState.Unknown))
+ pkg.UpdateState(AppInstallationState.Skipped);
+ }
+ return allRequiredComplete;
+ }
+
+ ///
+ /// Gets the first non-completed package (for display)
+ ///
+ public AppPackageState ActivePackage => this.FirstOrDefault(x => !x.IsCompleted);
+
+ public int CountAll => Count;
+ public int CountCompleted => this.Count(x => x.IsCompleted);
+ public bool HasError => this.Any(x => x.IsError);
+ public int ErrorCount => this.Count(x => x.IsError);
+
+ ///
+ /// Gets summary data for event emission
+ ///
+ public Dictionary GetSummaryData()
+ {
+ return new Dictionary
+ {
+ { "totalApps", CountAll },
+ { "completedApps", CountCompleted },
+ { "errorCount", ErrorCount },
+ { "deviceErrors", this.Count(x => x.IsError && x.Targeted == AppTargeted.Device) },
+ { "userErrors", this.Count(x => x.IsError && x.Targeted == AppTargeted.User) },
+ { "hasErrors", HasError },
+ { "isAllCompleted", IsAllCompleted() },
+ { "ignoredCount", IgnoreList.Count },
+ // State-breakdown for UI progress displays (replaces the full "apps" array
+ // which could exceed Azure Table Storage's 64KB property limit with 150+ apps)
+ { "downloading", this.Count(x => x.InstallationState == AppInstallationState.Downloading) },
+ { "installing", this.Count(x => x.InstallationState == AppInstallationState.Installing) },
+ { "installed", this.Count(x => x.InstallationState == AppInstallationState.Installed) },
+ { "skipped", this.Count(x => x.InstallationState == AppInstallationState.Skipped) },
+ { "failed", this.Count(x => x.InstallationState == AppInstallationState.Error) },
+ { "postponed", this.Count(x => x.InstallationState == AppInstallationState.Postponed) },
+ { "pending", this.Count(x => x.InstallationState < AppInstallationState.Downloading) },
+ // App names per status category for diagnostics (typically few entries per category,
+ // well within Azure Table Storage's 64KB/30K-char limit even with 150+ apps)
+ { "pendingNames", this.Where(x => x.InstallationState < AppInstallationState.Downloading).Select(x => x.Name).ToList() },
+ { "failedNames", this.Where(x => x.InstallationState == AppInstallationState.Error).Select(x => x.Name).ToList() },
+ { "postponedNames", this.Where(x => x.InstallationState == AppInstallationState.Postponed).Select(x => x.Name).ToList() },
+ { "installedNames", this.Where(x => x.InstallationState == AppInstallationState.Installed).Select(x => x.Name).ToList() },
+ { "skippedNames", this.Where(x => x.InstallationState == AppInstallationState.Skipped).Select(x => x.Name).ToList() }
+ };
+ }
+
+ ///
+ /// Gets all package states as a list of dictionaries for final status dump.
+ /// Reuses AppPackageState.ToEventData() which includes state, error info, DO telemetry.
+ ///
+ public List> ToFinalStatusList()
+ {
+ return this.Select(pkg => pkg.ToEventData()).ToList();
+ }
+
+ ///
+ /// Recursively gets all dependent package IDs (packages that depend on the given parent)
+ ///
+ private HashSet GetDependentIdsDeep(string parentId, int level = 0)
+ {
+ var result = new HashSet();
+ try
+ {
+ level++;
+ if (level > 10)
+ {
+ _logger?.Warning("Dependency tree deeper than 10 levels - possible circular dependency");
+ return result;
+ }
+
+ foreach (var dependent in this.Where(x => x.DependsOn.Contains(parentId)).Select(x => x.Id))
+ {
+ result.Add(dependent);
+ result.UnionWith(GetDependentIdsDeep(dependent, level));
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Warning($"Error resolving dependencies for {parentId}: {ex.Message}");
+ }
+
+ return result;
+ }
+
+ private static AppRunAs GetRunAsFromPolicy(Dictionary policy)
+ {
+ try
+ {
+ object installExObj;
+ if (policy.TryGetValue("InstallEx", out installExObj))
+ return ExtractRunAsFromInstallEx(installExObj);
+ }
+ catch { }
+ return AppRunAs.Unknown;
+ }
+
+ private static AppRunAs ExtractRunAsFromInstallEx(object installExObj)
+ {
+ try
+ {
+ if (installExObj is Dictionary installEx)
+ {
+ object runAsObj;
+ if (installEx.TryGetValue("RunAs", out runAsObj))
+ return (AppRunAs)Convert.ToInt32(runAsObj);
+ }
+ // Handle as JSON string
+ var str = installExObj?.ToString();
+ if (!string.IsNullOrEmpty(str))
+ {
+ var json = System.Text.Json.JsonSerializer.Deserialize>(str);
+ if (json != null)
+ {
+ object runAsObj;
+ if (json.TryGetValue("RunAs", out runAsObj))
+ return (AppRunAs)Convert.ToInt32(runAsObj.ToString());
+ }
+ }
+ }
+ catch { }
+ return AppRunAs.Unknown;
+ }
+
+ private static HashSet ExtractDependencies(object flatDepsObj, string appId)
+ {
+ try
+ {
+ if (flatDepsObj == null) return new HashSet();
+
+ var deps = flatDepsObj as List