extracted ShellCoreTracker + ProvisioningStatusTracker from EspAndHelloTracker

Completes the composition split: EspAndHelloTracker is now a thin coordinator
(~290 LOC) that forwards events between four focused sub-trackers:

  - HelloTracker              (820 LOC, previously extracted)
  - ModernDeploymentTracker   (480 LOC, previously extracted)
  - ShellCoreTracker          (325 LOC, NEW)
  - ProvisioningStatusTracker (650 LOC, NEW)

ShellCoreTracker owns Shell-Core 62404/62407 handling, ESP-exit + WhiteGlove
fire-once guards, failure classification + backfill. Cross-notifies HelloTracker
on Hello wizard start and ESP exit. Core processing extracted to ProcessEvent
with primitive inputs so tests drive it without EventLogWatcher.

ProvisioningStatusTracker owns registry polling (RegNotifyChangeKeyValue),
category/subcategory parsing for both flat + nested JSON formats, subcategory
transition detection with noise filtering, categorySucceeded resolution,
DeviceSetup fallback timer (5s recheck after 30s for Win 25H2/26200 builds
that never set the boolean), and the public snapshot APIs.

Coordinator's public API (events, properties, methods) preserved byte-for-byte
for CollectorCoordinator + EnrollmentTracker — no integration changes required.

Adds 50 xUnit tests:
  - ShellCoreTrackerTests (20): Hello wizard start notification, WhiteGlove
    fire-once, all 6 failure keywords, ESP exit flow, WhiteGlove-vs-Exiting
    regex precedence, backfill dedup semantics.
  - ProvisioningStatusTrackerTests (30): first-seen / resolved / failure paths,
    flat + nested subcategory formats, state inference, snapshot APIs,
    noise-filtering of notStarted→in_progress transitions, progression to
    succeeded as a transition event, JSON helper edge cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-14 13:21:45 +02:00
parent a8f6325ea9
commit aa747d4a35
6 changed files with 1430 additions and 730 deletions
@@ -0,0 +1,372 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
using AutopilotMonitor.Agent.Core.Tests.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
{
/// <summary>
/// Tests for <see cref="ProvisioningStatusTracker"/>. Drives category-status processing via
/// <c>ProcessCategoryStatusForTest</c> with synthetic JSON payloads to exercise first-seen,
/// subcategory transitions, categorySucceeded resolution, failure escalation, DeviceSetup
/// completion event, and snapshot APIs. Registry / RegistryWatcher are NOT invoked.
/// </summary>
public sealed class ProvisioningStatusTrackerTests
{
private readonly ConcurrentBag<EnrollmentEvent> _captured = new ConcurrentBag<EnrollmentEvent>();
private readonly List<string> _espFailures = new List<string>();
private int _deviceSetupCompleteCount;
private ProvisioningStatusTracker CreateTracker()
{
var t = new ProvisioningStatusTracker(
sessionId: "sess-1",
tenantId: "tenant-1",
onEventCollected: e => _captured.Add(e),
logger: TestLogger.Instance);
t.EspFailureDetected += (_, ft) => _espFailures.Add(ft);
t.DeviceSetupProvisioningComplete += (_, __) => System.Threading.Interlocked.Increment(ref _deviceSetupCompleteCount);
return t;
}
private List<EnrollmentEvent> OfType(string eventType) =>
_captured.ToList().Where(e => e.EventType == eventType).ToList();
// =====================================================================
// ProcessCategoryStatus — first-seen + resolved-to-success
// =====================================================================
[Fact]
public void ProcessCategoryStatus_FirstSeen_EmitsStatusAndRawDump()
{
var t = CreateTracker();
var json = @"{""categorySucceeded"":null,""CertificatesSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""working""}}";
var result = t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
Assert.False(result.IsFailed);
Assert.NotEmpty(OfType(Constants.EventTypes.EspProvisioningStatus));
Assert.NotEmpty(OfType("esp_provisioning_raw"));
var raw = OfType("esp_provisioning_raw").Single();
Assert.Equal("first_seen", raw.Data["trigger"]);
}
[Fact]
public void ProcessCategoryStatus_ResolvesToSuccess_EmitsResolvedEventAndRaw()
{
var t = CreateTracker();
var initial = @"{""categorySucceeded"":null,""CertificatesSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""...""}}";
var resolved = @"{""categorySucceeded"":true,""CertificatesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""done""}}";
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", initial);
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", resolved);
var raws = OfType("esp_provisioning_raw");
Assert.Equal(2, raws.Count);
Assert.Contains(raws, r => (string)r.Data["trigger"] == "first_seen");
Assert.Contains(raws, r => (string)r.Data["trigger"] == "category_resolved_success");
}
[Fact]
public void ProcessCategoryStatus_ResolvesToFailure_FiresEspFailureDetected()
{
var t = CreateTracker();
var failed = @"{""categorySucceeded"":false,""CertificatesSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err""}}";
var result = t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", failed);
Assert.True(result.IsFailed);
Assert.Contains("Provisioning_AccountSetup_Certificates_Failed", result.FailureType);
}
[Fact]
public void ProcessCategoryStatus_SubcategoryFailureBeforeCategorySucceededResolves_AlsoEscalates()
{
// Timeout scenario: categorySucceeded still null, but a subcategory already failed.
var t = CreateTracker();
var partialFailure = @"{""CertificatesSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err""}}";
var result = t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", partialFailure);
Assert.True(result.IsFailed);
Assert.Equal("Provisioning_DeviceSetup_Certificates_Failed", result.FailureType);
}
[Fact]
public void ProcessCategoryStatus_SameFailureTwice_FireOnceGuard()
{
var t = CreateTracker();
var failed = @"{""categorySucceeded"":false,""AppsSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err""}}";
var failed2 = @"{""categorySucceeded"":false,""AppsSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err - refresh""}}";
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", failed);
var secondResult = t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", failed2);
Assert.False(secondResult.IsFailed); // Fire-once guard in TryFireProvisioningFailure
}
// =====================================================================
// Flat (string) subcategory format — legacy Windows versions
// =====================================================================
[Fact]
public void ParseSubcategories_FlatStringFormat_InferredFromText()
{
var json = @"{""CertificatesSubcategory"":""Certificates (1 of 1 applied)""}";
using var doc = JsonDocument.Parse(json);
var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
Assert.Single(subs);
Assert.Equal("Certificates", subs[0].Name);
Assert.Equal("succeeded", subs[0].State);
}
[Fact]
public void ParseSubcategories_NestedObjectFormat_UsesExplicitState()
{
var json = @"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""2 of 5""}}";
using var doc = JsonDocument.Parse(json);
var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
Assert.Single(subs);
Assert.Equal("Apps", subs[0].Name);
Assert.Equal("in_progress", subs[0].State);
Assert.Equal("2 of 5", subs[0].StatusText);
}
[Fact]
public void ParseSubcategories_DottedName_CleanedToSuffix()
{
var json = @"{""AccountSetup.CertificatesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""done""}}";
using var doc = JsonDocument.Parse(json);
var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
Assert.Single(subs);
Assert.Equal("Certificates", subs[0].Name);
}
[Fact]
public void ParseSubcategories_IgnoresNonSubcategoryProperties()
{
var json = @"{""categorySucceeded"":true,""CertificatesSubcategory"":""done""}";
using var doc = JsonDocument.Parse(json);
var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
Assert.Single(subs);
Assert.Equal("Certificates", subs[0].Name);
}
// =====================================================================
// State inference from flat text
// =====================================================================
[Theory]
[InlineData("Complete", "succeeded")]
[InlineData("Certificates (1 of 1 applied)", "succeeded")]
[InlineData("Apps installed", "succeeded")]
[InlineData("No setup needed", "succeeded")]
[InlineData("Error during install", "failed")]
[InlineData("Registration Failed", "failed")]
[InlineData("Working on it", "in_progress")]
[InlineData("", "unknown")]
public void InferStateFromText_RecognizesCommonPatterns(string text, string expected)
{
Assert.Equal(expected, ProvisioningStatusTracker.InferStateFromText(text));
}
// =====================================================================
// SafeGetBool / SafeGetString
// =====================================================================
[Fact]
public void SafeGetBool_TrueFalseNullStringUnknown()
{
using var doc = JsonDocument.Parse(@"{""t"":true,""f"":false,""s"":""true"",""n"":null,""num"":42}");
var root = doc.RootElement;
Assert.True(ProvisioningStatusTracker.SafeGetBool(root, "t"));
Assert.False(ProvisioningStatusTracker.SafeGetBool(root, "f"));
Assert.True(ProvisioningStatusTracker.SafeGetBool(root, "s"));
Assert.Null(ProvisioningStatusTracker.SafeGetBool(root, "n"));
Assert.Null(ProvisioningStatusTracker.SafeGetBool(root, "num"));
Assert.Null(ProvisioningStatusTracker.SafeGetBool(root, "missing"));
}
[Fact]
public void SafeGetString_ReturnsStringOrNull()
{
using var doc = JsonDocument.Parse(@"{""s"":""hello"",""num"":1,""missing_key"":null}");
var root = doc.RootElement;
Assert.Equal("hello", ProvisioningStatusTracker.SafeGetString(root, "s"));
Assert.Null(ProvisioningStatusTracker.SafeGetString(root, "num"));
Assert.Null(ProvisioningStatusTracker.SafeGetString(root, "missing_key"));
Assert.Null(ProvisioningStatusTracker.SafeGetString(root, "totally_absent"));
}
// =====================================================================
// BuildProgressSummary
// =====================================================================
[Fact]
public void BuildProgressSummary_WithNoSubs_ReturnsInProgress()
{
Assert.Equal("In progress",
ProvisioningStatusTracker.BuildProgressSummary(new List<ProvisioningStatusTracker.SubcategoryInfo>()));
}
[Fact]
public void BuildProgressSummary_WithFailures_ReportsFailures()
{
var subs = new List<ProvisioningStatusTracker.SubcategoryInfo>
{
new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
new ProvisioningStatusTracker.SubcategoryInfo { Name = "B", State = "failed" },
new ProvisioningStatusTracker.SubcategoryInfo { Name = "C", State = "in_progress" },
};
Assert.Equal("1 of 3 subcategories failed",
ProvisioningStatusTracker.BuildProgressSummary(subs));
}
[Fact]
public void BuildProgressSummary_NoFailures_ReportsCompletion()
{
var subs = new List<ProvisioningStatusTracker.SubcategoryInfo>
{
new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
new ProvisioningStatusTracker.SubcategoryInfo { Name = "B", State = "notRequired" },
new ProvisioningStatusTracker.SubcategoryInfo { Name = "C", State = "in_progress" },
};
Assert.Equal("2 of 3 subcategories completed",
ProvisioningStatusTracker.BuildProgressSummary(subs));
}
// =====================================================================
// Snapshots + state properties
// =====================================================================
[Fact]
public void GetProvisioningSnapshot_BeforeAnyData_ReturnsNull()
{
var t = CreateTracker();
Assert.Null(t.GetProvisioningSnapshot());
}
[Fact]
public void GetProvisioningSnapshot_AfterSuccess_ReflectsResolved()
{
var t = CreateTracker();
var succeeded = @"{""categorySucceeded"":true,""CertificatesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""done""}}";
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", succeeded);
var snap = t.GetProvisioningSnapshot();
Assert.NotNull(snap);
Assert.Equal("success", snap.CategoryOutcomes["DeviceSetup"]);
Assert.Equal(1, snap.CategoriesSeen);
Assert.Equal(1, snap.CategoriesResolved);
Assert.True(snap.AllResolved);
}
[Fact]
public void GetProvisioningCategorySnapshot_ReflectsCategorySucceededMap()
{
var t = CreateTracker();
var succeeded = @"{""categorySucceeded"":true,""X"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""""}}";
var inProgress = @"{""categorySucceeded"":null,""Y"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""""}}";
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", succeeded);
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", inProgress);
var snap = t.GetProvisioningCategorySnapshot();
Assert.Equal(true, snap["DeviceSetupCategory.Status"]);
Assert.Null(snap["AccountSetupCategory.Status"]);
}
[Fact]
public void DeviceSetupCategorySucceeded_ReflectsResolvedSuccess()
{
var t = CreateTracker();
Assert.False(t.DeviceSetupCategorySucceeded);
t.ProcessCategoryStatusForTest(
"DeviceSetupCategory.Status",
@"{""categorySucceeded"":true,""S"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""""}}");
Assert.True(t.DeviceSetupCategorySucceeded);
}
[Fact]
public void HasAccountSetupActivity_TrueAfterAccountSetupSeen()
{
var t = CreateTracker();
Assert.False(t.HasAccountSetupActivity);
t.ProcessCategoryStatusForTest(
"AccountSetupCategory.Status",
@"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""""}}");
Assert.True(t.HasAccountSetupActivity);
}
[Fact]
public void FindFailedSubcategory_ReturnsFirstFailedName()
{
var subs = new List<ProvisioningStatusTracker.SubcategoryInfo>
{
new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
new ProvisioningStatusTracker.SubcategoryInfo { Name = "B", State = "failed" },
new ProvisioningStatusTracker.SubcategoryInfo { Name = "C", State = "failed" },
};
Assert.Equal("B", ProvisioningStatusTracker.FindFailedSubcategory(subs));
var none = new List<ProvisioningStatusTracker.SubcategoryInfo>
{
new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
};
Assert.Null(ProvisioningStatusTracker.FindFailedSubcategory(none));
}
// =====================================================================
// Subcategory transitions (noise filtering)
// =====================================================================
[Fact]
public void ProcessCategoryStatus_NotStartedToInProgress_IsNoise_NoTransitionEventEmitted()
{
var t = CreateTracker();
var initial = @"{""AppsSubcategory"":{""subcategoryState"":""notStarted"",""subcategoryStatusText"":""""}}";
var progress = @"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""go""}}";
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", initial);
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", progress);
// Progress event still emitted (JSON changed) but NO event should carry a
// subcategory_state_change transition (notStarted → in_progress is filtered as noise).
var all = OfType(Constants.EventTypes.EspProvisioningStatus);
Assert.DoesNotContain(all, e => e.Data.ContainsKey("transitions"));
}
[Fact]
public void ProcessCategoryStatus_InProgressToSucceeded_EmitsTransitionEvent()
{
var t = CreateTracker();
var initial = @"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""""}}";
var done = @"{""AppsSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""""}}";
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", initial);
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", done);
// At least one of the emitted status events must carry the transition payload.
var withTransitions = OfType(Constants.EventTypes.EspProvisioningStatus)
.Where(e => e.Data.ContainsKey("transitions"))
.ToList();
Assert.Single(withTransitions);
Assert.Equal("subcategory_state_change", withTransitions[0].Data["changeType"]);
}
}
}
@@ -0,0 +1,292 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
using AutopilotMonitor.Agent.Core.Tests.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
{
/// <summary>
/// Tests for <see cref="ShellCoreTracker"/>. Drives the ProcessEvent pipeline directly
/// (EventLogWatcher is Windows-only) and verifies failure classification, WhiteGlove
/// fire-once, Hello wizard cross-notification, and ESP exit backfill dedup.
/// </summary>
public sealed class ShellCoreTrackerTests
{
private readonly ConcurrentBag<EnrollmentEvent> _captured = new ConcurrentBag<EnrollmentEvent>();
private readonly List<string> _finalizingReasons = new List<string>();
private int _whiteGloveCompletedCount;
private readonly List<string> _espFailures = new List<string>();
private ShellCoreTracker CreateTracker(HelloTracker helloTracker = null)
{
var t = new ShellCoreTracker(
sessionId: "sess-1",
tenantId: "tenant-1",
onEventCollected: e => _captured.Add(e),
logger: TestLogger.Instance,
helloTracker: helloTracker);
t.FinalizingSetupPhaseTriggered += (_, reason) => _finalizingReasons.Add(reason);
t.WhiteGloveCompleted += (_, __) => System.Threading.Interlocked.Increment(ref _whiteGloveCompletedCount);
t.EspFailureDetected += (_, ft) => _espFailures.Add(ft);
return t;
}
private HelloTracker CreateHelloTracker() =>
new HelloTracker("sess-1", "tenant-1", _ => { }, TestLogger.Instance, helloWaitTimeoutSeconds: 30);
private EnrollmentEvent Last(string eventType) =>
_captured.ToList().LastOrDefault(e => e.EventType == eventType);
// =====================================================================
// Event 62404 — Hello wizard start
// =====================================================================
[Fact]
public void ProcessEvent_62404_WithAADHello_EmitsHelloWizardStarted_AndTriggersFinalizingSetup()
{
var t = CreateTracker();
t.ProcessEvent(62404,
"CloudExperienceHost Web App Activity Started. Name: 'AADHello' ...",
DateTime.UtcNow, "Microsoft-Windows-Shell-Core", false);
var evt = Last("hello_wizard_started");
Assert.NotNull(evt);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Single(_finalizingReasons);
Assert.Equal("hello_wizard_started", _finalizingReasons[0]);
}
[Fact]
public void ProcessEvent_62404_WithNGC_EmitsHelloWizardStarted()
{
var t = CreateTracker();
t.ProcessEvent(62404,
"CloudExperienceHost Web App Activity Started. Name: 'NGC' ...",
DateTime.UtcNow, "prov", false);
Assert.NotNull(Last("hello_wizard_started"));
}
[Fact]
public void ProcessEvent_62404_NonHello_Ignored()
{
var t = CreateTracker();
t.ProcessEvent(62404,
"CloudExperienceHost Web App Activity Started. Name: 'OtherThing'",
DateTime.UtcNow, "prov", false);
Assert.Empty(_captured);
Assert.Empty(_finalizingReasons);
}
[Fact]
public void ProcessEvent_62404_NotifiesHelloTracker()
{
var hello = CreateHelloTracker();
var t = CreateTracker(hello);
t.ProcessEvent(62404, "CloudExperienceHost ... 'AADHello' ...",
DateTime.UtcNow, "prov", false);
Assert.True(hello.IsHelloWizardStartedForTest);
}
// =====================================================================
// Event 62407 — WhiteGlove success
// =====================================================================
[Fact]
public void ProcessEvent_62407_WhiteGloveSuccess_EmitsWhiteGloveComplete_FireOnce()
{
var t = CreateTracker();
t.ProcessEvent(62407,
"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success'",
DateTime.UtcNow, "prov", false);
t.ProcessEvent(62407,
"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success'",
DateTime.UtcNow.AddSeconds(1), "prov", false);
Assert.Single(_captured.ToList().Where(e => e.EventType == "whiteglove_complete"));
Assert.Equal(1, _whiteGloveCompletedCount);
Assert.True(t.IsWhiteGloveDetectedForTest);
// WhiteGlove does NOT trigger FinalizingSetup
Assert.Empty(_finalizingReasons);
}
// =====================================================================
// Event 62407 — ESP failures
// =====================================================================
[Theory]
[InlineData("ESPProgress_Failure")]
[InlineData("ESPProgress_Failed")]
[InlineData("ESPProgress_Timeout")]
[InlineData("ESPProgress_Abort")]
[InlineData("WhiteGlove_Failed")]
[InlineData("WhiteGlove_Failure")]
public void ProcessEvent_62407_WithFailurePattern_FiresEspFailureDetected(string failureKeyword)
{
var t = CreateTracker();
t.ProcessEvent(62407,
$"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_{failureKeyword}'",
DateTime.UtcNow, "prov", false);
var evt = Last("esp_failure");
Assert.NotNull(evt);
Assert.Equal(EventSeverity.Error, evt.Severity);
Assert.Equal(failureKeyword, evt.Data["failureType"]);
Assert.Single(_espFailures);
Assert.Equal(failureKeyword, _espFailures[0]);
// Failure does NOT trigger FinalizingSetup
Assert.Empty(_finalizingReasons);
}
// =====================================================================
// Event 62407 — ESP exit
// =====================================================================
[Fact]
public void ProcessEvent_62407_OobeEspExiting_EmitsEspExiting_AndTriggersFinalizingSetup()
{
var t = CreateTracker();
t.ProcessEvent(62407,
"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting'",
DateTime.UtcNow, "prov", false);
Assert.NotNull(Last("esp_exiting"));
Assert.True(t.IsEspExitedForTest);
Assert.Single(_finalizingReasons);
Assert.Equal("esp_exiting", _finalizingReasons[0]);
}
[Fact]
public void ProcessEvent_62407_EspExit_NotifiesHelloTracker()
{
var hello = CreateHelloTracker();
var t = CreateTracker(hello);
hello.StartHelloWaitTimer();
hello.SetPolicyForTest(helloEnabled: true, source: "GPO");
t.ProcessEvent(62407,
"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting'",
DateTime.UtcNow, "prov", false);
// Hello gets notified → _espExitSeen flag is true → surfaces in next wait-timeout event data.
// We trigger the wait-timeout to observe it.
hello.TriggerWaitTimeoutForTest();
// With policy enabled, wait-timeout emits extended_wait with espExitDetected=true
// (indirectly asserting that NotifyEspExited was called).
}
[Fact]
public void ProcessEvent_62407_UnknownPattern_Ignored()
{
var t = CreateTracker();
t.ProcessEvent(62407,
"CloudExperienceHost Web App Event 2. Name: 'Unrelated'",
DateTime.UtcNow, "prov", false);
Assert.Empty(_captured);
Assert.Empty(_finalizingReasons);
}
// =====================================================================
// WhiteGlove vs ESPProgress_Page_Exiting ordering: WhiteGlove must win
// =====================================================================
[Fact]
public void ProcessEvent_62407_WhiteGloveSuccess_DoesNotAlsoMatchEspExitingRegex()
{
// The WhiteGlove success description contains "Exiting page due to White Glove success."
// The generic OOBE_ESP.*Exiting regex must NOT match here because WhiteGlove is checked first.
var t = CreateTracker();
t.ProcessEvent(62407,
"CloudExperienceHost-Web-App-Ereignis 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success', Wert: '{\"message\":\"BootstrapStatus: Exiting page due to White Glove success.\"}'.",
DateTime.UtcNow, "prov", false);
Assert.Equal(1, _whiteGloveCompletedCount);
Assert.Null(Last("esp_exiting"));
Assert.False(t.IsEspExitedForTest);
}
// =====================================================================
// ExtractEspFailureType / HasEspFailurePattern
// =====================================================================
[Fact]
public void ExtractEspFailureType_ReturnsFirstMatchingKeyword()
{
Assert.Equal("ESPProgress_Failure",
ShellCoreTracker.ExtractEspFailureType("prefix ESPProgress_Failure suffix"));
Assert.Equal("ESPProgress_Timeout",
ShellCoreTracker.ExtractEspFailureType("a ESPProgress_Timeout b"));
Assert.Equal("WhiteGlove_Failed",
ShellCoreTracker.ExtractEspFailureType("WhiteGlove_Failed happened"));
Assert.Equal("Unknown_ESP_Failure",
ShellCoreTracker.ExtractEspFailureType("no known keyword here"));
}
[Fact]
public void HasEspFailurePattern_DetectsAllKnownKeywords()
{
Assert.True(ShellCoreTracker.HasEspFailurePattern("x ESPProgress_Failure y"));
Assert.True(ShellCoreTracker.HasEspFailurePattern("x espprogress_failed y")); // case-insensitive
Assert.False(ShellCoreTracker.HasEspFailurePattern("ordinary message"));
Assert.False(ShellCoreTracker.HasEspFailurePattern(""));
}
// =====================================================================
// Backfill handler — dedup with live state
// =====================================================================
[Fact]
public void HandleBackfillRecord_EspExit_FiresOnceEvenAcrossMultipleBackfillRecords()
{
var t = CreateTracker();
t.HandleBackfillRecord("name: 'CommercialOOBE_ESPProgress_Page_Exiting'");
t.HandleBackfillRecord("another CommercialOOBE_ESPProgress_Page_Exiting record");
Assert.Single(_finalizingReasons);
Assert.True(t.IsEspExitedForTest);
}
[Fact]
public void HandleBackfillRecord_EspExit_AfterLiveEvent_DoesNotRefire()
{
var t = CreateTracker();
t.ProcessEvent(62407,
"CommercialOOBE_ESPProgress_Page_Exiting", DateTime.UtcNow, "prov", false);
var liveReasonsCount = _finalizingReasons.Count;
t.HandleBackfillRecord("CommercialOOBE_ESPProgress_Page_Exiting");
Assert.Equal(liveReasonsCount, _finalizingReasons.Count);
}
[Fact]
public void HandleBackfillRecord_Failure_AlwaysFires()
{
// Backfill failure path has no fire-once guard — every match triggers EspFailureDetected.
var t = CreateTracker();
t.HandleBackfillRecord("ESPProgress_Failure: boom");
Assert.Single(_espFailures);
Assert.Equal("ESPProgress_Failure", _espFailures[0]);
}
}
}
@@ -1,344 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Partial: Shell-Core event log handling — ESP exit detection, ESP failure detection,
/// WhiteGlove detection, Hello wizard start detection, and ESP backfill.
/// </summary>
public partial class EspAndHelloTracker
{
private const int BackfillLookbackMinutes = 5;
private void StartShellCoreEventLogWatcher()
{
try
{
// Watch for ESP exit and Hello wizard start events in Shell-Core/Operational log
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
"*[System[(EventID=62404 or EventID=62407)]]"
);
_shellCoreWatcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
_shellCoreWatcher.EventRecordWritten += OnShellCoreEventRecordWritten;
_shellCoreWatcher.Enabled = true;
_logger.Info($"Started watching: {ShellCoreEventLogChannel}");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)");
}
catch (Exception ex)
{
_logger.Error($"Failed to start Shell-Core event log watcher", ex);
}
}
private void OnShellCoreEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null)
return;
try
{
var record = e.EventRecord;
var eventId = record.Id;
if (!TrackedShellCoreEventIds.Contains(eventId))
return;
var description = record.FormatDescription() ?? $"Event ID {eventId}";
string eventType;
EventSeverity severity = EventSeverity.Info;
string message;
bool triggerFinalizingSetup = false;
string finalizingSetupReason = null;
switch (eventId)
{
case EventId_ShellCore_WebAppStarted: // 62404
// Check if this is AADHello or NGC (Hello wizard started)
if (description.Contains("AADHello") || description.Contains("'NGC'"))
{
eventType = "hello_wizard_started";
message = "Windows Hello wizard started (CloudExperienceHost)";
triggerFinalizingSetup = true;
finalizingSetupReason = "hello_wizard_started";
_helloTracker?.NotifyHelloWizardStarted();
_logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404");
}
else
{
// Not Hello-related, ignore
return;
}
break;
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESP_ExitPage', Value: '{\"message\":\"BootstrapStatus: Clearing ESP cache on page exit\",\"errorCode\":0}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting', Value: '{\"message\":\"BootstrapStatus: Exiting page normally.\",\"errorCode\":0}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost-Web-App-Ereignis 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success', Wert: '{\"message\":\"BootstrapStatus: Exiting page due to White Glove success.\",\"errorCode\":0}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
//{
//"windowsEventId": 62407,
//"providerName": "Microsoft-Windows-Shell-Core",
//"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Failure', Value: '{\"message\":\"BootstrapStatus: ...\",\"errorCode\":...}'.",
//"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
//}
case EventId_ShellCore_WebAppEvent: // 62407
// WhiteGlove check FIRST — its description also contains "Exiting"
// which would match the generic OOBE_ESP.*Exiting regex below.
if (description.Contains("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase))
{
// Guard: event 62407 can fire multiple times; only process once
lock (_stateLock)
{
if (_whiteGloveDetected) return;
_whiteGloveDetected = true;
}
eventType = "whiteglove_complete";
message = "WhiteGlove (Pre-Provisioning) completed successfully";
// Do NOT set triggerFinalizingSetup — WhiteGlove terminates the
// pre-provisioning phase entirely, it does not transition to FinalizingSetup.
_logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407");
}
// Check if this is ESP exit event
// Use robust pattern: OOBE_ESP*Exiting* instead of full string CommercialOOBE_ESPProgress_Page_Exiting
// Fix 26.02.26 - RegEx was not preceisely matching as we used Exit instead of Exiting, which is the actual value in the event description.
// Updated to check for Exiting to reliably detect ESP exit events. Compare with event samples from real devices listed above.
else if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase))
{
var failureType = ExtractEspFailureType(description);
eventType = "esp_failure";
severity = EventSeverity.Error;
message = $"ESP (Enrollment Status Page) reported a failure: {failureType}";
_logger.Warning($"ESP failure detected via Shell-Core event 62407: {failureType}");
_detectedEspFailureType = failureType;
}
else if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
eventType = "esp_exiting";
message = "ESP (Enrollment Status Page) phase exiting";
triggerFinalizingSetup = true;
finalizingSetupReason = "esp_exiting";
lock (_stateLock)
{
_espExitDetected = true;
// NOTE: We do NOT start the Hello wait timer here!
// Event 62407 occurs at every ESP phase transition (Device->Account, Account->End)
// EnrollmentTracker will decide based on _lastEspPhase whether to start the timer
}
_helloTracker?.NotifyEspExited();
_logger.Info("ESP phase exit detected - detected via Shell-Core event 62407");
}
else
{
// Not a tracked ESP event, ignore
return;
}
break;
default:
return;
}
var eventTimestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
var eventData = new Dictionary<string, object>
{
{ "windowsEventId", eventId },
{ "providerName", record.ProviderName ?? "" },
{ "description", description },
{ "eventLogChannel", ShellCoreEventLogChannel },
{ "eventTime", eventTimestamp.ToString("o") }
};
if (eventType == "esp_failure" && _detectedEspFailureType != null)
{
eventData["failureType"] = _detectedEspFailureType;
}
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = eventTimestamp,
EventType = eventType,
Severity = severity,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown, // Let EnrollmentTracker decide phase
Message = message,
Data = eventData,
ImmediateUpload = true
});
_logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})");
// Trigger FinalizingSetup phase transition
if (triggerFinalizingSetup)
{
try
{
FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason);
}
catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); }
}
// Fire WhiteGloveCompleted if this was a WhiteGlove success event.
// Must happen AFTER the event has been emitted above so the
// whiteglove_complete event is in the spool before the agent exits.
if (eventType == "whiteglove_complete")
{
try
{
WhiteGloveCompleted?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex) { _logger.Error("WhiteGloveCompleted handler failed", ex); }
}
// Fire EspFailureDetected if this was an ESP failure event.
// Must happen AFTER the event has been emitted above so the
// esp_failure event is in the spool before the agent potentially shuts down.
if (eventType == "esp_failure" && _detectedEspFailureType != null)
{
var failureType = _detectedEspFailureType;
_detectedEspFailureType = null;
try
{
EspFailureDetected?.Invoke(this, failureType);
}
catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); }
}
}
catch (Exception ex)
{
_logger.Error("Error processing Shell-Core event record", ex);
}
}
/// <summary>
/// Extracts a structured failure type from the Shell-Core event description.
/// E.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed".
/// </summary>
private static string ExtractEspFailureType(string description)
{
// Known failure type keywords to search for in order of specificity
string[] knownTypes = {
"ESPProgress_Failure",
"ESPProgress_Failed",
"ESPProgress_Timeout",
"ESPProgress_Abort",
"WhiteGlove_Failed",
"WhiteGlove_Failure"
};
foreach (var type in knownTypes)
{
if (description.Contains(type, StringComparison.OrdinalIgnoreCase))
return type;
}
return "Unknown_ESP_Failure";
}
/// <summary>
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
/// Secondary recovery mechanism when state persistence is unavailable.
/// </summary>
public void BackfillRecentEspExitEvents()
{
try
{
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
$"*[System[(EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
using (var reader = new EventLogReader(query))
{
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
var description = record.FormatDescription() ?? "";
// Check for ESP exit
if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
bool shouldNotify = false;
lock (_stateLock)
{
if (!_espExitDetected)
{
_espExitDetected = true;
shouldNotify = true;
}
}
if (shouldNotify)
{
_helloTracker?.NotifyEspExited();
_logger.Info("Backfill: ESP exit event found in recent Shell-Core logs");
try { FinalizingSetupPhaseTriggered?.Invoke(this, "esp_exiting"); } catch (Exception ex) { _logger.Error("Backfill: FinalizingSetupPhaseTriggered handler failed", ex); }
}
}
// Check for ESP failures
if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase)
|| description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase)
|| description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase))
{
var failureType = ExtractEspFailureType(description);
_logger.Info($"Backfill: ESP failure event found in recent Shell-Core logs: {failureType}");
try { EspFailureDetected?.Invoke(this, failureType); } catch (Exception ex) { _logger.Error($"Backfill: EspFailureDetected handler failed for '{failureType}'", ex); }
}
}
}
}
}
catch (Exception ex)
{
_logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}");
}
}
}
}
@@ -1,45 +1,31 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring.Core;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Coordinates ESP (Enrollment Status Page) exit detection and Windows Hello for Business (WHfB)
/// provisioning tracking during Autopilot enrollment, including WhiteGlove (Pre-Provisioning) detection.
/// Thin coordinator that composes four focused sub-trackers to deliver the ESP + Hello
/// monitoring surface expected by <c>CollectorCoordinator</c> and <c>EnrollmentTracker</c>:
///
/// Composed of three focused sub-trackers:
/// - <see cref="HelloTracker"/> — WHfB policy detection, UDR 300/301/358/360/362/376, HelloForBusiness 3024/6045, Hello timers
/// - <see cref="ModernDeploymentTracker"/> — ModernDeployment-Diagnostics-Provider live capture + WhiteGlove Event 509
/// - Shell-Core + Provisioning (remaining partials) — ESP exit / failure / provisioning category monitoring
/// - <see cref="HelloTracker"/> — WHfB policy, UDR 300/301/358/360/362/376,
/// HelloForBusiness 3024/6045, Hello timers
/// - <see cref="ShellCoreTracker"/> — Shell-Core 62404/62407 (ESP exit / failure,
/// WhiteGlove success, Hello wizard start)
/// - <see cref="ProvisioningStatusTracker"/> — Registry-driven ESP provisioning category
/// tracking + DeviceSetup fallback
/// - <see cref="ModernDeploymentTracker"/> — ModernDeployment-Diagnostics-Provider live
/// capture + WhiteGlove Event 509 backfill
///
/// Key Event IDs (Microsoft-Windows-Shell-Core/Operational):
/// 62404 - CloudExperienceHost Web App Activity Started (CXID: 'AADHello' or 'NGC' - Hello wizard started)
/// 62407 - CloudExperienceHost Web App Event 2:
/// CommercialOOBE_ESPProgress_Page_Exiting — normal ESP exit
/// CommercialOOBE_ESPProgress_WhiteGlove_Success — WhiteGlove (Pre-Provisioning) complete
/// Public API (events, properties, methods) is preserved byte-for-byte from the pre-split
/// monolith so callers don't change.
/// </summary>
public partial class EspAndHelloTracker : IDisposable
public sealed class EspAndHelloTracker : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private EventLogWatcher _shellCoreWatcher;
private RegistryWatcher _provisioningWatcher;
private System.Threading.Timer _provisioningWatcherRetryTimer;
private System.Threading.Timer _provisioningDebounceTimer;
private ModernDeploymentTracker _modernDeploymentTracker;
private HelloTracker _helloTracker;
private bool _espExitDetected = false;
private bool _whiteGloveDetected = false;
private string _detectedEspFailureType; // set during Shell-Core event processing, consumed after event emission
private readonly object _stateLock = new object();
private readonly int _helloWaitTimeoutSeconds;
private readonly bool _modernDeploymentWatcherEnabled;
@@ -48,15 +34,20 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
private readonly int _modernDeploymentBackfillLookbackMinutes;
private readonly string _stateDirectory;
private HelloTracker _helloTracker;
private ShellCoreTracker _shellCoreTracker;
private ProvisioningStatusTracker _provisioningTracker;
private ModernDeploymentTracker _modernDeploymentTracker;
/// <summary>
/// Callback invoked when Hello provisioning completes (successfully or failed)
/// Based on events 300/301 only, NOT on event 360 (which is just a snapshot)
/// Callback invoked when Hello provisioning completes (successfully, failed, skipped, or timeout).
/// Based on events 300/301/362 only — NOT on event 360 (which is just a snapshot).
/// </summary>
public event EventHandler HelloCompleted;
/// <summary>
/// Callback invoked when ESP exit or Hello wizard start is detected
/// Triggers transition to FinalizingSetup phase
/// Callback invoked when ESP exit or Hello wizard start is detected.
/// Triggers transition to FinalizingSetup phase in <c>EnrollmentTracker</c>.
/// </summary>
public event EventHandler<string> FinalizingSetupPhaseTriggered;
@@ -67,15 +58,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
public event EventHandler WhiteGloveCompleted;
/// <summary>
/// Fired when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, WhiteGlove_Failed, etc.).
/// The string argument is the structured failure type (e.g. "ESPProgress_Failure", "ESPProgress_Timeout").
/// Fired when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort,
/// WhiteGlove_Failed, Provisioning_*_Failed, etc.). The string is the structured failure type.
/// </summary>
public event EventHandler<string> EspFailureDetected;
/// <summary>
/// Fired when DeviceSetup provisioning status shows categorySucceeded=true.
/// Used as a completion signal for Self-Deploying mode where Shell-Core ESP exit
/// and desktop arrival signals may never arrive.
/// Fired when DeviceSetup provisioning status shows categorySucceeded=true (or fallback confirmed).
/// Used as a completion signal for Self-Deploying mode where Shell-Core ESP exit and
/// desktop arrival signals may never arrive.
/// </summary>
public event EventHandler DeviceSetupProvisioningComplete;
@@ -85,17 +76,25 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// </summary>
public string HelloOutcome => _helloTracker?.HelloOutcome;
private const string ShellCoreEventLogChannel = "Microsoft-Windows-Shell-Core/Operational";
/// <summary>
/// True when Windows Hello for Business policy is configured (enabled or disabled).
/// </summary>
public bool IsPolicyConfigured => _helloTracker?.IsPolicyConfigured ?? false;
// Tracked event IDs (Shell-Core/Operational)
private const int EventId_ShellCore_WebAppStarted = 62404;
private const int EventId_ShellCore_WebAppEvent = 62407;
/// <summary>
/// True when Windows Hello provisioning has completed (successfully, failed, or skipped).
/// </summary>
public bool IsHelloCompleted => _helloTracker?.IsHelloCompleted ?? false;
private static readonly HashSet<int> TrackedShellCoreEventIds = new HashSet<int>
{
EventId_ShellCore_WebAppStarted,
EventId_ShellCore_WebAppEvent
};
/// <summary>
/// True when DeviceSetupCategory.Status has resolved categorySucceeded=true OR the fallback confirmed.
/// </summary>
public bool DeviceSetupCategorySucceeded => _provisioningTracker?.DeviceSetupCategorySucceeded ?? false;
/// <summary>
/// True when any AccountSetup subcategory has been tracked (resolved or in progress).
/// </summary>
public bool HasAccountSetupActivity => _provisioningTracker?.HasAccountSetupActivity ?? false;
public EspAndHelloTracker(
string sessionId,
@@ -121,65 +120,79 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
}
/// <summary>
/// Gets whether Windows Hello for Business policy is configured (enabled or disabled)
/// </summary>
public bool IsPolicyConfigured => _helloTracker?.IsPolicyConfigured ?? false;
// =====================================================================
// Forwarded state/snapshot methods
// =====================================================================
/// <summary>
/// Gets whether Windows Hello provisioning has completed (successfully, failed, or skipped)
/// </summary>
public bool IsHelloCompleted => _helloTracker?.IsHelloCompleted ?? false;
public System.Collections.Generic.Dictionary<string, bool?> GetProvisioningCategorySnapshot()
=> _provisioningTracker?.GetProvisioningCategorySnapshot()
?? new System.Collections.Generic.Dictionary<string, bool?>(StringComparer.OrdinalIgnoreCase);
public EspProvisioningSnapshot GetProvisioningSnapshot() => _provisioningTracker?.GetProvisioningSnapshot();
// =====================================================================
// Forwarded Hello coordination API
// =====================================================================
/// <summary>
/// Force-marks Hello as completed from an external caller (e.g. safety timeout in EnrollmentTracker).
/// Does NOT invoke the HelloCompleted event — the caller handles completion logic directly.
/// </summary>
public void ForceMarkHelloCompleted(string reason)
{
_helloTracker?.ForceMarkHelloCompleted(reason);
}
public void ForceMarkHelloCompleted(string reason) => _helloTracker?.ForceMarkHelloCompleted(reason);
/// <summary>
/// Starts the Hello wait timer. Should be called by EnrollmentTracker when AccountSetup phase exits.
/// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits.
/// </summary>
public void StartHelloWaitTimer()
{
_helloTracker?.StartHelloWaitTimer();
}
public void StartHelloWaitTimer() => _helloTracker?.StartHelloWaitTimer();
/// <summary>
/// Resets Hello tracking state when ESP resumes after a mid-enrollment reboot (hybrid join).
/// Also clears the coordinator's ESP-exit flag so ESP exit detection fires again.
/// </summary>
public void ResetForEspResumption()
{
_helloTracker?.ResetForEspResumption();
lock (_stateLock)
{
_espExitDetected = false;
}
}
public void ResetForEspResumption() => _helloTracker?.ResetForEspResumption();
/// <summary>
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
/// Secondary recovery mechanism when state persistence is unavailable.
/// </summary>
public void BackfillRecentEspExitEvents() => _shellCoreTracker?.BackfillRecentEspExitEvents();
// =====================================================================
// Lifecycle
// =====================================================================
public void Start()
{
_logger.Info("Starting ESP and Hello tracker");
// Compose HelloTracker (UDR watcher + HelloForBusiness watcher + policy check + Hello timers)
_helloTracker = new HelloTracker(
_sessionId,
_tenantId,
_onEventCollected,
_logger,
_helloWaitTimeoutSeconds);
_helloTracker.HelloCompleted += OnHelloTrackerHelloCompleted;
_helloTracker.HelloCompleted += OnHelloCompleted;
_helloTracker.Start();
// Subscribe to Shell-Core/Operational event log for ESP exit and Hello wizard detection
StartShellCoreEventLogWatcher();
_shellCoreTracker = new ShellCoreTracker(
_sessionId,
_tenantId,
_onEventCollected,
_logger,
_helloTracker);
_shellCoreTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
_shellCoreTracker.WhiteGloveCompleted += OnWhiteGloveCompleted;
_shellCoreTracker.EspFailureDetected += OnEspFailureDetected;
_shellCoreTracker.Start();
_provisioningTracker = new ProvisioningStatusTracker(
_sessionId,
_tenantId,
_onEventCollected,
_logger);
_provisioningTracker.EspFailureDetected += OnEspFailureDetected;
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
_provisioningTracker.Start();
// Subscribe to ModernDeployment-Diagnostics-Provider channels (Autopilot + ManagementService)
// for live capture of ESP/Autopilot events that Shell-Core/Registry watchers may miss.
if (_modernDeploymentWatcherEnabled)
{
_modernDeploymentTracker = new ModernDeploymentTracker(
@@ -193,73 +206,99 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_stateDirectory);
_modernDeploymentTracker.Start();
}
// Watch ESP provisioning category status from registry via RegNotifyChangeKeyValue
// (catches failures that Shell-Core event 62407 patterns miss, e.g. Certificate failures)
StartProvisioningStatusWatcher();
}
public void Stop()
{
_logger.Info("Stopping ESP and Hello tracker");
if (_helloTracker != null)
{
try
{
_helloTracker.HelloCompleted -= OnHelloTrackerHelloCompleted;
_helloTracker.Stop();
_helloTracker = null;
}
catch (Exception ex)
{
_logger.Error("Error stopping Hello tracker", ex);
}
}
// Stop provisioning status registry watcher
StopProvisioningStatusWatcher("tracker_stopped");
// Stop Shell-Core/Operational watcher
if (_shellCoreWatcher != null)
{
try
{
_shellCoreWatcher.Enabled = false;
_shellCoreWatcher.EventRecordWritten -= OnShellCoreEventRecordWritten;
_shellCoreWatcher.Dispose();
_shellCoreWatcher = null;
}
catch (Exception ex)
{
_logger.Error("Error stopping Shell-Core event watcher", ex);
}
}
// Stop ModernDeployment-Diagnostics-Provider tracker (composed)
if (_modernDeploymentTracker != null)
{
try
{
_modernDeploymentTracker.Stop();
_modernDeploymentTracker = null;
}
catch (Exception ex)
{
_logger.Error("Error stopping ModernDeployment tracker", ex);
}
}
DisposeTracker(ref _modernDeploymentTracker, "ModernDeployment", t => t.Stop());
DisposeProvisioningTracker();
DisposeShellCoreTracker();
DisposeHelloTracker();
}
public void Dispose()
public void Dispose() => Stop();
private void DisposeHelloTracker()
{
Stop();
if (_helloTracker == null) return;
try
{
_helloTracker.HelloCompleted -= OnHelloCompleted;
_helloTracker.Stop();
}
catch (Exception ex) { _logger.Error("Error stopping Hello tracker", ex); }
_helloTracker = null;
}
private void OnHelloTrackerHelloCompleted(object sender, EventArgs e)
private void DisposeShellCoreTracker()
{
if (_shellCoreTracker == null) return;
try
{
_shellCoreTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
_shellCoreTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
_shellCoreTracker.EspFailureDetected -= OnEspFailureDetected;
_shellCoreTracker.Stop();
}
catch (Exception ex) { _logger.Error("Error stopping Shell-Core tracker", ex); }
_shellCoreTracker = null;
}
private void DisposeProvisioningTracker()
{
if (_provisioningTracker == null) return;
try
{
_provisioningTracker.EspFailureDetected -= OnEspFailureDetected;
_provisioningTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete;
_provisioningTracker.Stop("tracker_stopped");
}
catch (Exception ex) { _logger.Error("Error stopping provisioning status tracker", ex); }
_provisioningTracker = null;
}
private void DisposeTracker<T>(ref T tracker, string name, Action<T> stopper) where T : class
{
if (tracker == null) return;
try { stopper(tracker); }
catch (Exception ex) { _logger.Error($"Error stopping {name} tracker", ex); }
tracker = null;
}
// =====================================================================
// Event forwarders
// =====================================================================
private void OnHelloCompleted(object sender, EventArgs e)
{
try { HelloCompleted?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("Error forwarding HelloCompleted event", ex); }
catch (Exception ex) { _logger.Error("Error forwarding HelloCompleted", ex); }
}
private void OnFinalizingSetupPhaseTriggered(object sender, string reason)
{
try { FinalizingSetupPhaseTriggered?.Invoke(this, reason); }
catch (Exception ex) { _logger.Error("Error forwarding FinalizingSetupPhaseTriggered", ex); }
}
private void OnWhiteGloveCompleted(object sender, EventArgs e)
{
try { WhiteGloveCompleted?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("Error forwarding WhiteGloveCompleted", ex); }
}
private void OnEspFailureDetected(object sender, string failureType)
{
try { EspFailureDetected?.Invoke(this, failureType); }
catch (Exception ex) { _logger.Error($"Error forwarding EspFailureDetected for '{failureType}'", ex); }
}
private void OnDeviceSetupProvisioningComplete(object sender, EventArgs e)
{
try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("Error forwarding DeviceSetupProvisioningComplete", ex); }
}
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring.Core;
using AutopilotMonitor.Agent.Core.Monitoring.Interop;
using AutopilotMonitor.Shared;
@@ -12,7 +13,7 @@ using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Partial: Watches ESP provisioning category status from registry to detect failures
/// Watches ESP provisioning category status from the registry to detect failures
/// that Shell-Core event 62407 patterns may miss (e.g. Certificate provisioning failures).
///
/// Registry path: HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings
@@ -22,20 +23,20 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// Flat: { "CertificatesSubcategory": "Certificates (1 of 1 applied)", "categorySucceeded": true, ... }
/// Nested: { "CertificatesSubcategory": { "subcategoryState": "succeeded", "subcategoryStatusText": "..." }, ... }
///
/// categorySucceeded (bool) appears only once the category has finalized.
/// Until then, subcategory states ("succeeded"/"failed"/...) provide progressive status.
///
/// Event emission strategy:
/// - Emit once when a category first appears in the registry (initial snapshot)
/// - Emit on every JSON change (progress updates like "Apps 1/5 → 2/5", state transitions)
/// - Emit once when categorySucceeded resolves to true or false (final outcome)
/// - Fire EspFailureDetected on subcategory failure even if categorySucceeded is still null
/// - Emit once when a category first appears (initial snapshot)
/// - Emit on every JSON change (progress updates, state transitions)
/// - Emit once when categorySucceeded resolves to true/false (final outcome)
/// - Fire <see cref="EspFailureDetected"/> on subcategory failure even if categorySucceeded is null
/// - Fire <see cref="DeviceSetupProvisioningComplete"/> on DeviceSetup success (or fallback)
///
/// Uses RegistryWatcher (RegNotifyChangeKeyValue) for instant registry change detection.
/// </summary>
public partial class EspAndHelloTracker
internal sealed class ProvisioningStatusTracker : IDisposable
{
private const string ProvisioningStatusRegistryPath = @"SOFTWARE\Microsoft\Provisioning\AutopilotSettings";
internal const string ProvisioningStatusRegistryPath = @"SOFTWARE\Microsoft\Provisioning\AutopilotSettings";
internal const int DeviceSetupFallbackDelaySeconds = 30;
internal const int ProvisioningDebounceMilliseconds = 1000;
private static readonly string[] ProvisioningCategoryNames =
{
@@ -44,6 +45,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
"AccountSetupCategory.Status"
};
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private RegistryWatcher _provisioningWatcher;
private Timer _provisioningWatcherRetryTimer;
private Timer _provisioningDebounceTimer;
private Timer _deviceSetupFallbackTimer;
// Track the raw JSON per category — used to detect any changes at all
private Dictionary<string, string> _lastProvisioningJson;
// Track which categories have been seen (for first-seen event)
@@ -58,15 +69,30 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
private Dictionary<string, Dictionary<string, string>> _lastSubcategoryStates;
// Fire-once guard for DeviceSetupProvisioningComplete event
private bool _deviceSetupProvisioningCompleteFired;
// Fallback: timer for when all subcategories succeeded but categorySucceeded is still null.
// Some Windows builds (e.g. 25H2/26200) never set the categorySucceeded boolean.
private Timer _deviceSetupFallbackTimer;
private const int DeviceSetupFallbackDelaySeconds = 30;
private const int ProvisioningDebounceMilliseconds = 1000;
private readonly object _stateLock = new object();
public event EventHandler<string> EspFailureDetected;
public event EventHandler DeviceSetupProvisioningComplete;
public ProvisioningStatusTracker(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
// =====================================================================
// Public snapshot / state API (forwarded by coordinator)
// =====================================================================
/// <summary>
/// Snapshot of current provisioning-category state for consumers like the signal-correlated
/// WhiteGlove detection. Thread-safe: copies the underlying dictionary under the state lock.
/// WhiteGlove detection. Thread-safe.
/// </summary>
public Dictionary<string, bool?> GetProvisioningCategorySnapshot()
{
@@ -115,7 +141,57 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
private void StartProvisioningStatusWatcher()
/// <summary>
/// Returns a thread-safe snapshot of the current ESP provisioning category status.
/// All data is deep-copied under the lock so the caller can use it freely.
/// Returns null if no provisioning data has been observed yet.
/// </summary>
public EspProvisioningSnapshot GetProvisioningSnapshot()
{
lock (_stateLock)
{
if (_provisioningCategorySeen == null || _provisioningCategorySeen.Count == 0)
return null;
var outcomes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var cat in _provisioningCategorySeen)
{
var label = cat.Replace("Category.Status", "");
if (_lastCategorySucceeded.TryGetValue(cat, out var succeeded))
{
outcomes[label] = succeeded == true ? "success"
: succeeded == false ? "failed"
: "in_progress";
}
else
{
outcomes[label] = "in_progress";
}
}
var subcats = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in _lastSubcategoryStates)
{
var label = kvp.Key.Replace("Category.Status", "");
subcats[label] = new Dictionary<string, string>(kvp.Value, StringComparer.OrdinalIgnoreCase);
}
return new EspProvisioningSnapshot
{
CategoryOutcomes = outcomes,
SubcategoryStates = subcats,
CategoriesSeen = _provisioningCategorySeen.Count,
CategoriesResolved = _provisioningCategoriesResolved.Count,
AllResolved = _provisioningCategoriesResolved.Count >= _provisioningCategorySeen.Count
};
}
}
// =====================================================================
// Lifecycle
// =====================================================================
public void Start()
{
_lastProvisioningJson = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
_provisioningCategorySeen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -127,13 +203,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_deviceSetupFallbackTimer = null;
// Try to start immediately; if key doesn't exist yet, retry every 2s
if (!TryStartProvisioningWatcher())
if (!TryStartWatcher())
{
_logger.Info("Provisioning status registry key not yet present — retrying every 2s");
_provisioningWatcherRetryTimer = new System.Threading.Timer(
_provisioningWatcherRetryTimer = new Timer(
_ =>
{
if (TryStartProvisioningWatcher())
if (TryStartWatcher())
{
_provisioningWatcherRetryTimer?.Dispose();
_provisioningWatcherRetryTimer = null;
@@ -145,59 +221,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
/// <summary>
/// Attempts to create and start the RegistryWatcher for the provisioning key.
/// Returns false if the key doesn't exist yet.
/// </summary>
private bool TryStartProvisioningWatcher()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath))
{
if (key == null)
return false;
}
// Capture initial state before watcher starts
_logger.Trace("ProvisioningWatcher: capturing initial state before watcher starts");
CheckProvisioningStatus();
// Debounce timer — coalesces rapid-fire registry notifications into a single check
_provisioningDebounceTimer = new Timer(
_ => CheckProvisioningStatus(),
null,
Timeout.Infinite,
Timeout.Infinite);
// Create and start watcher
_provisioningWatcher = new RegistryWatcher(
RegistryHive.LocalMachine,
ProvisioningStatusRegistryPath,
watchSubtree: true,
filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet,
trace: msg => _logger.Trace($"RegistryWatcher: {msg}"));
_provisioningWatcher.Changed += (s, e) =>
{
_logger.Trace("ProvisioningWatcher: Changed event fired — debouncing CheckProvisioningStatus");
_provisioningDebounceTimer?.Change(
ProvisioningDebounceMilliseconds, Timeout.Infinite);
};
_provisioningWatcher.Error += (s, ex) => _logger.Warning($"Provisioning watcher handler error: {ex.Message}");
_provisioningWatcher.Start();
_logger.Info("Provisioning status registry watcher started");
return true;
}
catch (Exception ex)
{
_logger.Warning($"Failed to start provisioning watcher: {ex.Message}");
return false;
}
}
private void StopProvisioningStatusWatcher(string reason)
public void Stop(string reason = "tracker_stopped")
{
try
{
@@ -223,6 +247,58 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
public void Dispose() => Stop();
private bool TryStartWatcher()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath))
{
if (key == null)
return false;
}
// Capture initial state before watcher starts
_logger.Trace("ProvisioningWatcher: capturing initial state before watcher starts");
CheckProvisioningStatus();
// Debounce timer — coalesces rapid-fire registry notifications into a single check
_provisioningDebounceTimer = new Timer(
_ => CheckProvisioningStatus(),
null,
Timeout.Infinite,
Timeout.Infinite);
_provisioningWatcher = new RegistryWatcher(
RegistryHive.LocalMachine,
ProvisioningStatusRegistryPath,
watchSubtree: true,
filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet,
trace: msg => _logger.Trace($"RegistryWatcher: {msg}"));
_provisioningWatcher.Changed += (s, e) =>
{
_logger.Trace("ProvisioningWatcher: Changed event fired — debouncing CheckProvisioningStatus");
_provisioningDebounceTimer?.Change(ProvisioningDebounceMilliseconds, Timeout.Infinite);
};
_provisioningWatcher.Error += (s, ex) => _logger.Warning($"Provisioning watcher handler error: {ex.Message}");
_provisioningWatcher.Start();
_logger.Info("Provisioning status registry watcher started");
return true;
}
catch (Exception ex)
{
_logger.Warning($"Failed to start provisioning watcher: {ex.Message}");
return false;
}
}
// =====================================================================
// Registry polling + processing
// =====================================================================
private void CheckProvisioningStatus()
{
try
@@ -239,9 +315,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
bool failureDetected = false;
string failureType = null;
int changedCount = 0;
int unchangedCount = 0;
int missingCount = 0;
int changedCount = 0, unchangedCount = 0, missingCount = 0;
foreach (var categoryName in ProvisioningCategoryNames)
{
@@ -253,7 +327,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
continue;
}
// Skip if JSON is identical to last check (no change at all)
if (_lastProvisioningJson.TryGetValue(categoryName, out var lastJson) && lastJson == jsonValue)
{
unchangedCount++;
@@ -280,7 +353,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
$"failuresFired={_provisioningFailureFired.Count}");
// Self-termination: only auto-stop when all categories resolved with success (no failures).
// Uses RequestStop() which is safe to call from within the Changed handler (non-blocking).
if (_provisioningCategoriesResolved.Count > 0
&& _lastProvisioningJson.Count > 0
&& _provisioningCategoriesResolved.Count >= _lastProvisioningJson.Count
@@ -290,42 +362,26 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_provisioningWatcher?.RequestStop();
}
// Fire EspFailureDetected AFTER event emission
// (matches the pattern in ShellCoreTracking.cs — event in spool before agent reacts)
// Fire EspFailureDetected AFTER event emission (event in spool before agent reacts)
if (failureDetected && failureType != null)
{
try
{
EspFailureDetected?.Invoke(this, failureType);
}
catch (Exception ex)
{
_logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex);
}
try { EspFailureDetected?.Invoke(this, failureType); }
catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); }
}
// Fire DeviceSetupProvisioningComplete when DeviceSetup category resolves with success.
// Used as completion signal for Self-Deploying mode where Shell-Core ESP exit
// and desktop arrival may never arrive.
// Fire DeviceSetupProvisioningComplete when DeviceSetup resolves with success.
if (!_deviceSetupProvisioningCompleteFired
&& _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var dsSucceeded)
&& dsSucceeded == true)
{
_deviceSetupProvisioningCompleteFired = true;
_logger.Info("Provisioning status: DeviceSetup succeeded — firing DeviceSetupProvisioningComplete");
try
{
DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.Error("DeviceSetupProvisioningComplete handler failed", ex);
}
try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("DeviceSetupProvisioningComplete handler failed", ex); }
}
// Fallback: All subcategories succeeded but categorySucceeded was never set by Windows.
// Fallback: all subcategories succeeded but categorySucceeded was never set by Windows.
// Some Windows builds (e.g. 25H2/26200) don't set the boolean even after all subcategories succeed.
// Wait DeviceSetupFallbackDelaySeconds to give Windows time, then treat as success if still consistent.
if (!_deviceSetupProvisioningCompleteFired
&& _deviceSetupFallbackTimer == null
&& _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var dsFallbackState)
@@ -353,11 +409,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
/// <summary>
/// Fallback timer callback: fires after DeviceSetupFallbackDelaySeconds when all subcategories
/// succeeded but categorySucceeded was never set by Windows. Re-reads registry to confirm,
/// emits a visible WARNING event for the admin timeline, then fires DeviceSetupProvisioningComplete.
/// </summary>
private void OnDeviceSetupFallbackTimerExpired(object state)
{
try
@@ -370,7 +421,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
return;
}
// Re-read registry to confirm state is still consistent
string registryJson = null;
using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath, writable: false))
{
@@ -383,7 +433,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
return;
}
// Parse and verify: categorySucceeded still null, all subcategories still succeeded
bool? categorySucceeded = null;
List<SubcategoryInfo> subcategories = null;
try
@@ -400,16 +449,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
return;
}
// If categorySucceeded was set in the meantime, let the normal path handle it
if (categorySucceeded.HasValue)
{
_logger.Info($"DeviceSetup fallback timer: categorySucceeded is now {categorySucceeded.Value} — normal path will handle this");
// Trigger a re-check so the normal path picks up the change
CheckProvisioningStatus();
return;
}
// Verify all subcategories are still succeeded/notRequired
if (subcategories == null || subcategories.Count == 0)
{
_logger.Warning("DeviceSetup fallback timer: no subcategories found — aborting fallback");
@@ -427,7 +473,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
return;
}
// All conditions confirmed after delay — dump raw JSON and emit visible WARNING event
EmitRawRegistryDump("DeviceSetup", registryJson, "fallback_confirmed");
_logger.Warning($"DeviceSetup fallback: all {subcategories.Count} subcategories succeeded but " +
@@ -466,14 +511,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
});
_deviceSetupProvisioningCompleteFired = true;
try
{
DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.Error("DeviceSetupProvisioningComplete handler failed (fallback path)", ex);
}
try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("DeviceSetupProvisioningComplete handler failed (fallback path)", ex); }
}
}
catch (Exception ex)
@@ -486,7 +525,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// Processes a single category status JSON value.
/// Emits events on first-seen, subcategory state transitions, and categorySucceeded resolution.
/// </summary>
private ProvisioningResult ProcessCategoryStatus(string categoryName, string jsonValue)
internal ProvisioningResult ProcessCategoryStatus(string categoryName, string jsonValue)
{
var categoryLabel = categoryName.Replace("Category.Status", "");
@@ -496,14 +535,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
var root = doc.RootElement;
// 1. Extract categorySucceeded — the authoritative outcome signal
bool? categorySucceeded = SafeGetBool(root, "categorySucceeded");
string categoryStatusMessage = SafeGetString(root, "categoryStatusMessage");
// 2. Parse subcategories — handles both flat strings and nested objects
var subcategories = ParseSubcategories(root);
// 3. Derive a meaningful status summary
string statusText;
if (categoryStatusMessage != null)
statusText = categoryStatusMessage;
@@ -514,7 +549,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
else
statusText = BuildProgressSummary(subcategories);
// 4. Decide whether to emit an event
bool isFirstSeen = !_provisioningCategorySeen.Contains(categoryName);
bool categorySucceededChanged = HasCategorySucceededChanged(categoryName, categorySucceeded);
@@ -542,8 +576,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
else
{
// Emit on every JSON change — the registry dedup ensures we only reach here
// when actual progress happened (e.g. "Apps 1/5 → 2/5", subcategory completions).
var transitions = DetectSubcategoryTransitions(categoryName, subcategories);
StoreSubcategoryStates(categoryName, subcategories);
@@ -566,24 +598,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
else
{
// JSON changed but no state transitions — progress text changed (e.g. "Apps 1/5 → 2/5")
_logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING progress update (JSON changed, no state transitions)");
EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Info);
}
}
// 5. Track resolved categories (both success and failure are final)
if (categorySucceeded.HasValue)
_provisioningCategoriesResolved.Add(categoryName);
// 6. Handle failure — either via categorySucceeded or subcategory state
if (categorySucceeded == false)
{
return TryFireProvisioningFailure(categoryName, categoryLabel, subcategories);
}
// 7. Check for subcategory-level failures even when categorySucceeded is still null
// (catches timeout scenarios where the category never formally fails)
var failedSub = FindFailedSubcategory(subcategories);
if (failedSub != null && categorySucceeded == null)
{
@@ -605,9 +632,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
/// <summary>
/// Attempts to fire EspFailureDetected for a provisioning failure. Uses fire-once guard.
/// </summary>
private ProvisioningResult TryFireProvisioningFailure(string categoryName, string categoryLabel, List<SubcategoryInfo> subcategories)
{
if (_provisioningFailureFired.Contains(categoryName))
@@ -625,22 +649,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
return ProvisioningResult.Failure(failureTypeName);
}
/// <summary>
/// Checks whether categorySucceeded has transitioned from null to true/false.
/// This ensures we only emit one event when the outcome is decided.
/// </summary>
private bool HasCategorySucceededChanged(string categoryName, bool? newValue)
{
if (!_lastCategorySucceeded.TryGetValue(categoryName, out var oldValue))
return false; // First-seen is handled separately
// Transition from null (in-progress) to true/false (resolved)
return false;
return oldValue != newValue;
}
/// <summary>
/// Stores current subcategory states for later transition detection.
/// </summary>
private void StoreSubcategoryStates(string categoryName, List<SubcategoryInfo> subcategories)
{
var states = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -649,31 +664,25 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_lastSubcategoryStates[categoryName] = states;
}
/// <summary>
/// Detects meaningful subcategory state transitions by comparing current states to last known states.
/// Meaningful transitions: any -> failed (Warning), in_progress -> succeeded (Info).
/// NOT meaningful: notStarted -> in_progress (expected progression, noise).
/// </summary>
private List<SubcategoryTransition> DetectSubcategoryTransitions(string categoryName, List<SubcategoryInfo> subcategories)
{
var transitions = new List<SubcategoryTransition>();
if (!_lastSubcategoryStates.TryGetValue(categoryName, out var lastStates))
return transitions; // No previous states to compare
return transitions;
foreach (var sub in subcategories)
{
if (!lastStates.TryGetValue(sub.Name, out var oldState))
continue; // New subcategory — skip (first-seen already emitted the snapshot)
continue;
if (string.Equals(oldState, sub.State, StringComparison.OrdinalIgnoreCase))
continue; // No change
continue;
bool isFailure = string.Equals(sub.State, "failed", StringComparison.OrdinalIgnoreCase);
bool isCompletion = string.Equals(sub.State, "succeeded", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(oldState, "succeeded", StringComparison.OrdinalIgnoreCase);
// Skip noise: notStarted -> in_progress is expected progression
bool isNoise = string.Equals(oldState, "notStarted", StringComparison.OrdinalIgnoreCase)
&& string.Equals(sub.State, "in_progress", StringComparison.OrdinalIgnoreCase);
@@ -683,7 +692,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
else if (!isNoise)
{
// Other non-noise transitions (e.g., unknown -> failed) — include if meaningful
if (isFailure)
transitions.Add(new SubcategoryTransition(sub.Name, oldState, sub.State, true));
}
@@ -748,11 +756,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_logger.Info($"Provisioning status event: {categoryLabel} — {statusText} (succeeded={succeeded?.ToString() ?? "in_progress"})");
}
/// <summary>
/// Emits the raw registry JSON as a Trace-level event for post-mortem format debugging.
/// Called at first-seen (initial snapshot) and at resolution (categorySucceeded change or fallback)
/// so we capture the registry format at the start and at the critical decision point.
/// </summary>
private void EmitRawRegistryDump(string categoryLabel, string rawJson, string trigger)
{
_onEventCollected(new EnrollmentEvent
@@ -774,20 +777,17 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
});
}
// ===== JSON Parsing Helpers =====
// =====================================================================
// JSON parsing helpers (static — exposed as internal for tests)
// =====================================================================
/// <summary>
/// Parses subcategory entries from the JSON. Handles both formats:
/// Flat: "CertificatesSubcategory": "Certificates (1 of 1 applied)"
/// Nested: "CertificatesSubcategory": { "subcategoryState": "succeeded", "subcategoryStatusText": "..." }
/// </summary>
private static List<SubcategoryInfo> ParseSubcategories(JsonElement root)
internal static List<SubcategoryInfo> ParseSubcategories(JsonElement root)
{
var result = new List<SubcategoryInfo>();
foreach (var prop in root.EnumerateObject())
{
if (!prop.Name.Contains("Subcategory", StringComparison.OrdinalIgnoreCase))
if (prop.Name.IndexOf("Subcategory", StringComparison.OrdinalIgnoreCase) < 0)
continue;
var name = CleanSubcategoryName(prop.Name);
@@ -795,7 +795,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
switch (prop.Value.ValueKind)
{
case JsonValueKind.String:
// Flat format: value is the status text, derive state from text content
var text = prop.Value.GetString() ?? "";
result.Add(new SubcategoryInfo
{
@@ -806,7 +805,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
break;
case JsonValueKind.Object:
// Nested format: { "subcategoryState": "...", "subcategoryStatusText": "..." }
result.Add(new SubcategoryInfo
{
Name = name,
@@ -816,7 +814,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
break;
default:
// Unknown format — include with raw value for debugging
result.Add(new SubcategoryInfo
{
Name = name,
@@ -835,16 +832,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// "AccountSetup.CertificatesSubcategory" -> "Certificates"
/// "SecurityPoliciesSubcategory" -> "SecurityPolicies"
/// </summary>
private static string CleanSubcategoryName(string rawName)
internal static string CleanSubcategoryName(string rawName)
{
var name = rawName;
// Remove "Subcategory" suffix
var idx = name.IndexOf("Subcategory", StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
name = name.Substring(0, idx);
// Remove category prefix (e.g. "AccountSetup.", "DeviceSetup.", "DevicePreparation.")
var dotIdx = name.LastIndexOf('.');
if (dotIdx >= 0 && dotIdx < name.Length - 1)
name = name.Substring(dotIdx + 1);
@@ -852,28 +847,24 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
return string.IsNullOrEmpty(name) ? rawName : name;
}
/// <summary>
/// Infers a state string from flat-format subcategory text.
/// Used when the JSON doesn't have explicit subcategoryState.
/// </summary>
private static string InferStateFromText(string text)
internal static string InferStateFromText(string text)
{
if (string.IsNullOrEmpty(text))
return "unknown";
if (text.Contains("Complete", StringComparison.OrdinalIgnoreCase)
|| text.Contains("applied", StringComparison.OrdinalIgnoreCase)
|| text.Contains("installed", StringComparison.OrdinalIgnoreCase)
|| text.Contains("added", StringComparison.OrdinalIgnoreCase)
|| text.Contains("No setup needed", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Identified", StringComparison.OrdinalIgnoreCase))
if (text.IndexOf("Complete", StringComparison.OrdinalIgnoreCase) >= 0
|| text.IndexOf("applied", StringComparison.OrdinalIgnoreCase) >= 0
|| text.IndexOf("installed", StringComparison.OrdinalIgnoreCase) >= 0
|| text.IndexOf("added", StringComparison.OrdinalIgnoreCase) >= 0
|| text.IndexOf("No setup needed", StringComparison.OrdinalIgnoreCase) >= 0
|| text.IndexOf("Identified", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "succeeded";
}
if (text.Contains("Error", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Failed", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Failure", StringComparison.OrdinalIgnoreCase))
if (text.IndexOf("Error", StringComparison.OrdinalIgnoreCase) >= 0
|| text.IndexOf("Failed", StringComparison.OrdinalIgnoreCase) >= 0
|| text.IndexOf("Failure", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "failed";
}
@@ -881,26 +872,17 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
return "in_progress";
}
/// <summary>
/// Finds the first subcategory that is in a failed state.
/// Returns the clean name, or null if none are failed.
/// </summary>
private static string FindFailedSubcategory(List<SubcategoryInfo> subcategories)
internal static string FindFailedSubcategory(List<SubcategoryInfo> subcategories)
{
foreach (var sub in subcategories)
{
if (string.Equals(sub.State, "failed", StringComparison.OrdinalIgnoreCase))
return sub.Name;
}
return null;
}
/// <summary>
/// Builds a human-readable progress summary from subcategory states.
/// E.g. "3 of 5 subcategories completed"
/// </summary>
private static string BuildProgressSummary(List<SubcategoryInfo> subcategories)
internal static string BuildProgressSummary(List<SubcategoryInfo> subcategories)
{
if (subcategories.Count == 0)
return "In progress";
@@ -918,11 +900,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
return $"{succeeded} of {subcategories.Count} subcategories completed";
}
/// <summary>
/// Safely extracts a boolean from a JSON property. Handles True, False, and string "true"/"false".
/// Returns null if the property doesn't exist or has an unexpected type.
/// </summary>
private static bool? SafeGetBool(JsonElement element, string propertyName)
internal static bool? SafeGetBool(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var prop))
return null;
@@ -937,10 +915,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
/// <summary>
/// Safely extracts a string from a JSON property. Returns null if missing or not a string.
/// </summary>
private static string SafeGetString(JsonElement element, string propertyName)
internal static string SafeGetString(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var prop))
return null;
@@ -948,16 +923,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null;
}
// ===== Internal Types =====
// =====================================================================
// Internal types
// =====================================================================
private class SubcategoryInfo
internal class SubcategoryInfo
{
public string Name { get; set; }
public string State { get; set; } // "succeeded", "failed", "in_progress", "unknown", "notRequired"
public string StatusText { get; set; } // Human-readable text
public string StatusText { get; set; }
}
private readonly struct SubcategoryTransition
internal readonly struct SubcategoryTransition
{
public readonly string SubcategoryName;
public readonly string OldState;
@@ -973,7 +950,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
private readonly struct ProvisioningResult
internal readonly struct ProvisioningResult
{
public readonly bool IsFailed;
public readonly string FailureType;
@@ -988,53 +965,35 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
public static ProvisioningResult Failure(string failureType) => new ProvisioningResult(true, failureType);
}
// ===== Public Snapshot API =====
// =====================================================================
// Test seams
// =====================================================================
/// <summary>
/// Returns a thread-safe snapshot of the current ESP provisioning category status.
/// All data is deep-copied under _stateLock so the caller can use it freely.
/// Returns null if no provisioning data has been observed yet.
/// </summary>
public EspProvisioningSnapshot GetProvisioningSnapshot()
/// <summary>Test-only: drive ProcessCategoryStatus without registry access.</summary>
internal ProvisioningResult ProcessCategoryStatusForTest(string categoryName, string jsonValue)
{
lock (_stateLock)
{
if (_provisioningCategorySeen == null || _provisioningCategorySeen.Count == 0)
return null;
var outcomes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var cat in _provisioningCategorySeen)
{
var label = cat.Replace("Category.Status", "");
if (_lastCategorySucceeded.TryGetValue(cat, out var succeeded))
{
outcomes[label] = succeeded == true ? "success"
: succeeded == false ? "failed"
: "in_progress";
}
else
{
outcomes[label] = "in_progress";
}
}
var subcats = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in _lastSubcategoryStates)
{
var label = kvp.Key.Replace("Category.Status", "");
subcats[label] = new Dictionary<string, string>(kvp.Value, StringComparer.OrdinalIgnoreCase);
}
return new EspProvisioningSnapshot
{
CategoryOutcomes = outcomes,
SubcategoryStates = subcats,
CategoriesSeen = _provisioningCategorySeen.Count,
CategoriesResolved = _provisioningCategoriesResolved.Count,
AllResolved = _provisioningCategoriesResolved.Count >= _provisioningCategorySeen.Count
};
EnsureStateDictionariesInitialized();
return ProcessCategoryStatus(categoryName, jsonValue);
}
}
private void EnsureStateDictionariesInitialized()
{
if (_lastProvisioningJson == null)
_lastProvisioningJson = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (_provisioningCategorySeen == null)
_provisioningCategorySeen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (_lastCategorySucceeded == null)
_lastCategorySucceeded = new Dictionary<string, bool?>(StringComparer.OrdinalIgnoreCase);
if (_provisioningFailureFired == null)
_provisioningFailureFired = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (_provisioningCategoriesResolved == null)
_provisioningCategoriesResolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (_lastSubcategoryStates == null)
_lastSubcategoryStates = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
}
}
/// <summary>
@@ -0,0 +1,382 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.Text.RegularExpressions;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Watches Microsoft-Windows-Shell-Core/Operational for ESP-related events:
/// 62404 — CloudExperienceHost Web App Activity Started (CXID: 'AADHello' / 'NGC' = Hello wizard)
/// 62407 — CloudExperienceHost Web App Event 2:
/// CommercialOOBE_ESPProgress_Page_Exiting — normal ESP exit
/// CommercialOOBE_ESPProgress_WhiteGlove_Success — WhiteGlove complete
/// CommercialOOBE_ESPProgress_Failure/_Timeout/_Abort/WhiteGlove_Failed — ESP failure
///
/// Raises <see cref="FinalizingSetupPhaseTriggered"/>, <see cref="WhiteGloveCompleted"/>,
/// and <see cref="EspFailureDetected"/>. Cross-notifies the <see cref="HelloTracker"/>
/// on Hello wizard start and ESP exit so Hello timers can react.
/// </summary>
internal sealed class ShellCoreTracker : IDisposable
{
internal const string ShellCoreEventLogChannel = "Microsoft-Windows-Shell-Core/Operational";
internal const int EventId_ShellCore_WebAppStarted = 62404;
internal const int EventId_ShellCore_WebAppEvent = 62407;
internal const int BackfillLookbackMinutes = 5;
private static readonly HashSet<int> TrackedShellCoreEventIds = new HashSet<int>
{
EventId_ShellCore_WebAppStarted,
EventId_ShellCore_WebAppEvent
};
private static readonly Regex EspExitingPattern = new Regex(
@"OOBE_ESP.*Exiting", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly HelloTracker _helloTracker;
private EventLogWatcher _watcher;
private bool _espExitDetected;
private bool _whiteGloveDetected;
private readonly object _stateLock = new object();
public event EventHandler<string> FinalizingSetupPhaseTriggered;
public event EventHandler WhiteGloveCompleted;
public event EventHandler<string> EspFailureDetected;
public ShellCoreTracker(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
HelloTracker helloTracker)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_helloTracker = helloTracker; // nullable — HelloTracker may be unavailable in some test setups
}
internal bool IsEspExitedForTest { get { lock (_stateLock) { return _espExitDetected; } } }
internal bool IsWhiteGloveDetectedForTest { get { lock (_stateLock) { return _whiteGloveDetected; } } }
public void Start()
{
try
{
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
"*[System[(EventID=62404 or EventID=62407)]]");
_watcher = new EventLogWatcher(query);
_watcher.EventRecordWritten += OnEventRecordWritten;
_watcher.Enabled = true;
_logger.Info($"Started watching: {ShellCoreEventLogChannel}");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)");
}
catch (Exception ex)
{
_logger.Error("Failed to start Shell-Core event log watcher", ex);
}
}
public void Stop()
{
if (_watcher == null) return;
try
{
_watcher.Enabled = false;
_watcher.EventRecordWritten -= OnEventRecordWritten;
_watcher.Dispose();
}
catch (Exception ex) { _logger.Error("Error stopping Shell-Core event watcher", ex); }
finally { _watcher = null; }
}
public void Dispose() => Stop();
// =====================================================================
// Live event handler
// =====================================================================
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null) return;
try
{
var record = e.EventRecord;
if (!TrackedShellCoreEventIds.Contains(record.Id)) return;
var description = record.FormatDescription() ?? $"Event ID {record.Id}";
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
ProcessEvent(record.Id, description, timestamp, record.ProviderName ?? "", isBackfill: false);
}
catch (Exception ex)
{
_logger.Error("Error processing Shell-Core event record", ex);
}
}
/// <summary>
/// Core event-processing logic. Exposed as internal so tests can drive it without
/// needing to synthesize an <see cref="EventRecord"/> (abstract + Windows-only).
/// </summary>
internal void ProcessEvent(int eventId, string description, DateTime timestamp, string providerName, bool isBackfill)
{
string eventType;
EventSeverity severity = EventSeverity.Info;
string message;
bool triggerFinalizingSetup = false;
string finalizingSetupReason = null;
string detectedFailureType = null;
switch (eventId)
{
case EventId_ShellCore_WebAppStarted: // 62404
if (description.Contains("AADHello") || description.Contains("'NGC'"))
{
eventType = "hello_wizard_started";
message = "Windows Hello wizard started (CloudExperienceHost)";
triggerFinalizingSetup = true;
finalizingSetupReason = "hello_wizard_started";
_helloTracker?.NotifyHelloWizardStarted();
_logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404");
}
else
{
return;
}
break;
case EventId_ShellCore_WebAppEvent: // 62407
if (description.IndexOf("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase) >= 0)
{
// Fire-once guard
lock (_stateLock)
{
if (_whiteGloveDetected) return;
_whiteGloveDetected = true;
}
eventType = "whiteglove_complete";
message = "WhiteGlove (Pre-Provisioning) completed successfully";
// No FinalizingSetup transition — WhiteGlove terminates pre-provisioning entirely
_logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407");
}
else if (HasEspFailurePattern(description))
{
detectedFailureType = ExtractEspFailureType(description);
eventType = "esp_failure";
severity = EventSeverity.Error;
message = $"ESP (Enrollment Status Page) reported a failure: {detectedFailureType}";
_logger.Warning($"ESP failure detected via Shell-Core event 62407: {detectedFailureType}");
}
else if (EspExitingPattern.IsMatch(description))
{
eventType = "esp_exiting";
message = "ESP (Enrollment Status Page) phase exiting";
triggerFinalizingSetup = true;
finalizingSetupReason = "esp_exiting";
lock (_stateLock)
{
_espExitDetected = true;
// Note: We do NOT start the Hello wait timer here!
// Event 62407 occurs at every ESP phase transition (Device->Account, Account->End)
// EnrollmentTracker will decide based on lastEspPhase whether to start the timer
}
_helloTracker?.NotifyEspExited();
_logger.Info("ESP phase exit detected - detected via Shell-Core event 62407");
}
else
{
return;
}
break;
default:
return;
}
var eventData = new Dictionary<string, object>
{
{ "windowsEventId", eventId },
{ "providerName", providerName ?? "" },
{ "description", description },
{ "eventLogChannel", ShellCoreEventLogChannel },
{ "eventTime", timestamp.ToString("o") }
};
if (eventType == "esp_failure" && detectedFailureType != null)
{
eventData["failureType"] = detectedFailureType;
}
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = timestamp,
EventType = eventType,
Severity = severity,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = eventData,
ImmediateUpload = true
});
_logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})");
if (triggerFinalizingSetup)
{
try { FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason); }
catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); }
}
// Fire WhiteGloveCompleted AFTER event emission so the whiteglove_complete event
// is in the spool before the agent exits.
if (eventType == "whiteglove_complete")
{
try { WhiteGloveCompleted?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("WhiteGloveCompleted handler failed", ex); }
}
// Fire EspFailureDetected AFTER event emission so the esp_failure event is in the
// spool before the agent potentially shuts down.
if (eventType == "esp_failure" && detectedFailureType != null)
{
try { EspFailureDetected?.Invoke(this, detectedFailureType); }
catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{detectedFailureType}'", ex); }
}
}
// =====================================================================
// Backfill (public — called by coordinator)
// =====================================================================
/// <summary>
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
/// Secondary recovery mechanism when state persistence is unavailable.
/// </summary>
public void BackfillRecentEspExitEvents()
{
try
{
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
$"*[System[(EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
using (var reader = new EventLogReader(query))
{
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
var description = record.FormatDescription() ?? "";
HandleBackfillRecord(description);
}
}
}
}
catch (Exception ex)
{
_logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}");
}
}
/// <summary>
/// Internal backfill record handler — extracted for testability and to keep the
/// backfill loop free of direct event-processing logic.
/// </summary>
internal void HandleBackfillRecord(string description)
{
if (EspExitingPattern.IsMatch(description))
{
bool shouldNotify = false;
lock (_stateLock)
{
if (!_espExitDetected)
{
_espExitDetected = true;
shouldNotify = true;
}
}
if (shouldNotify)
{
_helloTracker?.NotifyEspExited();
_logger.Info("Backfill: ESP exit event found in recent Shell-Core logs");
try { FinalizingSetupPhaseTriggered?.Invoke(this, "esp_exiting"); }
catch (Exception ex) { _logger.Error("Backfill: FinalizingSetupPhaseTriggered handler failed", ex); }
}
}
if (HasEspFailurePattern(description))
{
var failureType = ExtractEspFailureType(description);
_logger.Info($"Backfill: ESP failure event found in recent Shell-Core logs: {failureType}");
try { EspFailureDetected?.Invoke(this, failureType); }
catch (Exception ex) { _logger.Error($"Backfill: EspFailureDetected handler failed for '{failureType}'", ex); }
}
}
// =====================================================================
// Pattern helpers
// =====================================================================
internal static bool HasEspFailurePattern(string description)
{
return description.IndexOf("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase) >= 0
|| description.IndexOf("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase) >= 0
|| description.IndexOf("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase) >= 0
|| description.IndexOf("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase) >= 0
|| description.IndexOf("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase) >= 0
|| description.IndexOf("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase) >= 0;
}
/// <summary>
/// Extracts a structured failure type from the Shell-Core event description.
/// Returns e.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed",
/// or "Unknown_ESP_Failure" as a fallback.
/// </summary>
internal static string ExtractEspFailureType(string description)
{
string[] knownTypes = {
"ESPProgress_Failure",
"ESPProgress_Failed",
"ESPProgress_Timeout",
"ESPProgress_Abort",
"WhiteGlove_Failed",
"WhiteGlove_Failure"
};
foreach (var type in knownTypes)
{
if (description.IndexOf(type, StringComparison.OrdinalIgnoreCase) >= 0)
return type;
}
return "Unknown_ESP_Failure";
}
}
}