diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs
deleted file mode 100644
index 6aeb46fb..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs
+++ /dev/null
@@ -1,96 +0,0 @@
-using System;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
-{
- ///
- /// Pure-function guards for enrollment completion decisions.
- /// Extracted 1:1 from the lock block in TryEmitEnrollmentComplete() for testability.
- /// Each method mirrors the exact condition evaluated in CompletionLogic.cs.
- ///
- internal static class CompletionGuards
- {
- ///
- /// Guard 1: Hello resolution check.
- /// Hello is considered resolved when:
- /// - No EspAndHelloTracker exists (no Hello tracking)
- /// - Hello has already completed (success, skip, or timeout)
- /// - Hello policy is not configured (Hello disabled)
- /// - Deployment is device-only (no interactive user session)
- ///
- ///
- /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
- /// line: helloResolved = _espAndHelloTracker == null || ...IsHelloCompleted || !...IsPolicyConfigured || isDeviceOnly
- ///
- public static bool IsHelloResolved(bool hasTracker, bool isHelloCompleted,
- bool isPolicyConfigured, bool isDeviceOnly)
- {
- return !hasTracker
- || isHelloCompleted
- || !isPolicyConfigured
- || isDeviceOnly;
- }
-
- ///
- /// Guard 2: ESP gate for desktop-based completion sources.
- /// Blocks completion when:
- /// - Source is desktop_arrival or desktop_hello
- /// - Enrollment type is v1 (not WDP)
- /// - ESP has been seen but has NOT exited yet
- /// Rationale: In v1 enrollments with active ESP, desktop presence alone is
- /// NOT sufficient to complete (AccountSetup may still be running).
- ///
- ///
- /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
- /// line: espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") && enrollmentType != "v2" && espEverSeen && !espFinalExitSeen
- ///
- public static bool IsEspGateBlocking(string source, string enrollmentType,
- bool espEverSeen, bool espFinalExitSeen)
- {
- return (source == "desktop_arrival" || source == "desktop_hello")
- && enrollmentType != "v2"
- && espEverSeen
- && !espFinalExitSeen;
- }
-
- ///
- /// Guard 3: Hybrid join reboot gate.
- /// In hybrid join, ESP may exit for a mid-enrollment reboot (domain user login required).
- /// Blocks esp_hello_composite unless we have stronger confirmation:
- /// - IME user session completed (imePatternSeenUtc has value)
- /// - Agent restarted after ESP exit (agentStartTimeUtc > espFinalExitUtc)
- ///
- ///
- /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
- /// lines: if (_isHybridJoin && source == "esp_hello_composite") { ... hybridRebootGateBlocked = !hasImeCompletion && !agentRestartedAfterEspExit; }
- ///
- public static bool IsHybridRebootGateBlocking(bool isHybridJoin, string source,
- DateTime? imePatternSeenUtc, DateTime? espFinalExitUtc, DateTime agentStartTimeUtc)
- {
- if (!isHybridJoin || source != "esp_hello_composite")
- return false;
-
- bool hasImeCompletion = imePatternSeenUtc.HasValue;
- bool agentRestartedAfterEspExit = espFinalExitUtc.HasValue
- && agentStartTimeUtc > espFinalExitUtc.Value;
-
- return !hasImeCompletion && !agentRestartedAfterEspExit;
- }
-
- ///
- /// Deployment classification: device-only check.
- /// A deployment is device-only when:
- /// - Self-Deploying mode (autopilotMode == 1), OR
- /// - SkipUserStatusPage == true AND no user has AAD-joined
- ///
- ///
- /// Source: EnrollmentTracker.cs, property IsDeviceOnlyDeployment
- /// line: IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser)
- ///
- public static bool IsDeviceOnlyDeployment(int? autopilotMode, bool? skipUserStatusPage,
- bool aadJoinedWithUser)
- {
- bool isSelfDeploying = autopilotMode == 1;
- return isSelfDeploying || (skipUserStatusPage == true && !aadJoinedWithUser);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs
deleted file mode 100644
index ca91b1cf..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs
+++ /dev/null
@@ -1,820 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
-{
- ///
- /// Read-only snapshot of all state needed for guard evaluation and transition decisions.
- /// Populated under lock in the caller, consumed outside lock by the state machine.
- /// Preserves the existing thread-safety pattern from EnrollmentTracker.
- ///
- internal class CompletionContext
- {
- // Deployment classification
- public string EnrollmentType { get; set; }
- public int? AutopilotMode { get; set; }
- public bool? SkipUserStatusPage { get; set; }
- public bool AadJoinedWithUser { get; set; }
- public bool IsHybridJoin { get; set; }
-
- // Computed deployment classification (mirrors IsDeviceOnlyDeployment property)
- public bool IsDeviceOnly => CompletionGuards.IsDeviceOnlyDeployment(
- AutopilotMode, SkipUserStatusPage, AadJoinedWithUser);
-
- // Signal state
- public bool EspEverSeen { get; set; }
- public bool EspFinalExitSeen { get; set; }
- public bool DesktopArrived { get; set; }
- public string LastEspPhase { get; set; }
- public bool DeviceInfoCollected { get; set; }
-
- // Hello state (from EspAndHelloTracker)
- public bool HasHelloTracker { get; set; }
- public bool IsHelloCompleted { get; set; }
- public bool IsHelloPolicyConfigured { get; set; }
- public bool HasUnresolvedEspCategories { get; set; }
- public bool HasAccountSetupActivity { get; set; }
-
- // WhiteGlove detection signals
- public bool WhiteGloveStartDetected { get; set; }
- public bool HasSaveWhiteGloveSuccessResult { get; set; }
- public bool ShellCoreWhiteGloveSuccess { get; set; }
- public bool IsFooUserDetected { get; set; }
-
- // Legacy helper — kept for backwards-compat with places that only need a binary
- // "there is some WG evidence" read. Actual classification now goes through
- // .
- public bool IsWhiteGloveActive => HasSaveWhiteGloveSuccessResult;
-
- // Computed: has the agent restarted since ESP final exit? Typical WhiteGlove Part 1
- // boundary marker (the restart between DeviceSetup-complete and user sign-in).
- public bool AgentRestartedAfterEspExit
- => EspFinalExitUtc.HasValue && AgentStartTimeUtc > EspFinalExitUtc.Value;
-
- // Timing
- public DateTime AgentStartTimeUtc { get; set; }
- public DateTime? EspFinalExitUtc { get; set; }
- public DateTime? ImePatternSeenUtc { get; set; }
-
- // Trigger metadata
- public string Source { get; set; }
- }
-
- ///
- /// Result of a state machine transition. Contains the new state and metadata
- /// about what actions the caller should take (emit events, start timers, etc.).
- /// The state machine itself is side-effect-free — all actions are communicated
- /// via this result object to preserve the lock-free emission pattern.
- ///
- internal class TransitionResult
- {
- public static readonly TransitionResult NoTransition = new TransitionResult
- {
- Transitioned = false,
- NewState = EnrollmentCompletionState.Idle
- };
-
- public bool Transitioned { get; set; }
- public EnrollmentCompletionState NewState { get; set; }
- public EnrollmentCompletionState PreviousState { get; set; }
- public string CompletionSource { get; set; }
-
- // Action flags — caller executes these OUTSIDE lock
- public bool ShouldEmitEnrollmentComplete { get; set; }
- public bool ShouldEmitEnrollmentFailed { get; set; }
- public bool ShouldEmitWhiteGloveComplete { get; set; }
- public bool ShouldStartHelloWaitTimer { get; set; }
- public bool ShouldStartHelloSafetyTimer { get; set; }
- public bool ShouldStartEspSettleTimer { get; set; }
- public bool ShouldStartDeviceOnlyEspTimer { get; set; }
- public bool ShouldStartDeviceOnlySafetyTimer { get; set; }
- public bool ShouldForceMarkHelloCompleted { get; set; }
- public bool ShouldResetEspForResumption { get; set; }
- public bool ShouldPersistState { get; set; }
- public string FailureType { get; set; }
- public string DeferredSource { get; set; }
-
- // Signals to record
- public List SignalsToRecord { get; set; }
- }
-
- ///
- /// Explicit state machine for enrollment completion logic.
- /// Replaces the implicit boolean-flag-based state tracking in CompletionLogic.cs.
- ///
- /// Thread safety: The ProcessTrigger method is thread-safe. State reads and transitions
- /// happen under a single lock (microsecond-level critical sections). All side effects
- /// (event emission, timer management) are communicated via TransitionResult and executed
- /// by the caller OUTSIDE the lock — identical pattern to the original code.
- ///
- /// This class is intentionally side-effect-free: it computes state transitions and
- /// returns action flags. The caller (EnrollmentTracker) owns the actual side effects.
- ///
- internal class CompletionStateMachine
- {
- private readonly object _lock = new object();
- private EnrollmentCompletionState _currentState = EnrollmentCompletionState.Idle;
- private string _deferredSource;
-
- // Signal state (mirrored from EnrollmentTracker fields)
- private bool _espEverSeen;
- private bool _espFinalExitSeen;
- private bool _desktopArrived;
- private bool _isWaitingForHello;
- private bool _isWaitingForEspSettle;
-
- public EnrollmentCompletionState CurrentState
- {
- get { lock (_lock) { return _currentState; } }
- }
-
- // Expose internal state for shadow-mode comparison and persistence
- internal bool EspEverSeen { get { lock (_lock) { return _espEverSeen; } } }
- internal bool EspFinalExitSeen { get { lock (_lock) { return _espFinalExitSeen; } } }
- internal bool DesktopArrived { get { lock (_lock) { return _desktopArrived; } } }
-
- ///
- /// Reconstructs state from persisted EnrollmentStateData (crash recovery).
- /// Must be called before Start() if recovering from a crash.
- ///
- public void RestoreState(EnrollmentCompletionState state, bool espEverSeen,
- bool espFinalExitSeen, bool desktopArrived, bool isWaitingForHello,
- bool isWaitingForEspSettle, string deferredSource)
- {
- lock (_lock)
- {
- _currentState = state;
- _espEverSeen = espEverSeen;
- _espFinalExitSeen = espFinalExitSeen;
- _desktopArrived = desktopArrived;
- _isWaitingForHello = isWaitingForHello;
- _isWaitingForEspSettle = isWaitingForEspSettle;
- _deferredSource = deferredSource;
- }
- }
-
- ///
- /// Reconstructs the explicit state from boolean flags (backwards compatibility).
- /// Used when loading state from an old-format persistence file that doesn't
- /// contain the CompletionState field.
- ///
- public static EnrollmentCompletionState ReconstructStateFromFlags(
- bool enrollmentCompleteEmitted, bool isWaitingForHello, bool isWaitingForEspSettle,
- bool espFinalExitSeen, bool desktopArrived, bool espEverSeen,
- bool isDeviceOnly)
- {
- if (enrollmentCompleteEmitted)
- return EnrollmentCompletionState.Completed;
- if (isWaitingForHello)
- return EnrollmentCompletionState.WaitingForHello;
- if (isWaitingForEspSettle)
- return EnrollmentCompletionState.WaitingForEspSettle;
- if (isDeviceOnly && espFinalExitSeen)
- return EnrollmentCompletionState.DeviceOnlyAwaitingCompletion;
- if (espFinalExitSeen && desktopArrived)
- return EnrollmentCompletionState.DesktopArrivedAwaitingHello;
- if (espFinalExitSeen)
- return EnrollmentCompletionState.EspExitedAwaitingCompletion;
- if (espEverSeen && desktopArrived)
- return EnrollmentCompletionState.DesktopArrivedEspBlocking;
- if (espEverSeen)
- return EnrollmentCompletionState.EspActive;
- if (desktopArrived)
- return EnrollmentCompletionState.DesktopArrivedAwaitingHello;
- return EnrollmentCompletionState.Idle;
- }
-
- ///
- /// Core state machine: processes a trigger against the current state and context.
- /// Returns a TransitionResult describing what happened and what actions to take.
- ///
- /// The state transition happens atomically under lock.
- /// All side-effect instructions are in the returned result — caller executes them outside lock.
- ///
- public TransitionResult ProcessTrigger(string trigger, CompletionContext ctx)
- {
- if (string.IsNullOrEmpty(trigger))
- return TransitionResult.NoTransition;
-
- lock (_lock)
- {
- // Sync internal signal flags from the authoritative tracker snapshot.
- // Monoton false→true: these signals are stable-once-seen. The single
- // intentional reset (HandleEspResumed clearing _espFinalExitSeen) runs
- // AFTER this sync inside its own switch case, so it remains the last writer.
- // Without this, the SM can drift from the tracker (e.g. when
- // _stateData.EspEverSeen=true was set in memory but Save() hadn't run
- // before a reboot — restore loads false and downstream guards misfire).
- if (ctx != null)
- {
- if (ctx.EspEverSeen) _espEverSeen = true;
- if (ctx.EspFinalExitSeen) _espFinalExitSeen = true;
- if (ctx.DesktopArrived) _desktopArrived = true;
- }
-
- // Terminal states: no transitions allowed
- if (_currentState.IsTerminal())
- return TransitionResult.NoTransition;
-
- switch (trigger)
- {
- case "esp_phase_changed":
- return HandleEspPhaseChanged(ctx);
-
- case "esp_exiting":
- return HandleEspExiting(ctx);
-
- case "desktop_arrived":
- return HandleDesktopArrived(ctx);
-
- case "hello_completed":
- return HandleHelloCompleted(ctx);
-
- case "ime_user_session_completed":
- return HandleImeUserSessionCompleted(ctx);
-
- case "device_setup_provisioning_complete":
- return HandleDeviceSetupProvisioningComplete(ctx);
-
- case "hello_safety_timeout":
- return HandleHelloSafetyTimeout(ctx);
-
- case "esp_settle_timeout":
- return HandleEspSettleTimeout(ctx);
-
- case "device_only_esp_timer_expired":
- return HandleDeviceOnlyEspTimerExpired(ctx);
-
- case "device_only_safety_timeout":
- return HandleDeviceOnlySafetyTimeout(ctx);
-
- case "esp_failure_terminal":
- return HandleEspFailureTerminal(ctx);
-
- case "esp_failure_grace_expired":
- return HandleEspFailureTerminal(ctx);
-
- case "whiteglove_complete":
- return HandleWhiteGloveComplete(ctx);
-
- case "esp_resumed":
- return HandleEspResumed(ctx);
-
- case "device_info_collected":
- return HandleDeviceInfoCollected(ctx);
-
- default:
- return TransitionResult.NoTransition;
- }
- }
- }
-
- // ===== Trigger Handlers (all called under _lock) =====
-
- private TransitionResult HandleEspPhaseChanged(CompletionContext ctx)
- {
- if (_currentState != EnrollmentCompletionState.Idle
- && _currentState != EnrollmentCompletionState.EspActive)
- return TransitionResult.NoTransition;
-
- var prev = _currentState;
- _espEverSeen = true;
- _currentState = EnrollmentCompletionState.EspActive;
-
- return new TransitionResult
- {
- Transitioned = prev != _currentState,
- PreviousState = prev,
- NewState = _currentState,
- SignalsToRecord = new List { "esp_phase_changed" }
- };
- }
-
- private TransitionResult HandleEspExiting(CompletionContext ctx)
- {
- // ESP exiting is only relevant when ESP has been seen
- if (!_espEverSeen)
- return TransitionResult.NoTransition;
-
- var prev = _currentState;
- var result = new TransitionResult { PreviousState = prev, SignalsToRecord = new List() };
-
- // Path A: AccountSetup completed, desktop already arrived, or provisioning registry confirms AccountSetup
- bool accountSetupFromProvisioning = ctx.LastEspPhase == null && ctx.HasAccountSetupActivity;
- if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)
- || _desktopArrived
- || accountSetupFromProvisioning)
- {
- _espFinalExitSeen = true;
- _currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion;
- result.SignalsToRecord.Add("esp_final_exit");
- result.ShouldStartHelloWaitTimer = true;
- result.ShouldPersistState = true;
-
- // Check if we can complete immediately
- if (CanComplete(ctx, "esp_hello_composite"))
- {
- _currentState = EnrollmentCompletionState.Completed;
- result.ShouldEmitEnrollmentComplete = true;
- result.CompletionSource = "esp_hello_composite";
- }
- else if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking(
- ctx.IsHybridJoin, "esp_hello_composite",
- ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc))
- {
- _currentState = EnrollmentCompletionState.HybridRebootGateBlocked;
- }
- }
- // Path B: Registry definitively says device-only
- else if (ctx.SkipUserStatusPage == true)
- {
- // WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as
- // HandleDeviceSetupProvisioningComplete and the EnrollmentTracker paths).
- // Weak confidence falls through to the regular device-only path by design.
- var wgSignals = WhiteGloveSignals.FromContext(ctx);
- var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
- if (wgClassification.ShouldRouteToWhiteGlovePart1)
- {
- _currentState = EnrollmentCompletionState.WhiteGloveCompleted;
- result.ShouldEmitWhiteGloveComplete = true;
- result.SignalsToRecord.Add("whiteglove_guard_esp_exiting");
- result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}");
- result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}");
- result.Transitioned = true;
- result.NewState = _currentState;
- return result;
- }
-
- _espFinalExitSeen = true;
- _currentState = EnrollmentCompletionState.DeviceOnlyAwaitingCompletion;
- result.SignalsToRecord.Add("device_only_esp_registry");
- result.ShouldStartHelloWaitTimer = true;
- result.ShouldPersistState = true;
-
- if (CanComplete(ctx, "device_only_esp_registry"))
- {
- _currentState = EnrollmentCompletionState.Completed;
- result.ShouldEmitEnrollmentComplete = true;
- result.CompletionSource = "device_only_esp_registry";
- }
- else
- {
- _currentState = EnrollmentCompletionState.DeviceOnlySafetyWait;
- result.ShouldStartDeviceOnlySafetyTimer = true;
- }
- }
- // Path C: Unknown — start device-only detection timer
- else
- {
- result.ShouldStartDeviceOnlyEspTimer = true;
- // State stays as-is (EspActive or DesktopArrivedEspBlocking)
- }
-
- result.Transitioned = prev != _currentState;
- result.NewState = _currentState;
- return result;
- }
-
- private TransitionResult HandleDesktopArrived(CompletionContext ctx)
- {
- if (_desktopArrived)
- return TransitionResult.NoTransition;
-
- var prev = _currentState;
- _desktopArrived = true;
-
- var result = new TransitionResult
- {
- PreviousState = prev,
- ShouldPersistState = true,
- SignalsToRecord = new List { "desktop_arrived" }
- };
-
- // SkipUserStatusPage handling: if ESP seen and SkipUser=true but no final exit yet
- if (_espEverSeen && ctx.SkipUserStatusPage == true && !_espFinalExitSeen)
- {
- _espFinalExitSeen = true;
- result.SignalsToRecord.Add("desktop_arrived_skip_user");
- }
-
- // Determine target state based on ESP status
- if (_espEverSeen && !_espFinalExitSeen)
- {
- // ESP still active — gate blocks
- _currentState = EnrollmentCompletionState.DesktopArrivedEspBlocking;
- }
- else
- {
- // No ESP blocking — can proceed toward completion
- _currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello;
-
- // Start Hello wait timer if needed (no active ESP, not device-only)
- if (!ctx.IsDeviceOnly && ctx.HasHelloTracker && !ctx.IsHelloCompleted)
- {
- result.ShouldStartHelloWaitTimer = true;
- }
-
- // Try immediate completion
- if (CanComplete(ctx, "desktop_arrival"))
- {
- _currentState = EnrollmentCompletionState.Completed;
- result.ShouldEmitEnrollmentComplete = true;
- result.CompletionSource = "desktop_arrival";
- }
- }
-
- result.Transitioned = prev != _currentState;
- result.NewState = _currentState;
- return result;
- }
-
- private TransitionResult HandleHelloCompleted(CompletionContext ctx)
- {
- var prev = _currentState;
- var result = new TransitionResult
- {
- PreviousState = prev,
- SignalsToRecord = new List { "hello_resolved" }
- };
-
- if (_isWaitingForHello)
- {
- // IME path: was waiting for Hello
- _isWaitingForHello = false;
- if (CanCompleteWithHelloResolved(ctx, "ime_hello"))
- {
- _currentState = EnrollmentCompletionState.Completed;
- result.ShouldEmitEnrollmentComplete = true;
- result.CompletionSource = "ime_hello";
- }
- }
- else if (_espFinalExitSeen)
- {
- // ESP composite path
- string source = "esp_hello_composite";
- if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking(
- ctx.IsHybridJoin, source,
- ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc))
- {
- _currentState = EnrollmentCompletionState.HybridRebootGateBlocked;
- }
- else
- {
- _currentState = EnrollmentCompletionState.Completed;
- result.ShouldEmitEnrollmentComplete = true;
- result.CompletionSource = source;
- }
- }
- else if (_desktopArrived)
- {
- // Desktop + Hello path
- string source = "desktop_hello";
- if (!CompletionGuards.IsEspGateBlocking(source, ctx.EnrollmentType,
- _espEverSeen, _espFinalExitSeen))
- {
- _currentState = EnrollmentCompletionState.Completed;
- result.ShouldEmitEnrollmentComplete = true;
- result.CompletionSource = source;
- }
- }
-
- result.Transitioned = prev != _currentState;
- result.NewState = _currentState;
- return result;
- }
-
- private TransitionResult HandleImeUserSessionCompleted(CompletionContext ctx)
- {
- var prev = _currentState;
- var result = new TransitionResult
- {
- PreviousState = prev,
- SignalsToRecord = new List { "ime_pattern" }
- };
-
- // Device-only: skip Hello entirely
- if (ctx.IsDeviceOnly)
- {
- _currentState = EnrollmentCompletionState.Completed;
- result.ShouldEmitEnrollmentComplete = true;
- result.CompletionSource = "ime_pattern";
- result.Transitioned = true;
- result.NewState = _currentState;
- return result;
- }
-
- // Hello configured but not yet completed — wait
- if (ctx.HasHelloTracker && ctx.IsHelloPolicyConfigured && !ctx.IsHelloCompleted)
- {
- _isWaitingForHello = true;
- _currentState = EnrollmentCompletionState.WaitingForHello;
- result.ShouldStartHelloWaitTimer = true;
- result.ShouldStartHelloSafetyTimer = true;
- result.ShouldPersistState = true;
- result.Transitioned = true;
- result.NewState = _currentState;
- return result;
- }
-
- // ESP categories unresolved — wait for settle
- if (ctx.HasHelloTracker && ctx.HasUnresolvedEspCategories)
- {
- _isWaitingForEspSettle = true;
- _currentState = EnrollmentCompletionState.WaitingForEspSettle;
- result.ShouldStartEspSettleTimer = true;
- result.ShouldPersistState = true;
- result.Transitioned = true;
- result.NewState = _currentState;
- return result;
- }
-
- // All clear — complete
- _currentState = EnrollmentCompletionState.Completed;
- result.ShouldEmitEnrollmentComplete = true;
- result.CompletionSource = "ime_pattern";
- result.Transitioned = true;
- result.NewState = _currentState;
- return result;
- }
-
- private TransitionResult HandleDeviceSetupProvisioningComplete(CompletionContext ctx)
- {
- var prev = _currentState;
- var result = new TransitionResult
- {
- PreviousState = prev,
- SignalsToRecord = new List { "device_setup_provisioning_complete" }
- };
-
- // WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as
- // EnrollmentTracker.CompletionLogic.OnDeviceSetupProvisioningComplete and
- // TryEmitEnrollmentComplete). Weak confidence falls through to the regular
- // self-deploying path by design (asymmetric-conservative routing).
- var wgSignals = WhiteGloveSignals.FromContext(ctx);
- var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
- if (wgClassification.ShouldRouteToWhiteGlovePart1)
- {
- _currentState = EnrollmentCompletionState.WhiteGloveCompleted;
- result.ShouldEmitWhiteGloveComplete = true;
- result.SignalsToRecord.Add("whiteglove_guard_activated");
- result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}");
- result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}");
- result.Transitioned = true;
- result.NewState = _currentState;
- return result;
- }
-
- if (!ctx.IsDeviceOnly)
- {
- // Not device-only: check if device info collected yet
- if (!ctx.DeviceInfoCollected)
- {
- _deferredSource = "device_setup_provisioning_complete";
- _currentState = EnrollmentCompletionState.DeferredForDeviceInfo;
- result.DeferredSource = _deferredSource;
- result.Transitioned = true;
- result.NewState = _currentState;
- return result;
- }
- // Not device-only and device info collected: normal paths handle it
- return TransitionResult.NoTransition;
- }
-
- // Device-only: mark ESP as seen and exited
- if (!_espEverSeen) _espEverSeen = true;
- if (!_espFinalExitSeen)
- {
- _espFinalExitSeen = true;
- result.SignalsToRecord.Add("self_deploying_esp_final_exit");
- }
- result.ShouldPersistState = true;
-
- _currentState = EnrollmentCompletionState.Completed;
- result.ShouldEmitEnrollmentComplete = true;
- result.CompletionSource = "self_deploying_provisioning_complete";
- result.Transitioned = true;
- result.NewState = _currentState;
- return result;
- }
-
- private TransitionResult HandleHelloSafetyTimeout(CompletionContext ctx)
- {
- if (!_isWaitingForHello)
- return TransitionResult.NoTransition;
-
- var prev = _currentState;
- _isWaitingForHello = false;
- _currentState = EnrollmentCompletionState.Completed;
-
- return new TransitionResult
- {
- Transitioned = true,
- PreviousState = prev,
- NewState = _currentState,
- ShouldForceMarkHelloCompleted = true,
- ShouldEmitEnrollmentComplete = true,
- CompletionSource = "ime_hello_safety_timeout",
- SignalsToRecord = new List { "ime_hello_safety_timeout" }
- };
- }
-
- private TransitionResult HandleEspSettleTimeout(CompletionContext ctx)
- {
- if (!_isWaitingForEspSettle)
- return TransitionResult.NoTransition;
-
- var prev = _currentState;
- _isWaitingForEspSettle = false;
- _currentState = EnrollmentCompletionState.Completed;
-
- return new TransitionResult
- {
- Transitioned = true,
- PreviousState = prev,
- NewState = _currentState,
- ShouldEmitEnrollmentComplete = true,
- CompletionSource = "ime_pattern",
- SignalsToRecord = new List { "esp_provisioning_settled" }
- };
- }
-
- private TransitionResult HandleDeviceOnlyEspTimerExpired(CompletionContext ctx)
- {
- // AccountSetup started meanwhile? Timer is obsolete
- if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- return TransitionResult.NoTransition;
-
- if (!_desktopArrived)
- return TransitionResult.NoTransition; // Wait for desktop
-
- var prev = _currentState;
- _espFinalExitSeen = true;
- _currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello;
-
- return new TransitionResult
- {
- Transitioned = true,
- PreviousState = prev,
- NewState = _currentState,
- ShouldStartHelloWaitTimer = true,
- ShouldPersistState = true,
- SignalsToRecord = new List { "device_only_esp_final_exit" }
- };
- }
-
- private TransitionResult HandleDeviceOnlySafetyTimeout(CompletionContext ctx)
- {
- var prev = _currentState;
- _currentState = EnrollmentCompletionState.Completed;
-
- return new TransitionResult
- {
- Transitioned = true,
- PreviousState = prev,
- NewState = _currentState,
- ShouldForceMarkHelloCompleted = true,
- ShouldEmitEnrollmentComplete = true,
- CompletionSource = "device_only_esp_safety_timeout",
- SignalsToRecord = new List { "device_only_esp_safety_timeout" }
- };
- }
-
- private TransitionResult HandleEspFailureTerminal(CompletionContext ctx)
- {
- var prev = _currentState;
- _currentState = EnrollmentCompletionState.Failed;
-
- return new TransitionResult
- {
- Transitioned = true,
- PreviousState = prev,
- NewState = _currentState,
- ShouldEmitEnrollmentFailed = true,
- FailureType = ctx.Source
- };
- }
-
- private TransitionResult HandleWhiteGloveComplete(CompletionContext ctx)
- {
- var prev = _currentState;
- _currentState = EnrollmentCompletionState.WhiteGloveCompleted;
-
- return new TransitionResult
- {
- Transitioned = true,
- PreviousState = prev,
- NewState = _currentState,
- ShouldEmitWhiteGloveComplete = true
- };
- }
-
- private TransitionResult HandleEspResumed(CompletionContext ctx)
- {
- if (!ctx.IsHybridJoin || !_espFinalExitSeen)
- return TransitionResult.NoTransition;
-
- var prev = _currentState;
- _espFinalExitSeen = false;
- _currentState = EnrollmentCompletionState.EspActive;
-
- return new TransitionResult
- {
- Transitioned = true,
- PreviousState = prev,
- NewState = _currentState,
- ShouldResetEspForResumption = true,
- ShouldPersistState = true,
- SignalsToRecord = new List { "esp_resumed" }
- };
- }
-
- private TransitionResult HandleDeviceInfoCollected(CompletionContext ctx)
- {
- if (_currentState != EnrollmentCompletionState.DeferredForDeviceInfo)
- return TransitionResult.NoTransition;
-
- var prev = _currentState;
- var deferredSource = _deferredSource;
- _deferredSource = null;
-
- // Re-evaluate: now that device info is available, check if device-only
- if (ctx.IsDeviceOnly)
- {
- if (!_espEverSeen) _espEverSeen = true;
- if (!_espFinalExitSeen)
- {
- _espFinalExitSeen = true;
- }
-
- _currentState = EnrollmentCompletionState.Completed;
- return new TransitionResult
- {
- Transitioned = true,
- PreviousState = prev,
- NewState = _currentState,
- ShouldEmitEnrollmentComplete = true,
- CompletionSource = deferredSource ?? "device_setup_provisioning_complete",
- ShouldPersistState = true,
- SignalsToRecord = new List { "self_deploying_esp_final_exit" }
- };
- }
-
- // Not device-only: revert to appropriate non-terminal state
- if (_espFinalExitSeen)
- _currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion;
- else if (_espEverSeen)
- _currentState = _desktopArrived
- ? EnrollmentCompletionState.DesktopArrivedEspBlocking
- : EnrollmentCompletionState.EspActive;
- else
- _currentState = _desktopArrived
- ? EnrollmentCompletionState.DesktopArrivedAwaitingHello
- : EnrollmentCompletionState.Idle;
-
- return new TransitionResult
- {
- Transitioned = true,
- PreviousState = prev,
- NewState = _currentState,
- DeferredSource = deferredSource
- };
- }
-
- // ===== Guard Helpers (called under lock) =====
-
- ///
- /// Checks all three completion guards for a given source.
- ///
- private bool CanComplete(CompletionContext ctx, string source)
- {
- bool helloResolved = CompletionGuards.IsHelloResolved(
- ctx.HasHelloTracker, ctx.IsHelloCompleted,
- ctx.IsHelloPolicyConfigured, ctx.IsDeviceOnly);
-
- bool espGateBlocking = CompletionGuards.IsEspGateBlocking(
- source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen);
-
- bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking(
- ctx.IsHybridJoin, source,
- ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc);
-
- return helloResolved && !espGateBlocking && !hybridRebootGateBlocking;
- }
-
- ///
- /// Completion check with Hello always resolved (caller has just confirmed Hello completed).
- /// Only checks ESP gate and hybrid reboot gate.
- ///
- private bool CanCompleteWithHelloResolved(CompletionContext ctx, string source)
- {
- bool espGateBlocking = CompletionGuards.IsEspGateBlocking(
- source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen);
-
- bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking(
- ctx.IsHybridJoin, source,
- ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc);
-
- return !espGateBlocking && !hybridRebootGateBlocking;
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs
deleted file mode 100644
index d9ec5c57..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs
+++ /dev/null
@@ -1,393 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.IO;
-using System.Security.AccessControl;
-using System.Security.Principal;
-using System.Threading;
-using System.Threading.Tasks;
-using AutopilotMonitor.Agent.V2.Core.Configuration;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Agent.V2.Core.Security;
-using AutopilotMonitor.Shared.Models;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
-{
- ///
- /// Handles enrollment terminal events: enrollment_complete, enrollment_failed, and
- /// whiteglove_complete shutdown sequences including diagnostics upload, summary dialog,
- /// self-destruct, and reboot.
- ///
- public class EnrollmentCompletionHandler
- {
- private readonly AgentConfiguration _configuration;
- private readonly AgentLogger _logger;
- private readonly string _agentVersion;
- private readonly Action _emitEvent;
- private readonly Action> _emitShutdownEvent;
- private readonly Func _uploadEventsAsync;
- private readonly Action _stopUploadTimers;
- private readonly CleanupService _cleanupService;
- private readonly DiagnosticsPackageService _diagnosticsService;
- private readonly SessionPersistence _sessionPersistence;
- private readonly EventSpool _spool;
-
- public EnrollmentCompletionHandler(
- AgentConfiguration configuration,
- AgentLogger logger,
- string agentVersion,
- Action emitEvent,
- Action> emitShutdownEvent,
- Func uploadEventsAsync,
- Action stopUploadTimers,
- CleanupService cleanupService,
- DiagnosticsPackageService diagnosticsService,
- SessionPersistence sessionPersistence,
- EventSpool spool)
- {
- _configuration = configuration;
- _logger = logger;
- _agentVersion = agentVersion;
- _emitEvent = emitEvent;
- _emitShutdownEvent = emitShutdownEvent;
- _uploadEventsAsync = uploadEventsAsync;
- _stopUploadTimers = stopUploadTimers;
- _cleanupService = cleanupService;
- _diagnosticsService = diagnosticsService;
- _sessionPersistence = sessionPersistence;
- _spool = spool;
- }
-
- ///
- /// Handles normal enrollment completion (success or failure).
- /// Stops collectors, runs shutdown analyzers, uploads diagnostics, optionally self-destructs.
- ///
- public async Task HandleEnrollmentComplete(
- bool enrollmentSucceeded,
- bool isWhiteGlovePart2,
- Action stopCollectors,
- Action runShutdownAnalyzers)
- {
- try
- {
- _logger.Info("===== ENROLLMENT COMPLETE - Starting shutdown sequence =====");
-
- _logger.Info("Stopping event collectors...");
- stopCollectors();
- _spool.StopWatching();
-
- runShutdownAnalyzers(isWhiteGlovePart2 ? 2 : (int?)null);
-
- _logger.Info("Uploading final events...");
- await _uploadEventsAsync();
-
- await Task.Delay(2000);
-
- await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded);
-
- if (_configuration.ShowEnrollmentSummary)
- LaunchEnrollmentSummaryDialog();
-
- if (_configuration.SelfDestructOnComplete)
- {
- _cleanupService.ExecuteSelfDestruct();
- }
- else if (_configuration.RebootOnComplete)
- {
- _logger.Info($"RebootOnComplete enabled - initiating reboot in {_configuration.RebootDelaySeconds}s");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "reboot_triggered",
- Severity = EventSeverity.Info,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Complete,
- Message = $"Reboot configured — triggering reboot in {_configuration.RebootDelaySeconds} seconds",
- Data = new Dictionary
- {
- { "rebootDelaySeconds", _configuration.RebootDelaySeconds }
- },
- ImmediateUpload = true
- });
-
- await _uploadEventsAsync();
-
- StartReboot(_configuration.RebootDelaySeconds);
- }
-
- _emitShutdownEvent(
- enrollmentSucceeded ? "enrollment_complete" : "enrollment_failed",
- $"Agent shutting down after enrollment {(enrollmentSucceeded ? "completion" : "failure")}",
- new Dictionary
- {
- { "enrollmentSucceeded", enrollmentSucceeded },
- { "selfDestruct", _configuration.SelfDestructOnComplete },
- { "reboot", _configuration.RebootOnComplete }
- });
- await _uploadEventsAsync();
-
- _logger.Info("Shutdown sequence complete. Agent will now exit.");
- ExitProcess(0);
- }
- catch (Exception ex)
- {
- _logger.Error("Error during self-destruct sequence", ex);
- ExitProcess(1);
- }
- }
-
- ///
- /// Graceful shutdown after WhiteGlove (Pre-Provisioning) completes.
- /// Does NOT delete the session ID — the session must survive for Part 2.
- ///
- public async Task HandleWhiteGloveComplete(
- Action stopCollectors,
- Action runShutdownAnalyzers,
- Func readEventSequence)
- {
- try
- {
- _logger.Info("===== WHITEGLOVE COMPLETE - Starting graceful shutdown sequence =====");
-
- runShutdownAnalyzers(1);
-
- _logger.Info("Uploading final events (draining spool)...");
- int maxIterations = 20;
- while (_spool.GetCount() > 0 && maxIterations-- > 0)
- {
- await _uploadEventsAsync();
- }
-
- await Task.Delay(2000);
-
- await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov");
-
- _sessionPersistence.SaveWhiteGloveComplete();
- _logger.Info("WhiteGlove marker persisted for Part 2 detection");
-
- _emitShutdownEvent(
- "whiteglove_complete",
- "Agent shutting down after WhiteGlove Part 1 — no cleanup, session preserved for Part 2 on next boot",
- new Dictionary
- {
- { "sessionPreserved", true },
- { "selfDestruct", false },
- { "reboot", false },
- { "cleanup", false },
- { "willResumeOnNextBoot", true }
- });
- await _uploadEventsAsync();
-
- var finalSequence = readEventSequence();
- _sessionPersistence.SaveSequence(finalSequence);
- _logger.Info($"Sequence counter persisted at {finalSequence} for next boot");
-
- _logger.Info("WhiteGlove graceful shutdown complete. Agent exiting.");
- ExitProcess(0);
- }
- catch (Exception ex)
- {
- _logger.Error("Error during WhiteGlove shutdown sequence", ex);
- ExitProcess(0);
- }
- }
-
- internal virtual void ExitProcess(int code) => Environment.Exit(code);
-
- internal virtual void StartReboot(int delaySeconds)
- {
- var psi = new ProcessStartInfo
- {
- FileName = SystemPaths.Shutdown,
- Arguments = $"/r /t {delaySeconds} /c \"Autopilot enrollment completed - rebooting\"",
- UseShellExecute = false,
- CreateNoWindow = true
- };
- Process.Start(psi);
- }
-
- ///
- /// Deletes the persisted session ID and sequence files.
- ///
- public void DeleteSessionId()
- {
- _logger.Info("Deleting persisted session ID and sequence...");
- try
- {
- _sessionPersistence.DeleteSession();
- _logger.Info("Session ID and sequence deleted successfully");
- }
- catch (Exception ex)
- {
- _logger.Warning($"Failed to delete session ID: {ex.Message}");
- }
- }
-
- private async Task UploadDiagnosticsWithTimelineEvents(bool enrollmentSucceeded, string fileNameSuffix = null)
- {
- var mode = _configuration.DiagnosticsUploadMode ?? "Off";
- if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
- return;
- if (!_configuration.DiagnosticsUploadEnabled)
- return;
- if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
- return;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "diagnostics_collecting",
- Severity = EventSeverity.Info,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Complete,
- Message = $"Collecting diagnostics package (mode={mode}{(fileNameSuffix != null ? $", type={fileNameSuffix}" : "")})",
- Data = new Dictionary
- {
- { "uploadMode", mode }
- }
- });
- await _uploadEventsAsync();
-
- var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix);
- var success = uploadResult?.Success == true;
-
- var resultData = new Dictionary
- {
- { "uploadMode", mode }
- };
- if (uploadResult?.BlobName != null)
- resultData["blobName"] = uploadResult.BlobName;
- if (uploadResult?.SasUrlPrefix != null)
- resultData["sasUrl"] = uploadResult.SasUrlPrefix;
- if (uploadResult?.ErrorCode != null)
- resultData["errorCode"] = uploadResult.ErrorCode;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = success ? "diagnostics_uploaded" : "diagnostics_upload_failed",
- Severity = success ? EventSeverity.Info : EventSeverity.Warning,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Complete,
- Message = success
- ? $"Diagnostics package uploaded: {uploadResult.BlobName}"
- : $"Diagnostics package upload failed{(uploadResult?.ErrorCode != null ? $": {uploadResult.ErrorCode}" : "")}",
- Data = resultData
- });
- await _uploadEventsAsync();
- }
-
- private void LaunchEnrollmentSummaryDialog()
- {
- try
- {
- var agentDir = Path.GetDirectoryName(
- System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty;
- var dialogExe = Path.Combine(agentDir, "AutopilotMonitor.SummaryDialog.exe");
-
- if (!File.Exists(dialogExe))
- {
- _logger.Warning($"Summary dialog EXE not found at {dialogExe} — skipping");
- return;
- }
-
- var statusFile = Path.Combine(
- Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State"),
- "final-status.json");
-
- if (!File.Exists(statusFile))
- {
- _logger.Warning($"final-status.json not found at {statusFile} — skipping summary dialog");
- return;
- }
-
- var tempDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
- "AutopilotMonitor-Summary");
-
- try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
- Directory.CreateDirectory(tempDir);
-
- GrantUserDeletePermission(tempDir);
-
- var tempDialogExe = Path.Combine(tempDir, "AutopilotMonitor.SummaryDialog.exe");
- File.Copy(dialogExe, tempDialogExe);
-
- var jsonDll = Path.Combine(agentDir, "Newtonsoft.Json.dll");
- if (File.Exists(jsonDll))
- File.Copy(jsonDll, Path.Combine(tempDir, "Newtonsoft.Json.dll"));
-
- var exeConfig = dialogExe + ".config";
- if (File.Exists(exeConfig))
- File.Copy(exeConfig, tempDialogExe + ".config");
-
- var tempStatusFile = Path.Combine(tempDir, "final-status.json");
- File.Copy(statusFile, tempStatusFile);
-
- var args = $"--status-file \"{tempStatusFile}\" --timeout {_configuration.EnrollmentSummaryTimeoutSeconds} --cleanup";
- if (!string.IsNullOrEmpty(_configuration.EnrollmentSummaryBrandingImageUrl))
- args += $" --branding-url \"{_configuration.EnrollmentSummaryBrandingImageUrl}\"";
-
- _logger.Info($"Launching enrollment summary dialog: {tempDialogExe} {args}");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "enrollment_summary_shown",
- Severity = EventSeverity.Info,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Complete,
- Message = "Launching enrollment summary dialog to user"
- });
-
- try { _uploadEventsAsync().Wait(TimeSpan.FromSeconds(5)); } catch { }
-
- var launched = UserSessionProcessLauncher.LaunchInUserSession(
- tempDialogExe, args, _logger,
- _configuration.EnrollmentSummaryLaunchRetrySeconds);
- if (!launched)
- {
- _logger.Warning("Could not launch summary dialog in user session (no interactive session found)");
- try { Directory.Delete(tempDir, true); } catch { }
- }
- }
- catch (Exception ex)
- {
- _logger.Warning($"Failed to launch enrollment summary dialog: {ex.Message}");
- }
- }
-
- private void GrantUserDeletePermission(string directoryPath)
- {
- try
- {
- var dirInfo = new DirectoryInfo(directoryPath);
- var security = dirInfo.GetAccessControl();
- var usersIdentity = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
- security.AddAccessRule(new FileSystemAccessRule(
- usersIdentity,
- FileSystemRights.FullControl,
- InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
- PropagationFlags.None,
- AccessControlType.Allow));
- dirInfo.SetAccessControl(security);
- }
- catch (Exception ex)
- {
- _logger.Debug($"Could not set ACL on summary folder: {ex.Message}");
- }
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs
deleted file mode 100644
index 47e705cc..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs
+++ /dev/null
@@ -1,120 +0,0 @@
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
-{
- ///
- /// Explicit states for the enrollment completion state machine.
- /// Each state represents a distinguishable configuration of the implicit boolean flags
- /// that previously existed only as field combinations in EnrollmentTracker.CompletionLogic.cs.
- ///
- internal enum EnrollmentCompletionState
- {
- ///
- /// Initial state. No completion-relevant signals received.
- /// Waiting for: ESP phase event, desktop arrival, or device setup provisioning.
- ///
- Idle,
-
- ///
- /// ESP has been seen (at least one phase detected) but no final exit yet.
- /// Waiting for: ESP exit (FinalizingSetup trigger), desktop arrival, or device-only timer.
- ///
- EspActive,
-
- ///
- /// ESP exited from AccountSetup (or desktop-arrival backup). espFinalExitSeen=true.
- /// Waiting for: Hello resolution, IME user session completion, or safety timeout.
- ///
- EspExitedAwaitingCompletion,
-
- ///
- /// IME user session completed but Hello is configured and not yet resolved.
- /// isWaitingForHello=true. Safety timer running (420s / 7 min).
- ///
- WaitingForHello,
-
- ///
- /// IME user session completed but ESP provisioning categories not yet resolved in registry.
- /// isWaitingForEspSettle=true. Settle timer running (30s).
- ///
- WaitingForEspSettle,
-
- ///
- /// Desktop arrived but ESP is still active (ESP gate blocking completion).
- /// Waiting for: ESP final exit.
- ///
- DesktopArrivedEspBlocking,
-
- ///
- /// Desktop arrived, no ESP blocking (ESP exited, never seen, or v2). Hello may be pending.
- /// Waiting for: Hello resolution or direct completion.
- ///
- DesktopArrivedAwaitingHello,
-
- ///
- /// Device-only deployment detected (Self-Deploying or SkipUserStatusPage=true without AAD user).
- /// Waiting for provisioning complete or IME completion. Hello guard bypassed.
- ///
- DeviceOnlyAwaitingCompletion,
-
- ///
- /// Device-only ESP exit detected but completion didn't fire immediately (Hello pending).
- /// Safety timer running (420s / 7 min).
- ///
- DeviceOnlySafetyWait,
-
- ///
- /// Hybrid join: esp_hello_composite blocked by reboot gate.
- /// Waiting for: IME user session completion or agent restart confirmation after ESP exit.
- ///
- HybridRebootGateBlocked,
-
- ///
- /// Device info not yet collected when a completion signal arrived.
- /// Completion source deferred until CollectDeviceInfo finishes (pendingCompletionSource set).
- ///
- DeferredForDeviceInfo,
-
- ///
- /// Terminal: enrollment_complete event emitted. No further transitions possible.
- ///
- Completed,
-
- ///
- /// Terminal: enrollment_failed event emitted. No further transitions possible.
- ///
- Failed,
-
- ///
- /// Terminal: whiteglove_complete event emitted. No further transitions possible.
- ///
- WhiteGloveCompleted
- }
-
- ///
- /// Extension methods for EnrollmentCompletionState.
- ///
- internal static class EnrollmentCompletionStateExtensions
- {
- ///
- /// Returns true if the state is terminal (Completed, Failed, or WhiteGloveCompleted).
- /// No further transitions are allowed from terminal states.
- ///
- public static bool IsTerminal(this EnrollmentCompletionState state)
- {
- return state == EnrollmentCompletionState.Completed
- || state == EnrollmentCompletionState.Failed
- || state == EnrollmentCompletionState.WhiteGloveCompleted;
- }
-
- ///
- /// Returns true if the state is a waiting state (actively waiting for a signal or timeout).
- ///
- public static bool IsWaiting(this EnrollmentCompletionState state)
- {
- return state == EnrollmentCompletionState.WaitingForHello
- || state == EnrollmentCompletionState.WaitingForEspSettle
- || state == EnrollmentCompletionState.DeviceOnlySafetyWait
- || state == EnrollmentCompletionState.HybridRebootGateBlocked
- || state == EnrollmentCompletionState.DeferredForDeviceInfo;
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs
deleted file mode 100644
index 000e8943..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs
+++ /dev/null
@@ -1,150 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
-{
- ///
- /// Confidence level for a WhiteGlove Part 1 classification.
- ///
- internal enum WhiteGloveConfidence
- {
- /// Score below — definitely not WhiteGlove.
- None,
-
- /// Score between low and high thresholds — ambiguous. Routed as NOT WhiteGlove (asymmetric-conservative).
- Weak,
-
- /// Score at or above — classify as WhiteGlove Part 1.
- Strong,
- }
-
- ///
- /// Result of a call. Contains the
- /// final routing decision, the numeric score and the contributing factors for
- /// observability / tuning.
- ///
- internal sealed class WhiteGloveClassification
- {
- public WhiteGloveConfidence Confidence { get; set; }
- public int Score { get; set; }
- public List ContributingFactors { get; set; } = new List();
- public string Reason { get; set; }
-
- ///
- /// Single Boolean the caller should read to decide whether to route to
- /// whiteglove_complete (Pending) instead of the normal completion path.
- /// Only true when is .
- ///
- public bool ShouldRouteToWhiteGlovePart1 { get; set; }
- }
-
- ///
- /// Scoring-based WhiteGlove Part 1 classifier — modelled on the backend
- /// AnalyzeRule.BaseConfidence + ConfidenceFactors + ConfidenceThreshold pattern.
- ///
- /// Asymmetric-conservative decision rule: only a
- /// score routes to WhiteGlove. A score is emitted
- /// to telemetry for tuning but treated as NOT WhiteGlove. Rationale: a false-positive WG
- /// classification keeps the agent pending when it should exit; a false-negative emits
- /// enrollment_complete early and the later user sign-in triggers a new session —
- /// the user-visible cost of false-negative is much lower than false-positive.
- ///
- internal static class WhiteGloveClassifier
- {
- // Thresholds (tunable after production data — see project memory).
- internal const int HighThreshold = 70; // >= 70 → Strong → route to WG
- internal const int LowThreshold = 30; // 30..69 → Weak (telemetry only)
- internal const int BaseConfidence = 0;
-
- // Positive-signal weights
- internal const int WeightShellCoreWhiteGloveSuccess = 80; // definitive — Event 62407
- internal const int WeightHasSaveWhiteGloveSuccess = 10; // very weak — also fires on non-WG devices
- internal const int WeightWhiteGloveStartDetected = 15; // soft — Event 509 also fires on hybrid
- internal const int WeightFooUserDetected = 20; // weak — pre-provisioning marker
- internal const int WeightAgentRestartedAfterEspExit = 10; // weak — typical WG boundary
- internal const int WeightDeviceOnlyDeployment = 15; // context boost
-
- // Negative-signal weights (hard excluders)
- internal const int WeightAadJoinedWithUser = -100; // hard exclude
- internal const int WeightDesktopArrived = -100; // hard exclude
- internal const int WeightAccountSetupActive = -40; // soft exclude
-
- public static WhiteGloveClassification Classify(WhiteGloveSignals s)
- {
- if (s == null) throw new ArgumentNullException(nameof(s));
-
- var factors = new List();
- int score = BaseConfidence;
-
- // Hard excluders
- if (s.AadJoinedWithUser)
- {
- score += WeightAadJoinedWithUser;
- factors.Add($"aad_joined_with_user:{WeightAadJoinedWithUser:+#;-#;0}");
- }
- if (s.DesktopArrived)
- {
- score += WeightDesktopArrived;
- factors.Add($"desktop_arrived:{WeightDesktopArrived:+#;-#;0}");
- }
- if (s.HasAccountSetupActivity)
- {
- score += WeightAccountSetupActive;
- factors.Add($"account_setup_activity:{WeightAccountSetupActive:+#;-#;0}");
- }
-
- // Positive signals — weighted by empirical reliability
- if (s.ShellCoreWhiteGloveSuccess)
- {
- score += WeightShellCoreWhiteGloveSuccess;
- factors.Add($"shellcore_wg_success:{WeightShellCoreWhiteGloveSuccess:+#;-#;0}");
- }
- if (s.HasSaveWhiteGloveSuccessResult)
- {
- score += WeightHasSaveWhiteGloveSuccess;
- factors.Add($"save_wg_success:{WeightHasSaveWhiteGloveSuccess:+#;-#;0}");
- }
- if (s.IsWhiteGloveStartDetected)
- {
- score += WeightWhiteGloveStartDetected;
- factors.Add($"event509_wg_start:{WeightWhiteGloveStartDetected:+#;-#;0}");
- }
- if (s.IsFooUserDetected)
- {
- score += WeightFooUserDetected;
- factors.Add($"foouser_detected:{WeightFooUserDetected:+#;-#;0}");
- }
- if (s.AgentRestartedAfterEspExit)
- {
- score += WeightAgentRestartedAfterEspExit;
- factors.Add($"agent_restart_after_esp:{WeightAgentRestartedAfterEspExit:+#;-#;0}");
- }
- if (s.IsDeviceOnlyDeployment)
- {
- score += WeightDeviceOnlyDeployment;
- factors.Add($"device_only_deployment:{WeightDeviceOnlyDeployment:+#;-#;0}");
- }
-
- // NOTE: no hybrid-join dampener — WhiteGlove works on both AADJ and hybrid.
- // NOTE: no "long stable since DeviceSetup" bonus — devices stand idle for other reasons.
-
- // Cap to [0, 100]
- if (score < 0) score = 0;
- if (score > 100) score = 100;
-
- WhiteGloveConfidence confidence;
- if (score >= HighThreshold) confidence = WhiteGloveConfidence.Strong;
- else if (score >= LowThreshold) confidence = WhiteGloveConfidence.Weak;
- else confidence = WhiteGloveConfidence.None;
-
- return new WhiteGloveClassification
- {
- Confidence = confidence,
- Score = score,
- ContributingFactors = factors,
- Reason = $"confidence={confidence} score={score}",
- ShouldRouteToWhiteGlovePart1 = (confidence == WhiteGloveConfidence.Strong),
- };
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs
deleted file mode 100644
index 80a2c680..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using System;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
-{
- ///
- /// Snapshot of all signals relevant for WhiteGlove (Part 1) classification.
- /// Populated under _stateLock in the caller and consumed outside the lock by
- /// — mirrors the CompletionContext
- /// pattern to preserve the existing thread-safety guarantees.
- ///
- /// Source-of-truth is the EnrollmentTracker state. When the tracker uses
- /// CompletionStateMachine (shadow), use to keep both
- /// decision paths in sync.
- ///
- internal sealed class WhiteGloveSignals
- {
- // "Hard" positive signals (registry / Shell-Core)
- public bool ShellCoreWhiteGloveSuccess { get; set; } // Event 62407 "WhiteGlove_Success" — strongest signal
- public bool HasSaveWhiteGloveSuccessResult { get; set; } // DeviceSetup registry subcategory — also fires on non-WG
- public bool IsWhiteGloveStartDetected { get; set; } // Event 509 — soft, fires on hybrid too
- public bool IsFooUserDetected { get; set; } // foouser@/autopilot@ in JoinInfo — pre-provisioning indicator
- public bool AgentRestartedAfterEspExit { get; set; } // typical WG Part 1 boundary
-
- // Hard excluders (drive score negative)
- public bool AadJoinedWithUser { get; set; } // real user signed in — not WG Part 1 anymore
- public bool DesktopArrived { get; set; } // explorer.exe running — not WG Part 1 anymore
- public bool HasAccountSetupActivity { get; set; } // user-driven ESP category active
-
- // Deployment context
- public bool IsDeviceOnlyDeployment { get; set; }
-
- // Optional metadata (populated for observability, not scored)
- public DateTime? EspFinalExitUtc { get; set; }
- public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; }
-
- ///
- /// Builds a signal snapshot from a . Used by the
- /// CompletionStateMachine so that state-machine decisions use the exact same
- /// inputs as the EnrollmentTracker's direct decisions.
- ///
- public static WhiteGloveSignals FromContext(CompletionContext ctx)
- {
- if (ctx == null) throw new ArgumentNullException(nameof(ctx));
-
- return new WhiteGloveSignals
- {
- ShellCoreWhiteGloveSuccess = ctx.ShellCoreWhiteGloveSuccess,
- HasSaveWhiteGloveSuccessResult = ctx.HasSaveWhiteGloveSuccessResult,
- IsWhiteGloveStartDetected = ctx.WhiteGloveStartDetected,
- IsFooUserDetected = ctx.IsFooUserDetected,
- AgentRestartedAfterEspExit = ctx.AgentRestartedAfterEspExit,
-
- AadJoinedWithUser = ctx.AadJoinedWithUser,
- DesktopArrived = ctx.DesktopArrived,
- HasAccountSetupActivity = ctx.HasAccountSetupActivity,
-
- IsDeviceOnlyDeployment = ctx.IsDeviceOnly,
-
- EspFinalExitUtc = ctx.EspFinalExitUtc,
- };
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs
deleted file mode 100644
index 8f0e322b..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs
+++ /dev/null
@@ -1,151 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using Newtonsoft.Json;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
-{
- ///
- /// Handles persistence of EnrollmentTracker state to disk (JSON serialization).
- /// Enables crash-recovery: after agent restart, completion signals already seen
- /// (ESP exit, desktop arrival, etc.) are restored so the enrollment can complete.
- /// Pattern follows ImeTrackerStatePersistence.
- ///
- public class EnrollmentStatePersistence
- {
- private readonly string _stateDirectory;
- private readonly string _stateFilePath;
- private readonly AgentLogger _logger;
-
- public EnrollmentStatePersistence(string stateDirectory, AgentLogger logger)
- {
- _stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
- _stateFilePath = Path.Combine(_stateDirectory, "enrollment-state.json");
- _logger = logger;
- }
-
- public string StateFilePath => _stateFilePath;
-
- ///
- /// Loads persisted state from disk. Returns null if no state file exists or on error.
- ///
- public EnrollmentStateData Load()
- {
- if (!File.Exists(_stateFilePath))
- {
- _logger.Info("EnrollmentStatePersistence: no persisted state found (fresh enrollment)");
- return null;
- }
-
- try
- {
- var json = File.ReadAllText(_stateFilePath);
- var state = JsonConvert.DeserializeObject(json);
- if (state == null)
- {
- _logger.Warning("EnrollmentStatePersistence: persisted state file was empty or invalid");
- return null;
- }
- return state;
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentStatePersistence: failed to load persisted state, starting fresh: {ex.Message}");
- return null;
- }
- }
-
- ///
- /// Persists tracker state to disk as JSON (atomic write via temp file).
- ///
- public void Save(EnrollmentStateData state)
- {
- try
- {
- Directory.CreateDirectory(_stateDirectory);
- var tempPath = _stateFilePath + ".tmp";
- File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented));
- File.Copy(tempPath, _stateFilePath, overwrite: true);
- try { File.Delete(tempPath); } catch { }
- }
- catch (Exception ex)
- {
- _logger.Debug($"EnrollmentStatePersistence: failed to save state: {ex.Message}");
- }
- }
-
- ///
- /// Deletes persisted state file.
- ///
- public void Delete()
- {
- try
- {
- if (File.Exists(_stateFilePath))
- {
- File.Delete(_stateFilePath);
- _logger.Info("EnrollmentStatePersistence: persisted state deleted");
- }
- }
- catch (Exception ex)
- {
- _logger.Debug($"EnrollmentStatePersistence: failed to delete state file: {ex.Message}");
- }
- }
- }
-
- // ---- DTO for state serialization ----
-
- public class EnrollmentStateData
- {
- public bool EspEverSeen { get; set; }
- public bool EspFinalExitSeen { get; set; }
- public bool DesktopArrived { get; set; }
- public string LastEspPhase { get; set; }
- public bool IsWaitingForHello { get; set; }
- public DateTime? WaitingForHelloStartedUtc { get; set; }
- public bool EnrollmentCompleteEmitted { get; set; }
- public string EnrollmentType { get; set; }
-
- // ESP configuration from registry (FirstSync\SkipUserStatusPage / SkipDeviceStatusPage)
- public bool? SkipUserStatusPage { get; set; }
- public bool? SkipDeviceStatusPage { get; set; }
-
- // Autopilot deployment mode: 0=UserDriven, 1=SelfDeploying, 2=PreProvisioning, null=unknown
- public int? AutopilotMode { get; set; }
-
- // True when AAD join status shows a joined device with a real user email
- // (placeholder accounts like foouser@/autopilot@ are excluded — see AadJoinInfo)
- public bool AadJoinedWithUser { get; set; }
-
- // True when a transient AAD provisioning placeholder (foouser@/autopilot@) has been
- // observed at any point in the session — survives crash/restart so the classifier
- // keeps its positive WG signal after agent recovery.
- public bool FooUserDetected { get; set; }
-
- // True when Autopilot profile indicates Hybrid Azure AD Join (CloudAssignedDomainJoinMethod == 1)
- public bool IsHybridJoin { get; set; }
-
- // Signal timestamps for audit trail
- public DateTime? EspFirstSeenUtc { get; set; }
- public DateTime? EspFinalExitUtc { get; set; }
- public DateTime? DesktopArrivedUtc { get; set; }
- public DateTime? HelloResolvedUtc { get; set; }
- public DateTime? ImePatternSeenUtc { get; set; }
- public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; }
- public List SignalsSeen { get; set; } = new List();
-
- // ESP provisioning settle wait state (crash recovery)
- public bool IsWaitingForEspSettle { get; set; }
- public DateTime? WaitingForEspSettleStartedUtc { get; set; }
-
- ///
- /// Explicit completion state machine state (dual-write).
- /// When present, used to restore the state machine directly.
- /// When absent (old format), the state is reconstructed from boolean flags.
- /// Serialized as string (enum name) for forward compatibility.
- ///
- public string CompletionState { get; set; }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs
deleted file mode 100644
index 1f571f1b..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs
+++ /dev/null
@@ -1,1813 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-using Microsoft.Win32;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
-{
- ///
- /// Partial: Enrollment completion detection, Hello/WhiteGlove/ESP failure handling,
- /// state persistence, and multi-signal completion logic.
- ///
- public partial class EnrollmentTracker
- {
- private void OnHelloCompleted(object sender, EventArgs e)
- {
- _logger.Info("EnrollmentTracker: Received HelloCompleted event from EspAndHelloTracker");
-
- lock (_stateLock)
- {
- _stateData.HelloResolvedUtc = DateTime.UtcNow;
- }
- RecordSignal("hello_resolved");
-
- // Cancel safety timer — Hello resolved normally
- _waitingForHelloSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite);
-
- // Snapshot state under lock for branch decision
- bool wasWaiting, espFinalExit, desktopArr;
- lock (_stateLock)
- {
- wasWaiting = _isWaitingForHello;
- espFinalExit = _espFinalExitSeen;
- desktopArr = _desktopArrived;
- if (wasWaiting)
- _isWaitingForHello = false;
- }
-
- if (wasWaiting)
- {
- _logger.Info("EnrollmentTracker: Hello provisioning completed while waiting (IME path) - marking enrollment as complete now");
- TryEmitEnrollmentComplete("ime_hello");
- }
- else if (espFinalExit)
- {
- _logger.Info("EnrollmentTracker: Hello completed + ESP final exit seen — composite completion");
- TryEmitEnrollmentComplete("esp_hello_composite");
- }
- else if (desktopArr)
- {
- _logger.Info("EnrollmentTracker: Hello completed + desktop arrived — desktop-hello completion");
- TryEmitEnrollmentComplete("desktop_hello");
- }
- else
- {
- _logger.Debug("EnrollmentTracker: HelloCompleted event received but no completion trigger active yet");
- }
-
- // Shadow state machine: track hello_completed trigger
- ShadowProcessTrigger("hello_completed");
- }
-
- ///
- /// Called when DeviceSetup provisioning status shows all subcategories succeeded.
- /// In Self-Deploying mode, this is the primary completion signal because no user session
- /// means no Hello, no desktop arrival, and possibly no Shell-Core ESP exit event.
- /// In non-Self-Deploying mode, this is informational only — normal paths handle completion.
- ///
- private void OnDeviceSetupProvisioningComplete(object sender, EventArgs e)
- {
- _logger.Info("EnrollmentTracker: DeviceSetup provisioning completed successfully");
- RecordSignal("device_setup_provisioning_complete");
- lock (_stateLock)
- {
- _stateData.DeviceSetupProvisioningCompleteUtc = DateTime.UtcNow;
- _stateDirty = true;
- }
-
- bool isDeviceOnly;
- int? autopilotModeSnap;
- bool? skipUserSnap;
- lock (_stateLock)
- {
- isDeviceOnly = IsDeviceOnlyDeployment;
- autopilotModeSnap = _autopilotMode;
- skipUserSnap = _skipUserStatusPage;
- }
-
- if (!isDeviceOnly)
- {
- if (!_deviceInfoCollected)
- {
- _logger.Info("EnrollmentTracker: DeviceSetup provisioning complete but device info not yet collected — " +
- "deferring classification decision until CollectDeviceInfo completes");
- lock (_stateLock) { _pendingCompletionSource = "device_setup_provisioning_complete"; }
- EmitTraceEvent("device_setup_complete_deferred",
- "DeviceSetup provisioning complete but device classification pending — will re-evaluate after CollectDeviceInfo",
- new Dictionary { { "autopilotMode", autopilotModeSnap }, { "skipUserStatusPage", skipUserSnap } });
- return;
- }
-
- _logger.Info("EnrollmentTracker: DeviceSetup provisioning complete but not device-only deployment — " +
- "using normal completion paths (ESP exit + Hello)");
- EmitTraceEvent("device_setup_complete_non_device_only",
- "DeviceSetup provisioning complete in non-device-only mode — no action, normal paths apply",
- new Dictionary { { "autopilotMode", autopilotModeSnap }, { "skipUserStatusPage", skipUserSnap } });
- return;
- }
-
- // WhiteGlove guard — canonical decision via WhiteGloveClassifier.
- // See plan: all three completion paths (this one, TryEmitEnrollmentComplete,
- // and CompletionStateMachine.HandleDeviceSetupProvisioningComplete) share the
- // same classifier output. HasSaveWhiteGloveSuccessResult alone is no longer
- // sufficient — it also fires on non-WG devices (observed in production).
- var wgSignals = SnapshotWhiteGloveSignals();
- var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
- EmitWhiteGloveClassification(wgClassification, "DeviceSetupProvisioningComplete", wgSignals);
-
- if (wgClassification.ShouldRouteToWhiteGlovePart1)
- {
- _logger.Info($"EnrollmentTracker: Device-only deployment with WhiteGlove (classifier: {wgClassification.Reason}) — " +
- "routing to WhiteGlove completion instead of self_deploying_provisioning_complete");
-
- RecordSignal("whiteglove_guard_activated");
-
- EmitTraceEvent("whiteglove_guard_activated",
- "DeviceSetup provisioning complete with WG classifier Strong — routing to WhiteGlove path",
- new Dictionary
- {
- { "wgScore", wgClassification.Score },
- { "wgConfidence", wgClassification.Confidence.ToString() },
- { "wgFactors", wgClassification.ContributingFactors },
- { "whiteGloveStartDetected", wgSignals.IsWhiteGloveStartDetected },
- { "saveWhiteGloveSuccessResult", wgSignals.HasSaveWhiteGloveSuccessResult },
- { "fooUserDetected", wgSignals.IsFooUserDetected },
- { "autopilotMode", _autopilotMode },
- { "skipUserStatusPage", _skipUserStatusPage }
- });
-
- try { CollectDeviceInfoAtFinalizingSetup("whiteglove_provisioning_complete"); }
- catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (whiteglove): {ex.Message}"); }
-
- OnWhiteGloveCompleted(this, EventArgs.Empty);
- ShadowProcessTrigger("whiteglove_complete");
- return;
- }
-
- // Device-only deployment: this is our primary completion signal.
- // No user session → no Hello, no desktop arrival, possibly no Shell-Core ESP exit.
- _logger.Info($"EnrollmentTracker: Device-only deployment (autopilotMode={_autopilotMode}, skipUserStatusPage={_skipUserStatusPage}) + DeviceSetup provisioning complete — " +
- "transitioning to FinalizingSetup and attempting completion");
-
- // Mark ESP as seen and final exit (provisioning success implies ESP device phase completed)
- bool needsSignal = false;
- lock (_stateLock)
- {
- if (!_espEverSeen)
- {
- _espEverSeen = true;
- _stateData.EspEverSeen = true;
- }
- if (!_espFinalExitSeen)
- {
- _espFinalExitSeen = true;
- _stateData.EspFinalExitSeen = true;
- _stateData.EspFinalExitUtc = DateTime.UtcNow;
- needsSignal = true;
- }
- }
- if (needsSignal)
- {
- _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate
- RecordSignal("self_deploying_esp_final_exit");
- }
-
- // Emit FinalizingSetup phase event
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.FinalizingSetup,
- Message = "ESP phase: FinalizingSetup (Self-Deploying — DeviceSetup provisioning complete, no user session expected)",
- Data = new Dictionary
- {
- { "espPhase", "FinalizingSetup" },
- { "autoDetected", true },
- { "triggeredBy", "self_deploying_provisioning_complete" },
- { "autopilotMode", _autopilotMode },
- { "skipUserStatusPage", _skipUserStatusPage }
- },
- ImmediateUpload = true
- });
-
- try { CollectDeviceInfoAtFinalizingSetup("self_deploying_provisioning_complete"); }
- catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (self_deploying): {ex.Message}"); }
-
- // Attempt completion — Hello guard is bypassed for device-only deployments (IsDeviceOnlyDeployment)
- TryEmitEnrollmentComplete("self_deploying_provisioning_complete");
-
- // Shadow state machine: track device_setup_provisioning_complete trigger
- ShadowProcessTrigger("device_setup_provisioning_complete");
- }
-
- ///
- /// Called when WhiteGlove (Pre-Provisioning) completes successfully (event-handler signature).
- /// Subscribed to EspAndHelloTracker.WhiteGloveCompleted. Delegates to CompleteWhiteGlove
- /// which contains the actual emission pipeline.
- ///
- private void OnWhiteGloveCompleted(object sender, EventArgs e)
- {
- // Shell-Core Event 62407 "WhiteGlove_Success" has fired — record it so the
- // WhiteGloveClassifier can see this hard-positive signal (weight +80) on any
- // subsequent classification call in the same session.
- lock (_stateLock) { _shellCoreWhiteGloveFired = true; }
-
- var wgSource = sender?.GetType().Name ?? "unknown";
- CompleteWhiteGlove(wgSource, decisionAlreadyEmitted: false);
- }
-
- ///
- /// Core WhiteGlove completion pipeline: emits the whiteglove_complete terminal event,
- /// the decision_process_completion audit record (unless the caller already emitted one),
- /// stops the summary timer, and updates the shadow state machine. MonitoringService handles
- /// the actual agent shutdown upon seeing whiteglove_complete.
- /// Idempotent: fire-once per session instance. A second invocation (e.g. Shell-Core
- /// watcher fires after the signal-correlated detector already matched) is a no-op.
- ///
- /// Decision source identifier (caller type or descriptor).
- ///
- /// Set to true when the caller already emitted a decision_process_completion for this
- /// terminal (e.g. the WG-redirect guard inside TryEmitEnrollmentComplete). Prevents the
- /// double-decision pattern that loses the upstream decisionChain under a second record.
- ///
- private void CompleteWhiteGlove(string source, bool decisionAlreadyEmitted)
- {
- lock (_stateLock)
- {
- if (_signalCorrelatedWhiteGloveTriggered)
- {
- _logger.Debug("EnrollmentTracker: whiteglove_complete already emitted, skipping duplicate");
- return;
- }
- _signalCorrelatedWhiteGloveTriggered = true;
- }
-
- _logger.Info($"EnrollmentTracker: WhiteGlove pre-provisioning completed (source: {source})");
-
- // Decision audit: only emit if the caller hasn't already recorded the decision that led here.
- // Keeps the invariant "one terminal event = one decision_process_completion".
- if (!decisionAlreadyEmitted)
- {
- EmitDecisionProcess("whiteglove_complete", source, "pending_part1", new List
- {
- $"trigger={source}",
- (_espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false) ? "registry_confirmed" : "registry_not_confirmed",
- (_espAndHelloTracker?.IsWhiteGloveStartDetected ?? false) ? "event509_seen" : "event509_not_seen",
- _aadJoinedWithUser ? "user_joined" : "no_user_joined",
- _fooUserDetected ? "foouser_detected" : "no_foouser"
- });
- }
-
- // Stop summary timer — no more app tracking needed
- _summaryTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
- _summaryTimerActive = false;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "whiteglove_complete",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Complete,
- Message = "WhiteGlove (Pre-Provisioning) completed \u2014 device entering pending state",
- ImmediateUpload = true
- });
-
- // WICHTIG: Kein WriteEnrollmentCompleteMarker()!
- // Das Marker-File wuerde verhindern, dass der Agent beim naechsten Start wieder laeuft.
- // WICHTIG: Kein _imeLogTracker?.DeleteState()!
- // Der State wird fuer Part 2 benoetigt falls der Tracker weiterlaufen muss.
-
- // Shadow state machine: track whiteglove_complete trigger
- ShadowProcessTrigger("whiteglove_complete");
- }
-
- ///
- /// Signal-correlated WhiteGlove detection (Ebene 2.6). Called periodically from the
- /// summary timer (every 30 s). Evaluates the sealing pattern and, on a stable match
- /// lasting ≥10 min, triggers the existing OnWhiteGloveCompleted pipeline — which emits
- /// whiteglove_complete, persists the Part 1 marker, and starts graceful shutdown.
- ///
- /// Pattern (ALL required):
- /// - All device-targeted apps installed, no errors (via PackageStates)
- /// - DeviceSetupCategory.Status is succeeded
- /// - A system reboot has been observed since session start
- /// - AAD join status shows Not Joined
- /// - AccountSetup category has no activity (no resolved subcategories)
- /// - No desktop arrival event has been seen
- /// - No Hello signals (Hello not completed, wizard not started)
- ///
- public void CheckSignalCorrelatedWhiteGlove()
- {
- // Fast-exit: guards that don't require I/O.
- lock (_stateLock)
- {
- if (_signalCorrelatedWhiteGloveTriggered)
- return;
- if (_desktopArrived || _enrollmentCompleteEmitted)
- return;
- // Reboot observation is required — without it we are still in Part 1 pre-reboot.
- if (!_systemRebootObserved)
- return;
- // AAD Not Joined is required — if the user already signed in, this is not sealing.
- if (_aadJoinedWithUser)
- return;
- }
-
- // EspAndHelloTracker provides the authoritative provisioning signals.
- if (_espAndHelloTracker == null)
- return;
-
- bool deviceSetupOk = _espAndHelloTracker.DeviceSetupCategorySucceeded;
- bool hasAccountSetupActivity = _espAndHelloTracker.HasAccountSetupActivity;
- bool helloInFlight = _espAndHelloTracker.IsHelloCompleted; // true once Hello resolved (either success or skip)
-
- // ImeLogTracker provides the app-tracking state.
- var packageStates = _imeLogTracker?.PackageStates;
- bool appsDone = packageStates != null
- && packageStates.CountAll > 0
- && packageStates.CountCompleted >= packageStates.CountAll
- && packageStates.ErrorCount == 0;
-
- bool allConditionsMet = deviceSetupOk && appsDone && !hasAccountSetupActivity && !helloInFlight;
-
- DateTime? triggerAt = null;
- bool shouldTrigger = false;
-
- lock (_stateLock)
- {
- if (!allConditionsMet)
- {
- // Stability window broken — reset.
- if (_signalCorrelatedWhiteGloveStableSince != null)
- {
- _logger.Debug("EnrollmentTracker: signal-correlated WhiteGlove conditions no longer stable, resetting stability window");
- _signalCorrelatedWhiteGloveStableSince = null;
- }
- return;
- }
-
- if (_signalCorrelatedWhiteGloveStableSince == null)
- {
- _signalCorrelatedWhiteGloveStableSince = DateTime.UtcNow;
- _logger.Info($"EnrollmentTracker: signal-correlated WhiteGlove conditions met — starting {SignalCorrelatedWhiteGloveStabilityMinutes}min stability window");
- return;
- }
-
- var stableDuration = DateTime.UtcNow - _signalCorrelatedWhiteGloveStableSince.Value;
- if (stableDuration.TotalMinutes >= SignalCorrelatedWhiteGloveStabilityMinutes)
- {
- shouldTrigger = true;
- triggerAt = _signalCorrelatedWhiteGloveStableSince.Value;
- }
- }
-
- if (shouldTrigger)
- {
- _logger.Info($"EnrollmentTracker: signal-correlated WhiteGlove stable since {triggerAt:HH:mm:ss} UTC — invoking OnWhiteGloveCompleted");
- try
- {
- OnWhiteGloveCompleted(this, EventArgs.Empty);
- }
- catch (Exception ex)
- {
- _logger.Error("Signal-correlated WhiteGlove invocation failed", ex);
- }
- }
- }
-
- ///
- /// Called when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, WhiteGlove_Failed, etc.).
- /// Terminal failures emit enrollment_failed immediately.
- /// Recoverable failures (e.g. ESPProgress_Timeout) get a grace period before failure.
- ///
- ///
- /// Public entry point for secondary ESP-failure detectors (StallProbeCollector) to report
- /// a terminal failure finding. Reuses the same OnEspFailureDetected pathway as the
- /// Shell-Core watcher so the downstream logic (grace period, enrollment_failed emission)
- /// stays unified.
- ///
- public void ReportEspFailureFromExternalSource(string failureType)
- {
- OnEspFailureDetected(this, failureType);
- }
-
- private void OnEspFailureDetected(object sender, string failureType)
- {
- _logger.Info($"EnrollmentTracker: ESP failure detected: {failureType}");
-
- if (RecoverableEspFailureTypes.Contains(failureType))
- {
- // Recoverable failure — start grace period timer
- _logger.Info($"EnrollmentTracker: '{failureType}' is recoverable — starting {EspFailureGracePeriodSeconds}s grace period");
-
- string previousPending;
- lock (_stateLock)
- {
- previousPending = _pendingEspFailureType;
- _pendingEspFailureType = failureType;
- }
-
- // Cancel existing timer if any (e.g. second timeout event)
- if (_espFailureTimer != null)
- _logger.Debug($"EnrollmentTracker: resetting existing grace period timer (previous pending: '{previousPending}')");
- _espFailureTimer?.Dispose();
- _espFailureTimer = new Timer(
- OnEspFailureGracePeriodExpired,
- null,
- TimeSpan.FromSeconds(EspFailureGracePeriodSeconds),
- TimeSpan.FromMilliseconds(-1));
- }
- else
- {
- // Terminal failure — emit enrollment_failed immediately
- _logger.Info($"EnrollmentTracker: '{failureType}' is terminal — emitting enrollment_failed immediately");
- EmitEnrollmentFailed(failureType, "esp_failure");
-
- // Shadow state machine: track terminal ESP failure
- var ctx = SnapshotCompletionContext();
- ctx.Source = failureType;
- ShadowProcessTrigger("esp_failure_terminal", ctx);
- }
- }
-
- ///
- /// Called when the ESP failure grace period expires without recovery.
- /// Emits enrollment_failed.
- ///
- private void OnEspFailureGracePeriodExpired(object state)
- {
- string failureType;
- lock (_stateLock)
- {
- failureType = _pendingEspFailureType ?? "unknown";
- _pendingEspFailureType = null;
- }
- _logger.Warning($"EnrollmentTracker: ESP failure grace period ({EspFailureGracePeriodSeconds}s) expired for '{failureType}' — emitting enrollment_failed");
-
- EmitEnrollmentFailed(failureType, "esp_failure_grace_expired");
-
- // Shadow state machine: track grace period expiry
- var ctx = SnapshotCompletionContext();
- ctx.Source = failureType;
- ShadowProcessTrigger("esp_failure_grace_expired", ctx);
- }
-
- ///
- /// Cancels any pending ESP failure grace period timer (called when recovery is detected).
- ///
- private void CancelPendingEspFailure()
- {
- if (_espFailureTimer != null)
- {
- string pending;
- lock (_stateLock) { pending = _pendingEspFailureType; }
- _logger.Info($"EnrollmentTracker: ESP recovery detected — cancelling pending failure for '{pending}'");
- _espFailureTimer.Dispose();
- _espFailureTimer = null;
- lock (_stateLock) { _pendingEspFailureType = null; }
- }
- }
-
- ///
- /// Safety-net callback: fires 7 minutes after waiting_for_hello was set.
- /// If the normal Hello timeout chain (30s + 300s) failed for any reason,
- /// this forces enrollment_complete instead of waiting for the 6h max lifetime timer.
- ///
- private void OnWaitingForHelloSafetyTimeout(object state)
- {
- bool isWaiting, alreadyEmitted;
- lock (_stateLock)
- {
- isWaiting = _isWaitingForHello;
- alreadyEmitted = _enrollmentCompleteEmitted;
- if (isWaiting && !alreadyEmitted)
- _isWaitingForHello = false;
- }
-
- if (!isWaiting || alreadyEmitted)
- {
- _logger.Debug($"EnrollmentTracker: Hello safety timeout fired but not applicable (waiting={isWaiting}, emitted={alreadyEmitted})");
- return;
- }
-
- _logger.Warning($"EnrollmentTracker: waiting_for_hello safety timeout ({WaitingForHelloSafetyTimeoutSeconds}s) expired — forcing completion");
-
- // Force-resolve Hello so TryEmitEnrollmentComplete's hello check passes
- if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
- {
- _espAndHelloTracker.ForceMarkHelloCompleted("safety_timeout");
- }
-
- TryEmitEnrollmentComplete("ime_hello_safety_timeout");
-
- // Shadow state machine: track hello safety timeout
- ShadowProcessTrigger("hello_safety_timeout");
- }
-
- ///
- /// Timer callback: fires after EspSettleTimeoutSeconds (30s) when waiting for ESP provisioning
- /// categories to resolve. Completes enrollment regardless of whether categories settled.
- /// The snapshot captured in TryEmitEnrollmentComplete will reflect the final state.
- ///
- private void OnEspSettleTimerExpired(object state)
- {
- bool isWaiting, alreadyEmitted;
- lock (_stateLock)
- {
- isWaiting = _isWaitingForEspSettle;
- alreadyEmitted = _enrollmentCompleteEmitted;
- if (isWaiting && !alreadyEmitted)
- _isWaitingForEspSettle = false;
- }
-
- if (!isWaiting || alreadyEmitted)
- {
- _logger.Debug($"EnrollmentTracker: ESP settle timer expired but not applicable " +
- $"(waiting={isWaiting}, emitted={alreadyEmitted})");
- return;
- }
-
- _logger.Info($"EnrollmentTracker: ESP settle timer expired ({EspSettleTimeoutSeconds}s) " +
- "— proceeding to completion with current ESP provisioning state");
- RecordSignal("esp_provisioning_settled");
- TryEmitEnrollmentComplete("ime_pattern");
-
- // Shadow state machine: track ESP settle timeout
- ShadowProcessTrigger("esp_settle_timeout");
- }
-
- ///
- /// Safety-net callback for device-only ESP (SkipUserStatusPage=true) completion.
- /// If the normal Hello timer chain (30s wait + 300s completion) failed for any reason
- /// (exception, timer disposal, race condition), this forces enrollment_complete instead
- /// of waiting for the 6h max lifetime timer.
- ///
- private void OnDeviceOnlyCompletionSafetyTimeout(object state)
- {
- bool alreadyEmitted;
- lock (_stateLock) { alreadyEmitted = _enrollmentCompleteEmitted; }
-
- if (alreadyEmitted)
- {
- _logger.Debug("EnrollmentTracker: device-only completion safety timeout fired but enrollment already completed — ignoring");
- return;
- }
-
- _logger.Warning($"EnrollmentTracker: device-only ESP completion safety timeout ({DeviceOnlyCompletionSafetyTimeoutSeconds}s) expired — forcing completion");
-
- // Force-resolve Hello if still pending so the Hello guard in TryEmitEnrollmentComplete passes
- if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
- {
- _espAndHelloTracker.ForceMarkHelloCompleted("device_only_safety_timeout");
- }
-
- TryEmitEnrollmentComplete("device_only_esp_safety_timeout");
-
- bool emittedAfterAttempt;
- lock (_stateLock) { emittedAfterAttempt = _enrollmentCompleteEmitted; }
- if (!emittedAfterAttempt)
- {
- _logger.Error("EnrollmentTracker: device-only ESP safety timeout — TryEmitEnrollmentComplete still did not fire (unexpected guard block)");
- }
-
- // Shadow state machine: track device-only safety timeout
- ShadowProcessTrigger("device_only_safety_timeout");
- }
-
- ///
- /// Emits an enrollment_failed event. The MonitoringService handles shutdown identically to enrollment_complete.
- ///
- private void EmitEnrollmentFailed(string failureType, string failureSource)
- {
- // Emit decision process before the terminal event
- EmitDecisionProcess("enrollment_failed", failureSource, failureType, new List
- {
- $"failure_type={failureType}",
- $"failure_source={failureSource}",
- _espEverSeen ? $"esp_seen(finalExit={_espFinalExitSeen})" : "esp_not_seen",
- _desktopArrived ? "desktop_arrived" : "desktop_not_arrived"
- });
-
- // Stop timers
- _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _summaryTimerActive = false;
- _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debugStateTimerActive = false;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "enrollment_failed",
- Severity = EventSeverity.Error,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Complete,
- Message = $"Autopilot enrollment failed: {failureType}",
- Data = new Dictionary
- {
- { "failureType", failureType },
- { "failureSource", failureSource },
- { "signalsSeen", SnapshotSignalsSeen() },
- { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }
- },
- ImmediateUpload = true
- });
-
- // Write final status dump before cleanup
- WriteFinalStatus("failed", failureType);
-
- // Clean up persisted tracker state
- _imeLogTracker?.DeleteState();
- _statePersistence.Delete();
- }
-
- ///
- /// Called when ESP exit or Hello wizard start is detected (via HelloDetector Shell-Core events)
- /// Triggers transition to FinalizingSetup phase
- ///
- private void OnFinalizingSetupPhaseTriggered(object sender, string reason)
- {
- _logger.Info($"EnrollmentTracker: FinalizingSetup phase trigger received - reason: {reason}");
-
- // If ESP exiting, check which phase we're in
- if (reason == "esp_exiting")
- {
- string lastPhase;
- bool desktopArr;
- bool? skipUser;
- lock (_stateLock)
- {
- _espEverSeen = true;
- lastPhase = _lastEspPhase;
- desktopArr = _desktopArrived;
- skipUser = _skipUserStatusPage;
- }
-
- // Defense-in-depth: if lastEspPhase is null (state lost after reboot) but the
- // ESP provisioning registry shows AccountSetup activity, treat as AccountSetup.
- // This catches the edge case where neither persistence nor backfill recovered the phase.
- bool accountSetupFromProvisioning = lastPhase == null
- && (_espAndHelloTracker?.HasAccountSetupActivity == true);
-
- if (string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)
- || desktopArr
- || accountSetupFromProvisioning)
- {
- // Final ESP exit: AccountSetup phase detected, desktop arrived (backup), or provisioning registry confirms AccountSetup
- var phaseInfo = string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)
- ? "AccountSetup"
- : accountSetupFromProvisioning
- ? "AccountSetup (provisioning registry fallback)"
- : $"{lastPhase ?? "unknown"} (desktop arrival backup)";
- _logger.Info($"EnrollmentTracker: ESP final exit from {phaseInfo} — marking _espFinalExitSeen, starting Hello wait timer");
-
- lock (_stateLock)
- {
- _espFinalExitSeen = true;
- _stateData.EspFinalExitSeen = true;
- _stateData.EspFinalExitUtc = DateTime.UtcNow;
- }
- _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate
- RecordSignal("esp_final_exit");
-
- // Emit phase change event to FinalizingSetup
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.FinalizingSetup,
- Message = $"ESP phase: FinalizingSetup ({phaseInfo} completed, waiting for final steps)",
- Data = new Dictionary
- {
- { "espPhase", "FinalizingSetup" },
- { "autoDetected", true },
- { "triggeredBy", reason },
- { "previousPhase", lastPhase ?? "unknown" },
- { "desktopArrivedBackup", desktopArr && !string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) }
- },
- ImmediateUpload = true
- });
-
- try { CollectDeviceInfoAtFinalizingSetup(reason); }
- catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (esp_final_exit): {ex.Message}"); }
-
- // Start Hello wait timer (waits for Hello wizard to start or timeout)
- _espAndHelloTracker?.StartHelloWaitTimer();
-
- // If Hello was already resolved (e.g., via EventLog backfill or Event 300/301
- // during AccountSetup), the composite signal can fire immediately.
- TryEmitEnrollmentComplete("esp_hello_composite");
- }
- else if (skipUser == true)
- {
- // WhiteGlove guard — canonical decision via WhiteGloveClassifier, identical to
- // the other three WG decision sites. HasSaveWhiteGloveSuccessResult alone is
- // not sufficient: after WhiteGlove Part 1 the registry flag remains set and a
- // subsequent user-enrollment ESP exit (Part 2) would falsely re-route to WG.
- // The classifier's AadJoinedWithUser hard-excluder blocks that path correctly.
- var wgSignals = SnapshotWhiteGloveSignals();
- var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
- EmitWhiteGloveClassification(wgClassification, "EspExiting_SkipUserTrue", wgSignals);
-
- if (wgClassification.ShouldRouteToWhiteGlovePart1)
- {
- _logger.Info($"EnrollmentTracker: ESP exiting with SkipUserStatusPage=true and WG classifier Strong " +
- $"({wgClassification.Reason}) — routing to WhiteGlove completion instead of device_only_esp_registry");
-
- RecordSignal("whiteglove_guard_activated");
- EmitTraceEvent("whiteglove_guard_esp_exiting",
- "ESP exiting with WG classifier Strong — routing to WhiteGlove path",
- new Dictionary
- {
- { "wgScore", wgClassification.Score },
- { "wgConfidence", wgClassification.Confidence.ToString() },
- { "wgFactors", wgClassification.ContributingFactors },
- { "whiteGloveStartDetected", wgSignals.IsWhiteGloveStartDetected },
- { "saveWhiteGloveSuccessResult", wgSignals.HasSaveWhiteGloveSuccessResult },
- { "fooUserDetected", wgSignals.IsFooUserDetected },
- { "aadJoinedWithUser", wgSignals.AadJoinedWithUser },
- { "previousPhase", lastPhase ?? "unknown" }
- });
-
- try { CollectDeviceInfoAtFinalizingSetup("whiteglove_esp_exiting"); }
- catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (whiteglove_esp_exiting): {ex.Message}"); }
-
- OnWhiteGloveCompleted(this, EventArgs.Empty);
- ShadowProcessTrigger("whiteglove_complete");
- return;
- }
-
- // Registry definitively says no AccountSetup expected → immediate device-only classification
- _logger.Info($"EnrollmentTracker: ESP phase exiting from '{lastPhase ?? "unknown"}' — SkipUserStatusPage=true, classified as device-only ESP (registry)");
-
- lock (_stateLock)
- {
- _espFinalExitSeen = true;
- _stateData.EspFinalExitSeen = true;
- _stateData.EspFinalExitUtc = DateTime.UtcNow;
- }
- _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate
- RecordSignal("device_only_esp_registry");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.FinalizingSetup,
- Message = "ESP phase: FinalizingSetup (device-only ESP — SkipUserStatusPage confirmed via registry)",
- Data = new Dictionary
- {
- { "espPhase", "FinalizingSetup" },
- { "autoDetected", true },
- { "triggeredBy", "device_only_esp_registry" },
- { "previousPhase", lastPhase ?? "unknown" },
- { "skipUserStatusPage", true }
- },
- ImmediateUpload = true
- });
-
- try { CollectDeviceInfoAtFinalizingSetup("device_only_esp_registry"); }
- catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (device_only_esp_registry): {ex.Message}"); }
-
- _espAndHelloTracker?.StartHelloWaitTimer();
- TryEmitEnrollmentComplete("device_only_esp_registry");
-
- // Safety net: if enrollment_complete didn't fire immediately (Hello pending),
- // start a safety timer to force completion. Without this, a failed Hello timer
- // chain would leave the session hanging until the 6h max lifetime timer.
- bool emittedYet;
- lock (_stateLock) { emittedYet = _enrollmentCompleteEmitted; }
- if (!emittedYet)
- {
- _logger.Info($"EnrollmentTracker: device-only ESP completion pending — starting safety timer ({DeviceOnlyCompletionSafetyTimeoutSeconds}s)");
- _deviceOnlyCompletionSafetyTimer?.Dispose();
- _deviceOnlyCompletionSafetyTimer = new Timer(
- OnDeviceOnlyCompletionSafetyTimeout,
- null,
- TimeSpan.FromSeconds(DeviceOnlyCompletionSafetyTimeoutSeconds),
- TimeSpan.FromMilliseconds(-1));
- }
- }
- else
- {
- // Registry keys unknown or SkipUserStatusPage=false → fallback to timer-based detection
- var fallbackReason = skipUser == null ? "registry keys not found" : "SkipUserStatusPage=false";
- _logger.Info($"EnrollmentTracker: ESP phase exiting from '{lastPhase ?? "unknown"}' — {fallbackReason}, starting device-only ESP detection timer ({DeviceOnlyEspTimerMinutes}min)");
-
- // Start device-only ESP detection timer
- _deviceOnlyEspTimer?.Dispose();
- _deviceOnlyEspTimer = new Timer(
- OnDeviceOnlyEspTimerExpired,
- null,
- TimeSpan.FromMinutes(DeviceOnlyEspTimerMinutes),
- TimeSpan.FromMilliseconds(-1));
- }
- }
- else if (reason == "hello_wizard_started")
- {
- // Hello wizard started - transition to FinalizingSetup regardless of previous phase
- _logger.Info("EnrollmentTracker: Hello wizard started - transitioning to FinalizingSetup phase");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.FinalizingSetup,
- Message = "ESP phase: FinalizingSetup (Hello wizard started)",
- Data = new Dictionary
- {
- { "espPhase", "FinalizingSetup" },
- { "autoDetected", true },
- { "triggeredBy", reason }
- },
- ImmediateUpload = true
- });
-
- try { CollectDeviceInfoAtFinalizingSetup(reason); }
- catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (hello_wizard_started): {ex.Message}"); }
- }
-
- // Shadow state machine: track esp_exiting trigger (covers all branches above)
- if (reason == "esp_exiting")
- ShadowProcessTrigger("esp_exiting");
- }
-
- // ===== State Persistence =====
-
- private void LoadState()
- {
- var loaded = _statePersistence.Load();
- if (loaded == null)
- {
- _logger.Debug("EnrollmentTracker: no persisted state found (fresh enrollment)");
- return;
- }
-
- lock (_stateLock)
- {
- _espEverSeen = loaded.EspEverSeen;
- _espFinalExitSeen = loaded.EspFinalExitSeen;
- _desktopArrived = loaded.DesktopArrived;
- _lastEspPhase = loaded.LastEspPhase;
- _isWaitingForHello = loaded.IsWaitingForHello;
- _isWaitingForEspSettle = loaded.IsWaitingForEspSettle;
- _enrollmentCompleteEmitted = loaded.EnrollmentCompleteEmitted;
- _enrollmentType = loaded.EnrollmentType ?? _enrollmentType;
- _flowHandler = Flows.EnrollmentFlowFactory.FromWireFormat(_enrollmentType);
- _skipUserStatusPage = loaded.SkipUserStatusPage;
- _skipDeviceStatusPage = loaded.SkipDeviceStatusPage;
- _autopilotMode = loaded.AutopilotMode;
- _aadJoinedWithUser = loaded.AadJoinedWithUser;
- _fooUserDetected = loaded.FooUserDetected;
- // Restore hybrid join flag from persisted state; re-detect as fallback
- _isHybridJoin = loaded.IsHybridJoin || DetectHybridJoinStatic();
- _stateData = loaded;
- }
-
- // Shadow state machine: restore state from persistence
- try
- {
- EnrollmentCompletionState smState;
- if (!string.IsNullOrEmpty(loaded.CompletionState)
- && Enum.TryParse(loaded.CompletionState, out smState))
- {
- // New format: explicit state stored
- _logger.Verbose($"EnrollmentTracker [SHADOW]: restoring explicit state '{loaded.CompletionState}' from persistence");
- }
- else
- {
- // Old format: reconstruct from boolean flags
- bool isDeviceOnly = CompletionGuards.IsDeviceOnlyDeployment(
- loaded.AutopilotMode, loaded.SkipUserStatusPage, loaded.AadJoinedWithUser);
- smState = CompletionStateMachine.ReconstructStateFromFlags(
- loaded.EnrollmentCompleteEmitted, loaded.IsWaitingForHello,
- loaded.IsWaitingForEspSettle, loaded.EspFinalExitSeen,
- loaded.DesktopArrived, loaded.EspEverSeen, isDeviceOnly);
- _logger.Verbose($"EnrollmentTracker [SHADOW]: reconstructed state '{smState}' from boolean flags");
- }
- _completionSm.RestoreState(smState,
- loaded.EspEverSeen, loaded.EspFinalExitSeen, loaded.DesktopArrived,
- loaded.IsWaitingForHello, loaded.IsWaitingForEspSettle, deferredSource: null);
- }
- catch (Exception ex)
- {
- _logger.Debug($"EnrollmentTracker [SHADOW ERROR]: state restoration failed: {ex.Message}");
- }
-
- _logger.Info($"EnrollmentTracker: state restored — espEverSeen={_espEverSeen}, espFinalExitSeen={_espFinalExitSeen}, desktopArrived={_desktopArrived}, lastEspPhase={_lastEspPhase}, enrollmentCompleteEmitted={_enrollmentCompleteEmitted}");
-
- // Restart Hello wait timer if needed after crash recovery
- if ((_desktopArrived || _espFinalExitSeen) && _espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
- {
- _logger.Info("EnrollmentTracker: restarting Hello wait timer after state recovery");
- _espAndHelloTracker.StartHelloWaitTimer();
- }
-
- // Restart safety timer if we were waiting for Hello — use remaining time, not full duration
- if (_isWaitingForHello && !_enrollmentCompleteEmitted)
- {
- var remaining = WaitingForHelloSafetyTimeoutSeconds;
- if (_stateData.WaitingForHelloStartedUtc.HasValue)
- {
- var elapsed = (DateTime.UtcNow - _stateData.WaitingForHelloStartedUtc.Value).TotalSeconds;
- remaining = Math.Max(0, WaitingForHelloSafetyTimeoutSeconds - (int)elapsed);
- }
-
- if (remaining <= 0)
- {
- _logger.Warning("EnrollmentTracker: waiting_for_hello safety timeout already expired during crash recovery — forcing completion now");
- _isWaitingForHello = false;
- _waitingForHelloSafetyTimer?.Dispose();
- _waitingForHelloSafetyTimer = new Timer(
- OnWaitingForHelloSafetyTimeout,
- null,
- TimeSpan.Zero,
- TimeSpan.FromMilliseconds(-1));
- }
- else
- {
- _logger.Info($"EnrollmentTracker: restarting waiting_for_hello safety timer after state recovery ({remaining}s remaining)");
- _waitingForHelloSafetyTimer?.Dispose();
- _waitingForHelloSafetyTimer = new Timer(
- OnWaitingForHelloSafetyTimeout,
- null,
- TimeSpan.FromSeconds(remaining),
- TimeSpan.FromMilliseconds(-1));
- }
- }
-
- // Restart ESP settle timer if we were waiting — use remaining time, not full duration
- if (_isWaitingForEspSettle && !_enrollmentCompleteEmitted)
- {
- var remaining = EspSettleTimeoutSeconds;
- if (_stateData.WaitingForEspSettleStartedUtc.HasValue)
- {
- var elapsed = (DateTime.UtcNow - _stateData.WaitingForEspSettleStartedUtc.Value).TotalSeconds;
- remaining = Math.Max(0, EspSettleTimeoutSeconds - (int)elapsed);
- }
-
- if (remaining <= 0)
- {
- _logger.Warning("EnrollmentTracker: ESP settle timeout already expired during crash recovery — completing now");
- _isWaitingForEspSettle = false;
- _waitingForEspSettleTimer?.Dispose();
- _waitingForEspSettleTimer = new Timer(
- OnEspSettleTimerExpired,
- null,
- TimeSpan.Zero,
- TimeSpan.FromMilliseconds(-1));
- }
- else
- {
- _logger.Info($"EnrollmentTracker: restarting ESP settle timer after state recovery ({remaining}s remaining)");
- _waitingForEspSettleTimer?.Dispose();
- _waitingForEspSettleTimer = new Timer(
- OnEspSettleTimerExpired,
- null,
- TimeSpan.FromSeconds(remaining),
- TimeSpan.FromMilliseconds(-1));
- }
- }
- }
-
- private void RecordSignal(string signal)
- {
- bool added;
- int count;
- lock (_stateLock)
- {
- if (!_stateData.SignalsSeen.Contains(signal))
- {
- _stateData.SignalsSeen.Add(signal);
- added = true;
- count = _stateData.SignalsSeen.Count;
- }
- else
- {
- added = false;
- count = _stateData.SignalsSeen.Count;
- }
- _stateDirty = true;
- }
-
- if (added)
- _logger.Verbose($"EnrollmentTracker: signal recorded: '{signal}' (total: {count})");
- else
- _logger.Debug($"EnrollmentTracker: signal '{signal}' already recorded — deduplicated");
- }
-
- ///
- /// Returns a thread-safe snapshot of _stateData.SignalsSeen for use in event data.
- ///
- private List SnapshotSignalsSeen()
- {
- lock (_stateLock)
- {
- return new List(_stateData.SignalsSeen);
- }
- }
-
- // ===== Unified Completion Logic =====
-
- ///
- /// Central guard method for enrollment_complete emission. All completion paths route through here.
- /// An _enrollmentCompleteEmitted flag prevents double emission.
- /// Emits a throttled completion_check event on every call for observability.
- ///
- private void TryEmitEnrollmentComplete(string source)
- {
- if (string.IsNullOrEmpty(source))
- {
- _logger.Debug("EnrollmentTracker: TryEmitEnrollmentComplete called with empty source — ignoring");
- return;
- }
-
- // Atomically check all guards and claim the completion flag under a single lock.
- // This prevents the critical double-emission race where two threads (e.g. OnHelloCompleted
- // and OnWaitingForHelloSafetyTimeout) both read _enrollmentCompleteEmitted=false simultaneously.
- bool helloResolved;
- bool espGateBlocked;
- bool hybridRebootGateBlocked;
- bool alreadyEmitted;
- string enrollmentType;
- bool espEverSeen, espFinalExitSeen, desktopArrived;
- bool isDeviceOnly, isSelfDeploying;
- int? autopilotMode;
- List signalsSeen;
-
- lock (_stateLock)
- {
- if (_enrollmentCompleteEmitted)
- {
- alreadyEmitted = true;
- helloResolved = false;
- espGateBlocked = false;
- hybridRebootGateBlocked = false;
- enrollmentType = _enrollmentType;
- espEverSeen = _espEverSeen;
- espFinalExitSeen = _espFinalExitSeen;
- desktopArrived = _desktopArrived;
- isDeviceOnly = IsDeviceOnlyDeployment;
- isSelfDeploying = IsSelfDeploying;
- autopilotMode = _autopilotMode;
- signalsSeen = null;
- }
- else
- {
- alreadyEmitted = false;
- enrollmentType = _enrollmentType;
- espEverSeen = _espEverSeen;
- espFinalExitSeen = _espFinalExitSeen;
- desktopArrived = _desktopArrived;
- isDeviceOnly = IsDeviceOnlyDeployment;
- isSelfDeploying = IsSelfDeploying;
- autopilotMode = _autopilotMode;
-
- helloResolved = _espAndHelloTracker == null
- || _espAndHelloTracker.IsHelloCompleted
- || !_espAndHelloTracker.IsPolicyConfigured
- || isDeviceOnly;
-
- espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello")
- && _flowHandler.AppliesEspGateOnDesktopArrival && espEverSeen && !espFinalExitSeen;
-
- // GUARD 3: Hybrid Join reboot gate
- // In hybrid join, ESP exit may be for a mid-enrollment reboot (domain user
- // login required). Block esp_hello_composite unless we have stronger confirmation:
- // - IME user session completed (strongest signal: IME says enrollment is done)
- // - Agent restarted after ESP exit (reboot happened, we survived it)
- hybridRebootGateBlocked = false;
- if (_isHybridJoin && source == "esp_hello_composite")
- {
- bool hasImeCompletion = _stateData.ImePatternSeenUtc.HasValue;
- bool agentRestartedAfterEspExit = _stateData.EspFinalExitUtc.HasValue
- && _agentStartTimeUtc > _stateData.EspFinalExitUtc.Value;
-
- hybridRebootGateBlocked = !hasImeCompletion && !agentRestartedAfterEspExit;
- }
-
- // If all guards pass, claim the flag atomically
- if (helloResolved && !espGateBlocked && !hybridRebootGateBlocked)
- {
- _enrollmentCompleteEmitted = true;
- _stateData.EnrollmentCompleteEmitted = true;
- signalsSeen = new List(_stateData.SignalsSeen);
-
- // Stop recurring/watchdog timers under the SAME lock that claims
- // the emission flag. Concurrent timer callbacks already past their
- // own _enrollmentCompleteEmitted check will no-op further work;
- // callbacks not yet past it will read flag=true and return.
- // Change(Inf,Inf) is idempotent and deadlock-safe inside the lock;
- // Dispose would wait on in-flight callbacks and is risky here.
- _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _summaryTimerActive = false;
- _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debugStateTimerActive = false;
- _deviceOnlyCompletionSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- }
- else
- {
- signalsSeen = null;
- }
- }
- }
-
- // All logging and event emission happens OUTSIDE the lock
- if (alreadyEmitted)
- {
- _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — already emitted, skipping");
- return;
- }
-
- _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — evaluating guards " +
- $"[espEverSeen={espEverSeen}, espFinalExitSeen={espFinalExitSeen}, desktopArrived={desktopArrived}, " +
- $"helloCompleted={_espAndHelloTracker?.IsHelloCompleted ?? false}, helloPolicyConfigured={_espAndHelloTracker?.IsPolicyConfigured ?? false}, " +
- $"enrollmentType={enrollmentType}, autopilotMode={autopilotMode}, isSelfDeploying={isSelfDeploying}, isDeviceOnlyDeployment={isDeviceOnly}, " +
- $"isHybridJoin={_isHybridJoin}]");
-
- if (!helloResolved)
- {
- _logger.Info($"EnrollmentTracker: Completion source '{source}' ready but Hello still pending — not completing yet");
- EmitCompletionCheck(source, "hello_pending", "hello_not_resolved");
- return;
- }
-
- if (espGateBlocked)
- {
- _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — ESP still active");
- EmitCompletionCheck(source, "blocked", "esp_active");
- return;
- }
-
- if (hybridRebootGateBlocked)
- {
- _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — hybrid join reboot gate " +
- $"(waiting for IME completion or agent restart after ESP exit)");
- EmitCompletionCheck(source, "blocked", "hybrid_reboot_gate");
- return;
- }
-
- // GUARD 4: WhiteGlove Part 1 redirect — canonical classifier decision.
- // All four WG decision sites go through WhiteGloveClassifier (this guard,
- // OnDeviceSetupProvisioningComplete, OnFinalizingSetupPhaseTriggered esp_exiting,
- // and the StateMachine), so decisions converge on the same rule.
- // ShouldRouteToWhiteGlovePart1==true only on Strong confidence; Weak is
- // observable telemetry but falls through to the regular completion path.
- var wgSignals = SnapshotWhiteGloveSignals();
- var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
- EmitWhiteGloveClassification(wgClassification, "TryEmitEnrollmentComplete", wgSignals);
-
- if (wgClassification.ShouldRouteToWhiteGlovePart1)
- {
- _logger.Info($"EnrollmentTracker: all guards passed and WG classifier Strong — " +
- $"redirecting to WhiteGlove Part 1 (source={source}, {wgClassification.Reason})");
-
- EmitDecisionProcess("whiteglove_complete", source, "redirected_wg_part1", new List
- {
- $"original_source={source}",
- $"wg_score={wgClassification.Score}",
- $"wg_confidence={wgClassification.Confidence}",
- }.Concat(wgClassification.ContributingFactors ?? new List())
- .Concat(new[] { "redirect: enrollment_complete -> whiteglove_complete" })
- .ToList(),
- classification: wgClassification);
-
- // Decision is already recorded above — CompleteWhiteGlove must not emit a second one.
- CompleteWhiteGlove(source, decisionAlreadyEmitted: true);
- return;
- }
-
- _logger.Info($"EnrollmentTracker: all guards passed for source '{source}' — emitting enrollment_complete " +
- $"(wg_classifier: {wgClassification.Reason})");
-
- // Emit decision process before the terminal event
- EmitDecisionProcess("enrollment_complete", source, "succeeded", new List
- {
- $"source={source}",
- helloResolved ? "hello_resolved" : "hello_bypassed",
- espEverSeen ? $"esp_seen(finalExit={espFinalExitSeen})" : "esp_not_seen",
- desktopArrived ? "desktop_arrived" : "desktop_not_arrived",
- isDeviceOnly ? "device_only" : "user_driven",
- _isHybridJoin ? "hybrid_join" : "aad_join",
- wgSignals.HasSaveWhiteGloveSuccessResult ? "wg_registry_succeeded" : "no_wg_registry",
- $"wg_score={wgClassification.Score}",
- $"wg_confidence={wgClassification.Confidence}"
- }, classification: wgClassification);
-
- // Timers were already stopped inside the lock when _enrollmentCompleteEmitted was claimed.
-
- var helloOutcome = _espAndHelloTracker?.HelloOutcome ?? "unknown";
-
- // Snapshot timestamps under lock for consistent event data
- Dictionary signalTimestamps;
- lock (_stateLock)
- {
- signalTimestamps = new Dictionary();
- if (_stateData.EspFirstSeenUtc.HasValue)
- signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
- if (_stateData.DesktopArrivedUtc.HasValue)
- signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
- if (_stateData.EspFinalExitUtc.HasValue)
- signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
- if (_stateData.HelloResolvedUtc.HasValue)
- signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
- if (_stateData.ImePatternSeenUtc.HasValue)
- signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
- if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue)
- signalTimestamps["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o");
- }
-
- // Capture ESP provisioning snapshot for the enrollment_complete event (all sources)
- Dictionary espProvisioningData = null;
- if (_espAndHelloTracker != null)
- {
- var espSnapshot = _espAndHelloTracker.GetProvisioningSnapshot();
- if (espSnapshot != null)
- {
- espProvisioningData = new Dictionary
- {
- { "categoriesSeen", espSnapshot.CategoriesSeen },
- { "categoriesResolved", espSnapshot.CategoriesResolved },
- { "allResolved", espSnapshot.AllResolved },
- { "categoryOutcomes", espSnapshot.CategoryOutcomes },
- { "subcategoryStates", espSnapshot.SubcategoryStates }
- };
- }
- }
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "enrollment_complete",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Complete,
- Message = $"Autopilot enrollment completed successfully (source: {source})",
- Data = new Dictionary
- {
- { "completionSource", source },
- { "helloOutcome", helloOutcome },
- { "autopilotMode", autopilotMode?.ToString() ?? "unknown" },
- { "isSelfDeploying", isSelfDeploying },
- { "isDeviceOnlyDeployment", isDeviceOnly },
- { "signalsSeen", signalsSeen },
- { "signalTimestamps", signalTimestamps },
- { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds },
- { "espProvisioningStatus", espProvisioningData }
- },
- ImmediateUpload = true
- });
-
- // Write final status dump and enrollment complete marker
- WriteFinalStatus("completed", source);
- WriteEnrollmentCompleteMarker();
-
- // Clean up persisted tracker state so next enrollment starts fresh
- _imeLogTracker?.DeleteState();
- _statePersistence.Delete();
- }
-
- ///
- /// Emits a throttled completion_check event for observability.
- /// Max 1 event per minute per source to avoid flooding.
- ///
- private void EmitCompletionCheck(string source, string result, string reason)
- {
- // Throttle: max 1x per minute per source
- var now = DateTime.UtcNow;
- if (_lastCompletionCheckBySource.TryGetValue(source, out var lastEmit) && (now - lastEmit).TotalSeconds < 60)
- return;
- _lastCompletionCheckBySource[source] = now;
-
- Dictionary checkData;
- lock (_stateLock)
- {
- checkData = new Dictionary
- {
- { "source", source },
- { "result", result },
- { "reason", reason },
- { "espEverSeen", _espEverSeen },
- { "espFinalExitSeen", _espFinalExitSeen },
- { "desktopArrived", _desktopArrived },
- { "helloResolved", _espAndHelloTracker?.IsHelloCompleted ?? false },
- { "helloPolicyConfigured", _espAndHelloTracker?.IsPolicyConfigured ?? false },
- { "enrollmentType", _enrollmentType },
- { "lastEspPhase", _lastEspPhase ?? "none" },
- { "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" },
- { "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" },
- { "autopilotMode", _autopilotMode?.ToString() ?? "unknown" },
- { "isSelfDeploying", IsSelfDeploying },
- { "isDeviceOnlyDeployment", IsDeviceOnlyDeployment },
- { "whiteGloveStartDetected", _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false },
- { "saveWhiteGloveSuccessResult", _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false },
- { "fooUserDetected", _fooUserDetected }
- };
- }
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "completion_check",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Completion source '{source}' evaluated — {result}",
- ImmediateUpload = true,
- Data = checkData
- });
- }
-
- ///
- /// Emits a decision_process_completion event immediately before a terminal event
- /// (enrollment_complete, whiteglove_complete, enrollment_failed).
- /// Contains the full state machine snapshot, all signal timestamps, and the decision chain
- /// that led to the outcome — enabling post-hoc analysis without reconstructing from individual events.
- ///
- private void EmitDecisionProcess(string terminalEvent, string source, string outcome,
- List decisionChain = null,
- WhiteGloveClassification classification = null)
- {
- Dictionary data;
- lock (_stateLock)
- {
- // Full state machine snapshot
- data = new Dictionary
- {
- // Decision metadata
- { "terminalEvent", terminalEvent },
- { "completionSource", source },
- { "outcome", outcome },
- { "decisionChain", decisionChain ?? new List() },
-
- // Core state variables
- { "enrollmentType", _enrollmentType },
- { "autopilotMode", _autopilotMode?.ToString() ?? "unknown" },
- { "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" },
- { "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" },
- { "isHybridJoin", _isHybridJoin },
- { "isSelfDeploying", IsSelfDeploying },
- { "isDeviceOnlyDeployment", IsDeviceOnlyDeployment },
-
- // ESP state
- { "espEverSeen", _espEverSeen },
- { "espFinalExitSeen", _espFinalExitSeen },
- { "lastEspPhase", _lastEspPhase ?? "none" },
-
- // Desktop & Hello state
- { "desktopArrived", _desktopArrived },
- { "helloResolved", _espAndHelloTracker?.IsHelloCompleted ?? false },
- { "helloPolicyConfigured", _espAndHelloTracker?.IsPolicyConfigured ?? false },
- { "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" },
-
- // WhiteGlove signals
- { "whiteGloveStartDetected", _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false },
- { "saveWhiteGloveSuccessResult", _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false },
- { "fooUserDetected", _fooUserDetected },
- { "aadJoinedWithUser", _aadJoinedWithUser },
-
- // Signal audit trail with timestamps
- { "signalsSeen", new List(_stateData.SignalsSeen) },
-
- // Agent context
- { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds },
- { "enrollmentCompleteAlreadyEmitted", _enrollmentCompleteEmitted },
- { "whiteGloveAlreadyTriggered", _signalCorrelatedWhiteGloveTriggered }
- };
-
- // Signal timestamps
- var ts = new Dictionary();
- if (_stateData.EspFirstSeenUtc.HasValue)
- ts["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
- if (_stateData.DesktopArrivedUtc.HasValue)
- ts["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
- if (_stateData.EspFinalExitUtc.HasValue)
- ts["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
- if (_stateData.HelloResolvedUtc.HasValue)
- ts["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
- if (_stateData.ImePatternSeenUtc.HasValue)
- ts["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
- if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue)
- ts["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o");
- data["signalTimestamps"] = ts;
-
- // WhiteGlove classifier output — same fields as the whiteglove_classification
- // event so aggregate MCP queries can correlate terminal decisions with the
- // scoring history across a session.
- if (classification != null)
- {
- data["wgScore"] = classification.Score;
- data["wgConfidence"] = classification.Confidence.ToString();
- data["wgFactors"] = classification.ContributingFactors ?? new List();
- }
- }
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "decision_process_completion",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Decision: {terminalEvent} via {source} — {outcome}",
- ImmediateUpload = true,
- Data = data
- });
-
- _logger.Info($"EnrollmentTracker: decision_process_completion emitted — {terminalEvent} via {source} ({outcome})");
- }
-
- // =====================================================================
- // WhiteGlove classification helpers — see WhiteGloveClassifier for the
- // scoring rules and thresholds. The three completion call-sites
- // (OnDeviceSetupProvisioningComplete, TryEmitEnrollmentComplete,
- // CompletionStateMachine.HandleDeviceSetupProvisioningComplete) all
- // classify through the same classifier, so decisions converge.
- // =====================================================================
-
- ///
- /// Snapshot current WhiteGlove-relevant state under _stateLock and return a
- /// consumable for .
- ///
- private WhiteGloveSignals SnapshotWhiteGloveSignals()
- {
- lock (_stateLock)
- {
- var hasAccountSetup = _espAndHelloTracker?.HasAccountSetupActivity ?? false;
- var hasSaveWg = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false;
- var wgStart = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false;
-
- bool agentRestartedAfterEsp = _stateData.EspFinalExitUtc.HasValue
- && _agentStartTimeUtc > _stateData.EspFinalExitUtc.Value;
-
- return new WhiteGloveSignals
- {
- ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired,
- HasSaveWhiteGloveSuccessResult = hasSaveWg,
- IsWhiteGloveStartDetected = wgStart,
- IsFooUserDetected = _fooUserDetected,
- AgentRestartedAfterEspExit = agentRestartedAfterEsp,
-
- AadJoinedWithUser = _aadJoinedWithUser,
- DesktopArrived = _desktopArrived,
- HasAccountSetupActivity = hasAccountSetup,
-
- IsDeviceOnlyDeployment = IsDeviceOnlyDeployment,
-
- EspFinalExitUtc = _stateData.EspFinalExitUtc,
- DeviceSetupProvisioningCompleteUtc = _stateData.DeviceSetupProvisioningCompleteUtc,
- };
- }
- }
-
- ///
- /// Emits the observability-only whiteglove_classification event with the
- /// full scoring breakdown. Enables MCP query_table queries like
- /// "distribution of wgConfidence over last 24h" or "drill-down into Weak sessions"
- /// without any backend schema change — all fields live inside the existing data bag.
- ///
- private void EmitWhiteGloveClassification(
- WhiteGloveClassification c,
- string callSite,
- WhiteGloveSignals signals)
- {
- if (c == null) return;
-
- var data = new Dictionary
- {
- { "wgScore", c.Score },
- { "wgConfidence", c.Confidence.ToString() },
- { "callSite", callSite },
- { "shouldRouteToWg", c.ShouldRouteToWhiteGlovePart1 },
- { "factors", c.ContributingFactors ?? new List() },
- { "signals", new Dictionary
- {
- { "shellCoreWg", signals.ShellCoreWhiteGloveSuccess },
- { "hasSaveWg", signals.HasSaveWhiteGloveSuccessResult },
- { "event509", signals.IsWhiteGloveStartDetected },
- { "fooUser", signals.IsFooUserDetected },
- { "agentRestartAfterEsp", signals.AgentRestartedAfterEspExit },
- { "aadJoined", signals.AadJoinedWithUser },
- { "desktopArrived", signals.DesktopArrived },
- { "accountSetupActive", signals.HasAccountSetupActivity },
- { "deviceOnly", signals.IsDeviceOnlyDeployment },
- }
- },
- };
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "whiteglove_classification",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"WG classification at {callSite}: {c.Confidence} (score={c.Score})",
- ImmediateUpload = false,
- Data = data,
- });
- }
-
- ///
- /// Called by CollectorCoordinator when the
- /// detects a transient provisioning placeholder user (foouser@/autopilot@). Updates
- /// _fooUserDetected so the WhiteGloveClassifier sees the positive indicator
- /// on the next classification call. Does NOT re-trigger completion — placeholder
- /// alone is not a terminal signal.
- ///
- public void NotifyAadPlaceholderUserDetected(string userEmail)
- {
- bool alreadyDetected;
- lock (_stateLock)
- {
- if (_enrollmentCompleteEmitted) return;
-
- alreadyDetected = _fooUserDetected;
- if (!alreadyDetected)
- {
- _fooUserDetected = true;
- _stateData.FooUserDetected = true;
- }
- }
-
- if (alreadyDetected)
- {
- _logger.Debug("EnrollmentTracker: NotifyAadPlaceholderUserDetected — already detected, ignoring");
- return;
- }
-
- _statePersistence.Save(_stateData);
- RecordSignal("aad_placeholder_user_detected_late");
- _logger.Info("EnrollmentTracker: late placeholder user detected — recording for classifier");
-
- var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds;
- EmitTraceEvent("aad_placeholder_user_detected_late",
- "Transient AAD provisioning placeholder user appeared in JoinInfo registry",
- new Dictionary
- {
- { "secondsSinceAgentStart", secondsSinceStart },
- { "emailPattern", userEmail?.Split('@')[0] ?? "unknown" },
- });
- }
-
- ///
- /// Called by CollectorCoordinator when the
- /// detects a real AAD user join (non-placeholder) during the session. Updates
- /// _aadJoinedWithUser atomically so subsequent WhiteGloveClassifier calls
- /// correctly see the real user (driving IsDeviceOnlyDeployment to false and
- /// triggering the classifier's hard-exclude).
- ///
- ///
- /// This is NOT a completion trigger. In a user-driven v1 flow the real AAD user
- /// appears at the START of AccountSetup (when the user signs in), not at the end —
- /// firing completion here would short-circuit ESP. The signal only flips classifier
- /// state away from device-only/WhiteGlove; completion continues to flow through the
- /// standard paths (ime_pattern / esp_hello_composite / desktop_hello), each of which
- /// enforces its own ESP and Hello gates.
- ///
- public void NotifyAadUserJoinedLate(string userEmail)
- {
- bool alreadyJoined;
- bool alreadyEmitted;
- lock (_stateLock)
- {
- alreadyEmitted = _enrollmentCompleteEmitted;
- alreadyJoined = _aadJoinedWithUser;
- if (!alreadyJoined && !alreadyEmitted)
- {
- _aadJoinedWithUser = true;
- _stateData.AadJoinedWithUser = true;
- }
- }
-
- if (alreadyEmitted)
- {
- _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — enrollment already emitted, ignoring");
- return;
- }
- if (alreadyJoined)
- {
- _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — already joined, ignoring");
- return;
- }
-
- _statePersistence.Save(_stateData);
- RecordSignal("aad_user_joined_late");
- _logger.Info($"EnrollmentTracker: late AAD user joined ({userEmail}) — classifier state updated");
-
- var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds;
- EmitTraceEvent("aad_user_joined_late",
- "Real AAD user appeared in JoinInfo registry after session started",
- new Dictionary
- {
- { "secondsSinceAgentStart", secondsSinceStart },
- { "userEmailDomain", userEmail?.Contains("@") == true ? userEmail.Substring(userEmail.IndexOf('@')) : "unknown" },
- });
- }
-
- ///
- /// Called by MonitoringService when Desktop Arrival is detected (explorer.exe under a real user).
- /// Corrects phase if needed, starts Hello wait timer in no-ESP scenarios, and attempts completion.
- ///
- public void NotifyDesktopArrived()
- {
- bool alreadyArrived;
- lock (_stateLock)
- {
- alreadyArrived = _desktopArrived;
- if (!alreadyArrived)
- {
- _desktopArrived = true;
- _stateData.DesktopArrived = true;
- _stateData.DesktopArrivedUtc = DateTime.UtcNow;
- }
- }
-
- if (alreadyArrived)
- {
- _logger.Debug("EnrollmentTracker: NotifyDesktopArrived called but already arrived — ignoring");
- return;
- }
-
- _statePersistence.Save(_stateData); // Immediate persist — desktop arrival is completion-critical
- RecordSignal("desktop_arrived");
- _logger.Info("EnrollmentTracker: Desktop arrival notified");
-
- // Phase correction and SkipUserStatusPage handling when ESP was seen.
- // SkipUserStatusPage check is prioritized over phase correction because in WhiteGlove Part 2,
- // _lastEspPhase may already be "AccountSetup" (persisted from Part 1) which would otherwise
- // skip the SkipUser block and leave _espFinalExitSeen=false, blocking the desktop-arrival gate.
- bool espSeen;
- string previousPhase;
- bool? skipUserPage;
- bool espFinalExit;
- lock (_stateLock)
- {
- espSeen = _espEverSeen;
- previousPhase = _lastEspPhase ?? "unknown";
- skipUserPage = _skipUserStatusPage;
- espFinalExit = _espFinalExitSeen;
- }
-
- if (espSeen)
- {
- if (skipUserPage == true && !espFinalExit)
- {
- lock (_stateLock)
- {
- _lastEspPhase = "FinalizingSetup";
- _stateData.LastEspPhase = "FinalizingSetup";
- _espFinalExitSeen = true;
- _stateData.EspFinalExitSeen = true;
- _stateData.EspFinalExitUtc = DateTime.UtcNow;
- _hasAutoSwitchedToAppsPhase = false;
- }
- _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate
- RecordSignal("desktop_arrived_skip_user");
- _logger.Info($"EnrollmentTracker: Desktop arrival with SkipUserStatusPage=true — skipping AccountSetup, transitioning to FinalizingSetup (was: {previousPhase})");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "DesktopArrivalDetector",
- Phase = EnrollmentPhase.FinalizingSetup,
- Message = $"ESP phase: FinalizingSetup (SkipUserStatusPage — desktop arrival, was: {previousPhase})",
- Data = new Dictionary
- {
- { "espPhase", "FinalizingSetup" },
- { "autoDetected", true },
- { "correctedBy", "desktop_arrival" },
- { "previousPhase", previousPhase },
- { "skipUserStatusPage", true }
- },
- ImmediateUpload = true
- });
-
- try { CollectDeviceInfoAtFinalizingSetup("desktop_arrived_skip_user"); }
- catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (desktop_arrived_skip_user): {ex.Message}"); }
- }
- else if (!string.Equals(previousPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- {
- // Normal flow: desktop arrival confirms AccountSetup phase
- lock (_stateLock) { _lastEspPhase = "AccountSetup"; }
- _logger.Info($"EnrollmentTracker: Desktop arrival confirmed AccountSetup phase (was: {previousPhase}) — phase corrected on timeline");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "DesktopArrivalDetector",
- Phase = EnrollmentPhase.AccountSetup,
- Message = $"ESP phase: AccountSetup (auto-detected from desktop arrival, was: {previousPhase})",
- Data = new Dictionary
- {
- { "espPhase", "AccountSetup" },
- { "autoDetected", true },
- { "correctedBy", "desktop_arrival" },
- { "previousPhase", previousPhase }
- },
- ImmediateUpload = true
- });
- }
- }
-
- // Start Hello wait timer ONLY when ESP is NOT actively running.
- // During active ESP (AccountSetup runs in background with desktop visible),
- // the Hello timer must wait until ESP exits (started in OnFinalizingSetupPhaseTriggered).
- // Without this guard, Hello timeout-resolves while ESP still installs apps → premature completion.
- // Device-only deployment: no user session, Hello is irrelevant — skip timer entirely.
- bool espSeenNow, espFinalExitNow, isDeviceOnlyNow;
- lock (_stateLock)
- {
- espSeenNow = _espEverSeen;
- espFinalExitNow = _espFinalExitSeen;
- isDeviceOnlyNow = IsDeviceOnlyDeployment;
- }
-
- if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted
- && (!espSeenNow || espFinalExitNow)
- && !isDeviceOnlyNow)
- {
- _logger.Info("EnrollmentTracker: Desktop arrived with Hello pending (no active ESP) — starting Hello wait timer");
- _espAndHelloTracker.StartHelloWaitTimer();
- }
-
- TryEmitEnrollmentComplete("desktop_arrival");
-
- // Shadow state machine: track desktop_arrived trigger
- ShadowProcessTrigger("desktop_arrived");
- }
-
- ///
- /// Called when the device-only ESP timer expires. If no AccountSetup phase started
- /// and desktop is available, classify as device-only ESP and mark final exit.
- ///
- private void OnDeviceOnlyEspTimerExpired(object state)
- {
- string lastPhaseSnap;
- bool desktopArrSnap;
- lock (_stateLock)
- {
- lastPhaseSnap = _lastEspPhase;
- desktopArrSnap = _desktopArrived;
- }
-
- // AccountSetup started meanwhile? Timer is obsolete
- if (string.Equals(lastPhaseSnap, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- {
- _logger.Debug("EnrollmentTracker: Device-only ESP timer expired but AccountSetup detected — ignoring");
- return;
- }
-
- if (desktopArrSnap)
- {
- _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min — classified as device-only ESP, desktop is active");
- lock (_stateLock)
- {
- _espFinalExitSeen = true;
- _stateData.EspFinalExitSeen = true;
- _stateData.EspFinalExitUtc = DateTime.UtcNow;
- }
- _statePersistence.Save(_stateData); // Immediate persist — device-only ESP final exit
- RecordSignal("device_only_esp_final_exit");
- _espAndHelloTracker?.StartHelloWaitTimer();
- }
- else
- {
- _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min and no desktop yet — waiting for Desktop Arrival or Lifetime Timer");
- }
-
- // Shadow state machine: track device-only ESP timer expiry
- ShadowProcessTrigger("device_only_esp_timer_expired");
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs
deleted file mode 100644
index df6a69ec..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs
+++ /dev/null
@@ -1,381 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
-{
- ///
- /// Partial: Device info collection at key lifecycle points, app tracking summaries,
- /// debug state timer, and final status file writing.
- ///
- public partial class EnrollmentTracker
- {
- private void CollectDeviceInfoAtEnrollmentStart()
- {
- lock (_stateLock)
- {
- if (_enrollmentStartDeviceInfoCollected)
- return;
- _enrollmentStartDeviceInfoCollected = true;
- }
-
- if (!_isBootstrapMode)
- {
- _logger.Debug("EnrollmentTracker: skipping enrollment-start device info re-collection (not bootstrap mode)");
- return;
- }
-
- _logger.Info("EnrollmentTracker: first ESP phase detected — re-collecting enrollment-dependent device info (bootstrap mode)");
- Task.Run(() =>
- {
- try
- {
- _deviceInfoCollector.CollectAtEnrollmentStart();
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: failed to re-collect device info at enrollment start: {ex.Message}");
- }
- });
- }
-
- private void CollectDeviceInfoAtFinalizingSetup(string triggerReason)
- {
- bool alreadyCollected;
- lock (_stateLock)
- {
- alreadyCollected = _finalDeviceInfoCollected;
- if (!alreadyCollected)
- _finalDeviceInfoCollected = true;
- }
-
- if (alreadyCollected)
- {
- _logger.Debug($"EnrollmentTracker: final device info already collected, skipping (trigger: {triggerReason})");
- return;
- }
- _logger.Info($"EnrollmentTracker: triggering final device info collection at FinalizingSetup (trigger: {triggerReason})");
- CollectDeviceInfoAtEnd();
- }
-
- private void SummaryTimerCallback(object state)
- {
- // Only emit if state-breakdown counters changed since last emission (backstop for missed events)
- EmitAppTrackingSummaryIfChanged();
-
- // Signal-correlated WhiteGlove detection (Ebene 2.6) — event-driven in the sense
- // that it only triggers when the observed signal set matches; zero-cost when the
- // session does not look like a sealing candidate.
- try { CheckSignalCorrelatedWhiteGlove(); }
- catch (Exception ex) { _logger.Error("CheckSignalCorrelatedWhiteGlove failed", ex); }
-
- // Periodic state save (piggybacks on the existing 30s timer)
- bool dirty;
- EnrollmentStateData stateSnapshot;
- lock (_stateLock)
- {
- dirty = _stateDirty;
- if (dirty)
- {
- _stateDirty = false;
- stateSnapshot = _stateData;
- }
- else
- {
- stateSnapshot = null;
- }
- }
-
- if (dirty)
- _statePersistence.Save(stateSnapshot);
- }
-
- ///
- /// Periodic debug state snapshot (every 60s). Writes to agent log only (not as event) to aid
- /// post-mortem debugging of stuck enrollments without consuming Azure Table Storage.
- ///
- private void DebugStateTimerCallback(object state)
- {
- try
- {
- string lastPhaseDbg;
- bool espFinalExitDbg, desktopDbg;
- lock (_stateLock)
- {
- lastPhaseDbg = _lastEspPhase;
- espFinalExitDbg = _espFinalExitSeen;
- desktopDbg = _desktopArrived;
- }
-
- var states = _imeLogTracker?.PackageStates;
- if (states == null || states.CountAll == 0) return;
-
- var active = states.Where(x => x.IsActive).Select(x => $"{x.Name ?? x.Id}({x.InstallationState})");
- var errors = states.Where(x => x.IsError).Select(x => $"{x.Name ?? x.Id}");
-
- _logger.Debug($"[StateSnapshot] Apps: {states.CountCompleted}/{states.CountAll} completed, " +
- $"{states.ErrorCount} errors (device:{states.Count(x => x.IsError && x.Targeted == AppTargeted.Device)}, " +
- $"user:{states.Count(x => x.IsError && x.Targeted == AppTargeted.User)}), " +
- $"active: [{string.Join(", ", active)}], " +
- $"failed: [{string.Join(", ", errors)}], " +
- $"phase: {lastPhaseDbg ?? "none"}, espExit: {espFinalExitDbg}, desktop: {desktopDbg}");
- }
- catch (Exception ex)
- {
- _logger.Debug($"[StateSnapshot] Error: {ex.Message}");
- }
- }
-
- ///
- /// Emits app_tracking_summary only if state-breakdown counters changed since last emission.
- /// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop).
- ///
- private void EmitAppTrackingSummaryIfChanged()
- {
- var states = _imeLogTracker?.PackageStates;
- if (states == null || states.CountAll == 0) return;
-
- var hash = GetSummaryHash(states);
-
- EventSeverity severity;
- lock (_stateLock)
- {
- if (hash == _lastEmittedSummaryHash) return;
- _lastEmittedSummaryHash = hash;
-
- var compactHash = $"{states.CountCompleted}_{states.ErrorCount}";
- severity = (compactHash != _lastEmittedSummaryCompactHash)
- ? (states.HasError ? EventSeverity.Warning : EventSeverity.Info)
- : EventSeverity.Debug;
- _lastEmittedSummaryCompactHash = compactHash;
- }
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "app_tracking_summary",
- Severity = severity,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
- (states.HasError ? $" ({states.ErrorCount} errors)" : ""),
- Data = states.GetSummaryData(),
- ImmediateUpload = (severity != EventSeverity.Debug)
- });
- }
-
- ///
- /// Emits app_tracking_summary unconditionally (for final summary on completion).
- ///
- private void EmitAppTrackingSummary()
- {
- var states = _imeLogTracker?.PackageStates;
- if (states == null || states.CountAll == 0) return;
-
- lock (_stateLock) { _lastEmittedSummaryHash = GetSummaryHash(states); }
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "app_tracking_summary",
- Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
- (states.HasError ? $" ({states.ErrorCount} errors)" : ""),
- Data = states.GetSummaryData(),
- ImmediateUpload = true
- });
- }
-
- private static string GetSummaryHash(AppPackageStateList states)
- {
- return $"{states.CountAll}_{states.CountCompleted}_{states.ErrorCount}_" +
- $"{states.Count(x => x.InstallationState == AppInstallationState.Downloading)}_" +
- $"{states.Count(x => x.InstallationState == AppInstallationState.Installing)}_" +
- $"{states.Count(x => x.InstallationState == AppInstallationState.Installed)}";
- }
-
- public void Dispose()
- {
- Stop();
-
- // Unsubscribe from EspAndHelloTracker events
- if (_espAndHelloTracker != null)
- {
- _espAndHelloTracker.HelloCompleted -= OnHelloCompleted;
- _espAndHelloTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
- _espAndHelloTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
- _espAndHelloTracker.EspFailureDetected -= OnEspFailureDetected;
- _espAndHelloTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete;
- }
-
- _espFailureTimer?.Dispose();
- _deviceOnlyEspTimer?.Dispose();
- _waitingForHelloSafetyTimer?.Dispose();
- _waitingForEspSettleTimer?.Dispose();
- _deviceOnlyCompletionSafetyTimer?.Dispose();
- _summaryTimer?.Dispose();
- _debugStateTimer?.Dispose();
- _imeLogTracker?.Dispose();
- }
-
- ///
- /// Writes an enrollment complete marker to the state directory.
- /// This marker is checked on agent restart to handle cleanup retry if scheduled task fails.
- ///
- private void WriteEnrollmentCompleteMarker()
- {
- try
- {
- var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
- Directory.CreateDirectory(stateDirectory);
-
- var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker");
- File.WriteAllText(markerPath, $"Enrollment completed at {DateTime.UtcNow:O}");
-
- _logger.Info($"Enrollment complete marker written: {markerPath}");
- }
- catch (Exception ex)
- {
- _logger.Warning($"Failed to write enrollment complete marker: {ex.Message}");
- }
- }
-
- ///
- /// Writes a comprehensive final-status.json with all app states, signals, and error summary.
- /// Automatically included in diagnostics uploads for post-mortem analysis.
- ///
- private void WriteFinalStatus(string outcome, string source)
- {
- try
- {
- var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
- Directory.CreateDirectory(stateDirectory);
-
- var appStates = _imeLogTracker?.PackageStates;
-
- var signalTimestamps = new Dictionary();
- if (_stateData.EspFirstSeenUtc.HasValue)
- signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
- if (_stateData.EspFinalExitUtc.HasValue)
- signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
- if (_stateData.DesktopArrivedUtc.HasValue)
- signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
- if (_stateData.HelloResolvedUtc.HasValue)
- signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
- if (_stateData.ImePatternSeenUtc.HasValue)
- signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
- if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue)
- signalTimestamps["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o");
-
- // Build per-phase package states: snapshots from completed phases + current phase
- var phaseSnapshots = _imeLogTracker?.PhasePackageSnapshots;
- var packageStatesByPhase = new Dictionary();
-
- // Add snapshots from completed phases (e.g. DeviceSetup apps captured before phase transition)
- if (phaseSnapshots != null)
- {
- foreach (var kvp in phaseSnapshots)
- packageStatesByPhase[kvp.Key] = kvp.Value;
- }
-
- // Add current phase (the phase active at completion, e.g. AccountSetup)
- var currentPhaseName = _lastEspPhase ?? "Unknown";
- var currentPhaseList = appStates?.ToFinalStatusList() ?? new List>();
- packageStatesByPhase[currentPhaseName] = currentPhaseList;
-
- // Aggregate app summary across ALL phases (snapshots + current)
- var currentPhaseTotal = appStates?.CountAll ?? 0;
- var currentPhaseCompleted = appStates?.CountCompleted ?? 0;
- var currentPhaseErrors = appStates?.ErrorCount ?? 0;
- var currentPhaseDeviceErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.Device) ?? 0;
- var currentPhaseUserErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.User) ?? 0;
-
- var allPhaseTotals = new Dictionary();
- var totalAppsAllPhases = currentPhaseTotal;
- var completedAppsAllPhases = currentPhaseCompleted;
- var errorCountAllPhases = currentPhaseErrors;
- var deviceErrorsAllPhases = currentPhaseDeviceErrors;
- var userErrorsAllPhases = currentPhaseUserErrors;
-
- allPhaseTotals[currentPhaseName] = currentPhaseTotal;
-
- if (phaseSnapshots != null)
- {
- foreach (var kvp in phaseSnapshots)
- {
- var phaseApps = kvp.Value;
- allPhaseTotals[kvp.Key] = phaseApps.Count;
- totalAppsAllPhases += phaseApps.Count;
- foreach (var app in phaseApps)
- {
- object stateVal;
- var isError = app.TryGetValue("state", out stateVal) &&
- string.Equals(stateVal?.ToString(), "Error", StringComparison.OrdinalIgnoreCase);
- var isCompleted = app.TryGetValue("state", out stateVal) &&
- (stateVal?.ToString() == "Installed" || stateVal?.ToString() == "Skipped" ||
- stateVal?.ToString() == "Postponed" || stateVal?.ToString() == "Error");
- if (isCompleted) completedAppsAllPhases++;
- if (isError)
- {
- errorCountAllPhases++;
- object targetVal;
- if (app.TryGetValue("targeted", out targetVal))
- {
- if (string.Equals(targetVal?.ToString(), "Device", StringComparison.OrdinalIgnoreCase))
- deviceErrorsAllPhases++;
- else if (string.Equals(targetVal?.ToString(), "User", StringComparison.OrdinalIgnoreCase))
- userErrorsAllPhases++;
- }
- }
- }
- }
- }
-
- var status = new Dictionary
- {
- { "timestamp", DateTime.UtcNow.ToString("o") },
- { "outcome", outcome },
- { "completionSource", source },
- { "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" },
- { "enrollmentType", _enrollmentType ?? "unknown" },
- { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds },
- { "signalsSeen", SnapshotSignalsSeen() },
- { "signalTimestamps", signalTimestamps },
- { "appSummary", new Dictionary
- {
- { "totalApps", totalAppsAllPhases },
- { "completedApps", completedAppsAllPhases },
- { "errorCount", errorCountAllPhases },
- { "deviceErrors", deviceErrorsAllPhases },
- { "userErrors", userErrorsAllPhases },
- { "appsByPhase", allPhaseTotals }
- }
- },
- { "packageStatesByPhase", packageStatesByPhase }
- };
-
- var json = System.Text.Json.JsonSerializer.Serialize(status,
- new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
- var path = Path.Combine(stateDirectory, "final-status.json");
- File.WriteAllText(path, json);
- _logger.Info($"Final status written: {path}");
- }
- catch (Exception ex)
- {
- _logger.Warning($"Failed to write final status: {ex.Message}");
- }
- }
- }
-}
-
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs
deleted file mode 100644
index 278708a1..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs
+++ /dev/null
@@ -1,768 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
-{
- ///
- /// Partial: IME log event handlers (ESP phase, app state, script completion, etc.).
- ///
- public partial class EnrollmentTracker
- {
- ///
- /// Wraps _emitEvent and automatically injects the current ImeLogTracker patternId into event Data.
- /// Use this for all events emitted from ImeLogTracker callback handlers.
- ///
- private void EmitImeTrackerEvent(EnrollmentEvent evt)
- {
- var logTimestamp = _imeLogTracker?.LastMatchedLogTimestamp;
- if (logTimestamp.HasValue)
- evt.Timestamp = logTimestamp.Value;
-
- var patternId = _imeLogTracker?.LastMatchedPatternId;
- if (!string.IsNullOrEmpty(patternId))
- {
- if (evt.Data == null)
- evt.Data = new Dictionary();
- evt.Data["patternId"] = patternId;
- }
- _emitEvent(evt);
- }
-
- private void HandleEspPhaseChanged(string phase)
- {
- string lastPhase;
- bool tracksEspPhases;
- lock (_stateLock)
- {
- tracksEspPhases = _flowHandler.TracksEspPhases;
- lastPhase = _lastEspPhase;
- }
-
- // Flows that do not track ESP phases (DevPrep/WDP) ignore these events entirely.
- if (!tracksEspPhases)
- {
- _logger.Debug($"EnrollmentTracker: skipping ESP phase event — flow does not track ESP (phase: {phase})");
- return;
- }
-
- // ESP resumed after final exit — only possible during hybrid join reboot recovery.
- // In hybrid join, ESP exits for a mid-enrollment reboot (domain user login required),
- // then the same phase re-appears in IME after the agent restarts. Reset completion state
- // so the agent waits for the real final exit instead of self-destructing prematurely.
- // Non-hybrid scenarios can see the same phase re-reported by IME (e.g., during user
- // session completion) which is NOT a real resumption — skip the reset entirely.
- bool espFinalExitSeen;
- lock (_stateLock) { espFinalExitSeen = _espFinalExitSeen; }
-
- if (_isHybridJoin && espFinalExitSeen && string.Equals(phase, lastPhase, StringComparison.OrdinalIgnoreCase))
- {
- _logger.Info($"EnrollmentTracker: ESP phase '{phase}' re-detected after final exit — " +
- "ESP has resumed (hybrid join reboot recovery), resetting completion state");
- lock (_stateLock)
- {
- _espFinalExitSeen = false;
- _stateData.EspFinalExitSeen = false;
- _stateData.EspFinalExitUtc = null;
- }
- _statePersistence.Save(_stateData); // Immediate persist — ESP resumed resets final exit, must survive crash
- RecordSignal("esp_resumed");
- _espAndHelloTracker?.ResetForEspResumption();
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_resumed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.AccountSetup,
- Message = $"ESP phase '{phase}' re-detected after final exit — enrollment resumed after reboot",
- Data = new Dictionary
- {
- { "espPhase", phase },
- { "isHybridJoin", _isHybridJoin }
- },
- ImmediateUpload = true
- });
-
- // Shadow state machine: track ESP resumed
- ShadowProcessTrigger("esp_resumed");
- return;
- }
-
- // Only emit event if the phase has actually changed
- if (string.Equals(phase, lastPhase, StringComparison.OrdinalIgnoreCase))
- {
- _logger.Debug($"EnrollmentTracker: ESP phase unchanged ({phase}), skipping event");
- return;
- }
-
- // AccountSetup verification: during WhiteGlove pre-provisioning the ESP briefly enters
- // AccountSetup before WhiteGlove success, but no real user has signed in (only defaultuser0
- // exists). Suppress the phase event to avoid a fake AccountSetup on the timeline.
- // The ImeLogTracker has already updated its internal phase tracking (ignore lists, phase order)
- // which is correct — we only suppress the event emission to the backend.
- if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- {
- var hasReal = HasRealUserProfile();
- _logger.Debug($"EnrollmentTracker: AccountSetup verification — HasRealUserProfile={hasReal}");
- if (!hasReal)
- {
- _logger.Info("EnrollmentTracker: AccountSetup suppressed — no real user profile (likely WhiteGlove)");
- EmitTraceEvent("AccountSetup_suppressed",
- "ESP reported AccountSetup but no real user profile found — likely WhiteGlove pre-provisioning",
- new Dictionary { { "espPhase", phase }, { "previousPhase", lastPhase ?? "null" } });
- return;
- }
- }
-
- _logger.Info($"EnrollmentTracker: ESP phase changed from '{lastPhase ?? "null"}' to '{phase}'");
- lock (_stateLock)
- {
- _lastEspPhase = phase;
- _hasAutoSwitchedToAppsPhase = false; // Reset when ESP phase changes
- _espEverSeen = true;
- _stateData.EspEverSeen = true;
- _stateData.LastEspPhase = phase;
- if (_stateData.EspFirstSeenUtc == null)
- _stateData.EspFirstSeenUtc = DateTime.UtcNow;
- }
- _statePersistence.Save(_stateData); // Immediate persist — ESP phase + espEverSeen must survive reboot
- RecordSignal($"esp_phase_{phase}");
-
- // ESP phase change means ESP is progressing — cancel any pending failure grace period
- CancelPendingEspFailure();
-
- // Cancel device-only ESP timer if AccountSetup phase detected
- if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- {
- if (_deviceOnlyEspTimer != null)
- {
- _logger.Info("EnrollmentTracker: AccountSetup detected — cancelling device-only ESP timer");
- _deviceOnlyEspTimer.Dispose();
- _deviceOnlyEspTimer = null;
- }
- }
-
- // Map ESP phase to EnrollmentPhase (phase change events)
- var enrollmentPhase = EnrollmentPhase.DeviceSetup;
- if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- enrollmentPhase = EnrollmentPhase.AccountSetup;
-
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "ImeLogTracker",
- Phase = enrollmentPhase,
- Message = $"ESP phase: {phase}",
- Data = new Dictionary { { "espPhase", phase } },
- ImmediateUpload = true
- });
-
- // Re-collect enrollment-dependent device info (AAD join, profile, ESP config)
- // that may have been empty at startup (bootstrap / pre-enrollment scenario).
- CollectDeviceInfoAtEnrollmentStart();
-
- // Check for ConfigMgr client at phase transitions (may arrive after agent startup)
- Task.Run(() => CheckConfigMgrClient(
- enrollmentPhase == EnrollmentPhase.AccountSetup ? "account_setup" : "device_setup"));
-
- // Start summary timer when we detect ESP phase
- if (!_summaryTimerActive)
- {
- _summaryTimerActive = true;
- _summaryTimer?.Change(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
- }
- if (!_debugStateTimerActive)
- {
- _debugStateTimerActive = true;
- _debugStateTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
- }
-
- // Shadow state machine: track ESP phase change
- ShadowProcessTrigger("esp_phase_changed");
- }
-
- private void HandleImeAgentVersion(string version)
- {
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "ime_agent_version",
- Severity = EventSeverity.Info,
- Source = "ImeLogTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"IME Agent version: {version}",
- Data = new Dictionary { { "agentVersion", version } }
- });
- }
-
- private void HandleImeStarted()
- {
- _logger.Info("EnrollmentTracker: IME started event");
- }
-
- private void HandleAppStateChanged(AppPackageState app, AppInstallationState oldState, AppInstallationState newState)
- {
- _logger.Verbose($"EnrollmentTracker: app state change '{app.Name ?? app.Id}': {oldState} -> {newState}");
-
- // Auto-switch to app installation phase when first app activity detected
- // If we're in DeviceSetup and an app starts downloading/installing, switch to AppsDevice
- // If we're in AccountSetup and an app starts downloading/installing, switch to AppsUser
- bool hasAutoSwitched;
- string currentPhase;
- lock (_stateLock)
- {
- hasAutoSwitched = _hasAutoSwitchedToAppsPhase;
- currentPhase = _lastEspPhase;
- }
-
- if (!hasAutoSwitched &&
- (newState == AppInstallationState.Downloading || newState == AppInstallationState.Installing) &&
- oldState < AppInstallationState.Downloading)
- {
- if (currentPhase != null)
- {
- if (string.Equals(currentPhase, "DeviceSetup", StringComparison.OrdinalIgnoreCase))
- {
- // Switch from DeviceSetup to AppsDevice
- _logger.Info($"EnrollmentTracker: First app activity detected during DeviceSetup, switching to AppsDevice phase");
- lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.AppsDevice,
- Message = "ESP phase: AppsDevice (auto-detected from app activity)",
- Data = new Dictionary { { "espPhase", "AppsDevice" }, { "autoDetected", true } },
- ImmediateUpload = true
- });
- }
- else if (string.Equals(currentPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- {
- // Switch from AccountSetup to AppsUser
- _logger.Info($"EnrollmentTracker: First app activity detected during AccountSetup, switching to AppsUser phase");
- lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.AppsUser,
- Message = "ESP phase: AppsUser (auto-detected from app activity)",
- Data = new Dictionary { { "espPhase", "AppsUser" }, { "autoDetected", true } },
- ImmediateUpload = true
- });
- }
- else if (string.Equals(currentPhase, "FinalizingSetup", StringComparison.OrdinalIgnoreCase))
- {
- // SkipUserStatusPage flow: apps starting after FinalizingSetup are background user apps
- _logger.Info($"EnrollmentTracker: First app activity detected during FinalizingSetup (SkipUserStatusPage), switching to AppsUser phase");
- lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.AppsUser,
- Message = "ESP phase: AppsUser (auto-detected from app activity)",
- Data = new Dictionary { { "espPhase", "AppsUser" }, { "autoDetected", true } },
- ImmediateUpload = true
- });
- }
- }
- }
-
- // Only emit strategic events for significant state transitions
- string eventType;
- var severity = EventSeverity.Info;
- var phase = EnrollmentPhase.Unknown; // Apps set to Unknown, will be sorted chronologically into active phase
-
- switch (newState)
- {
- case AppInstallationState.Downloading:
- // Emit strategic event once when download starts
- if (oldState < AppInstallationState.Downloading)
- {
- eventType = "app_download_started";
- }
- else
- {
- // Emit debug event for download progress updates
- // Skip if no real download data (bytesTotal too small or zero)
- if (app.BytesTotal > 1024) // At least 1 KB to be a real download
- {
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "download_progress",
- Severity = EventSeverity.Debug,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = $"{app.Name ?? app.Id}: {app.ProgressPercent}%",
- Data = app.ToEventData()
- });
- }
- return; // Skip main event emission below
- }
- break;
-
- case AppInstallationState.Installing:
- // Only emit the strategic event once when install actually starts.
- // Progress-only updates (oldState == Installing, just progress/bytes changed)
- // are skipped — same pattern as Downloading above.
- if (oldState == AppInstallationState.Installing)
- return;
- eventType = "app_install_started";
- break;
-
- case AppInstallationState.Installed:
- eventType = "app_install_completed";
- // Emit download_progress event for download manager (shows as completed)
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "download_progress",
- Severity = EventSeverity.Debug,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = $"{app.Name ?? app.Id}: completed",
- Data = new Dictionary(app.ToEventData())
- {
- ["status"] = "completed"
- }
- });
- break;
-
- case AppInstallationState.Skipped:
- eventType = "app_install_skipped";
- break;
-
- case AppInstallationState.Error:
- eventType = "app_install_failed";
- severity = EventSeverity.Error;
- // Emit download_progress event for download manager (shows as failed)
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "download_progress",
- Severity = EventSeverity.Debug,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = $"{app.Name ?? app.Id}: failed",
- Data = new Dictionary(app.ToEventData())
- {
- ["status"] = "failed"
- }
- });
- break;
-
- case AppInstallationState.Postponed:
- eventType = "app_install_postponed";
- severity = EventSeverity.Warning;
- break;
-
- default:
- return; // Don't emit for Unknown, NotInstalled, InProgress
- }
-
- // Build a descriptive message: include error detail if available
- var message = $"{app.Name ?? app.Id}: {newState}";
- if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(app.ErrorDetail))
- message = $"{app.Name ?? app.Id}: {app.ErrorDetail}";
-
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = eventType,
- Severity = severity,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = message,
- Data = app.ToEventData(),
- ImmediateUpload = true
- });
-
- // Emit summary immediately if state-breakdown counters changed (instant UI updates)
- EmitAppTrackingSummaryIfChanged();
- }
-
- private void HandlePoliciesDiscovered(string policiesJson)
- {
- _logger.Info($"EnrollmentTracker: policies discovered, tracking {_imeLogTracker.PackageStates.Count} apps");
- }
-
- private void HandleAllAppsCompleted()
- {
- _logger.Info("EnrollmentTracker: all apps completed");
-
- // Stop timers
- _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _summaryTimerActive = false;
- _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debugStateTimerActive = false;
-
- // Emit final summary
- EmitAppTrackingSummary();
-
- // Note: Phase transition to FinalizingSetup is now handled by Shell-Core events
- // (ESP exit or Hello wizard start) for more robust detection.
- // We no longer automatically transition here when apps complete.
- string phaseSnap;
- lock (_stateLock) { phaseSnap = _lastEspPhase; }
- if (phaseSnap != null)
- {
- _logger.Info($"EnrollmentTracker: All apps completed while in phase '{phaseSnap}'");
- _logger.Info("EnrollmentTracker: Waiting for ESP exit or Hello wizard events to transition to FinalizingSetup");
- }
- }
-
- private void HandleDoTelemetryReceived(AppPackageState app)
- {
- _logger.Info($"EnrollmentTracker: DO telemetry received for {app.Name ?? app.Id}");
-
- var phase = EnrollmentPhase.Unknown;
-
- // Dedicated do_telemetry event for backend aggregation
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "do_telemetry",
- Severity = EventSeverity.Info,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = $"{app.Name ?? app.Id}: DO complete - {app.DoPercentPeerCaching}% peers, mode={app.DoDownloadMode}",
- Data = app.ToEventData(),
- ImmediateUpload = true
- });
-
- // Also emit download_progress so the UI picks up DO stats
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "download_progress",
- Severity = EventSeverity.Debug,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = $"{app.Name ?? app.Id}: DO telemetry received",
- Data = app.ToEventData(),
- ImmediateUpload = true
- });
- }
-
- private void HandleScriptCompleted(ScriptExecutionState script)
- {
- if (script == null || string.IsNullOrEmpty(script.PolicyId))
- {
- _logger.Debug($"EnrollmentTracker: HandleScriptCompleted — skipped (script={script != null}, policyId='{script?.PolicyId}')");
- return;
- }
-
- var isSuccess = string.Equals(script.Result, "Success", StringComparison.OrdinalIgnoreCase)
- || (script.ExitCode.HasValue && script.ExitCode.Value == 0 && string.IsNullOrEmpty(script.Result));
-
- var eventType = isSuccess
- ? Constants.EventTypes.ScriptCompleted
- : Constants.EventTypes.ScriptFailed;
-
- var severity = isSuccess ? EventSeverity.Info : EventSeverity.Warning;
-
- // Build human-readable message
- string shortId = script.PolicyId.Length >= 8 ? script.PolicyId.Substring(0, 8) : script.PolicyId;
- string message;
- if (string.Equals(script.ScriptType, "remediation", StringComparison.OrdinalIgnoreCase))
- {
- var complianceStr = string.Equals(script.ComplianceResult, "True", StringComparison.OrdinalIgnoreCase)
- ? "Compliant" : "Non-compliant";
- message = $"Remediation {script.ScriptPart ?? "detection"} {shortId}: {complianceStr} (exit: {script.ExitCode ?? -1})";
- }
- else
- {
- message = $"Platform script {shortId}: {script.Result ?? "Unknown"} (exit: {script.ExitCode ?? -1})";
- }
-
- // Add stderr hint to message if present
- if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
- message += " - stderr present";
-
- var data = new Dictionary
- {
- ["policyId"] = script.PolicyId,
- ["scriptType"] = script.ScriptType ?? "platform",
- ["exitCode"] = script.ExitCode ?? -1
- };
-
- if (!string.IsNullOrEmpty(script.RunContext))
- data["runContext"] = script.RunContext;
- if (!string.IsNullOrEmpty(script.Result))
- data["result"] = script.Result;
- if (!string.IsNullOrEmpty(script.Stdout) && script.Stdout.Trim().Length > 0)
- data["stdout"] = script.Stdout;
- if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
- data["stderr"] = script.Stderr;
- if (!string.IsNullOrEmpty(script.ComplianceResult))
- data["complianceResult"] = script.ComplianceResult;
- if (!string.IsNullOrEmpty(script.ScriptPart))
- data["scriptPart"] = script.ScriptPart;
-
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = eventType,
- Severity = severity,
- Source = Constants.EventSources.IME,
- Phase = EnrollmentPhase.Unknown,
- Message = message,
- Data = data
- });
-
- _logger.Info($"EnrollmentTracker: {message}");
- }
-
- private void HandleImeSessionChange(string changeType)
- {
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "ime_session_change",
- Severity = EventSeverity.Debug,
- Source = "ImeLogTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"IME session change: {changeType}",
- Data = new Dictionary { { "changeType", changeType ?? "" } }
- });
- }
-
- private void HandleUserSessionCompleted()
- {
- _logger.Info("EnrollmentTracker: User session completed (detected from IME log)");
- _stateData.ImePatternSeenUtc = DateTime.UtcNow;
- RecordSignal("ime_pattern");
-
- // Emit timeline event so admins can see exactly when IME finished its user part
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "ime_user_session_completed",
- Severity = EventSeverity.Info,
- Source = "ImeLogTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = "IME user session completed",
- Data = new Dictionary { { "detectedAt", DateTime.UtcNow.ToString("o") } }
- });
-
- // User session completed successfully — cancel any pending ESP failure
- CancelPendingEspFailure();
-
- // Stop timers if running
- _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _summaryTimerActive = false;
- _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debugStateTimerActive = false;
-
- // Device-only deployment (Self-Deploying or SkipUserStatusPage=true): skip Hello wait
- // entirely — no interactive user session means Hello provisioning cannot complete.
- bool isDeviceOnlySnap;
- int? autopilotModeSnap;
- bool? skipUserSnap;
- lock (_stateLock)
- {
- isDeviceOnlySnap = IsDeviceOnlyDeployment;
- autopilotModeSnap = _autopilotMode;
- skipUserSnap = _skipUserStatusPage;
- }
-
- if (isDeviceOnlySnap)
- {
- _logger.Info($"EnrollmentTracker: Device-only deployment (autopilotMode={autopilotModeSnap}, skipUserStatusPage={skipUserSnap}) — skipping Hello wait, proceeding to completion");
- TryEmitEnrollmentComplete("ime_pattern");
- ShadowProcessTrigger("ime_user_session_completed");
- return;
- }
-
- // Check if Windows Hello is configured but not yet completed
- if (_espAndHelloTracker != null)
- {
- bool helloPolicyConfigured = _espAndHelloTracker.IsPolicyConfigured;
- bool helloCompleted = _espAndHelloTracker.IsHelloCompleted;
-
- if (helloPolicyConfigured && !helloCompleted)
- {
- // Hello is configured but not finished yet - DO NOT mark enrollment as complete
- _logger.Info("EnrollmentTracker: Windows Hello policy is configured but provisioning has not completed yet.");
- _logger.Info("EnrollmentTracker: Waiting for Hello provisioning to finish before marking enrollment as complete.");
-
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "waiting_for_hello",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.FinalizingSetup,
- Message = "User apps completed - waiting for Windows Hello provisioning to finish",
- ImmediateUpload = true
- });
-
- // Set flag so we know we're waiting
- lock (_stateLock)
- {
- _isWaitingForHello = true;
- _stateData.IsWaitingForHello = true;
- _stateData.WaitingForHelloStartedUtc = DateTime.UtcNow;
- _stateDirty = true;
- }
- _statePersistence.Save(_stateData); // Immediate persist — summary timer was just stopped
-
- // Defense-in-depth: Ensure Hello wait timer is running.
- // StartHelloWaitTimer() has internal guards (returns if timer already running,
- // if Hello already completed, or if wizard already started). Safe to call multiple times.
- _espAndHelloTracker?.StartHelloWaitTimer();
-
- // Safety net: if Hello never resolves (timer chain fails, agent crash without restart),
- // force enrollment_complete after 7 minutes instead of waiting for the 6h max lifetime timer.
- _waitingForHelloSafetyTimer?.Dispose();
- _waitingForHelloSafetyTimer = new Timer(
- OnWaitingForHelloSafetyTimeout,
- null,
- TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds),
- TimeSpan.FromMilliseconds(-1));
-
- ShadowProcessTrigger("ime_user_session_completed");
- return;
- }
- }
-
- // ESP provisioning settle check: if ESP categories are still resolving in the registry,
- // wait up to 30s for Windows to finalize the outcomes before completing.
- // This catches cases where the agent started late and the ESP status hasn't settled yet.
- if (_espAndHelloTracker != null)
- {
- var snapshot = _espAndHelloTracker.GetProvisioningSnapshot();
- if (snapshot != null && snapshot.CategoriesSeen > 0 && !snapshot.AllResolved)
- {
- var unresolvedCategories = snapshot.CategoryOutcomes
- .Where(kvp => kvp.Value == "in_progress")
- .Select(kvp => kvp.Key)
- .ToList();
-
- _logger.Info($"EnrollmentTracker: ESP provisioning categories not yet resolved " +
- $"({string.Join(", ", unresolvedCategories)}) — waiting up to {EspSettleTimeoutSeconds}s for settlement");
-
- EmitImeTrackerEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_provisioning_settle_started",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"ESP provisioning settle wait started — {unresolvedCategories.Count} category(ies) unresolved: {string.Join(", ", unresolvedCategories)}",
- Data = new Dictionary
- {
- { "unresolvedCategories", unresolvedCategories },
- { "settleTimeoutSeconds", EspSettleTimeoutSeconds },
- { "categoriesSeen", snapshot.CategoriesSeen },
- { "categoriesResolved", snapshot.CategoriesResolved }
- },
- ImmediateUpload = true
- });
-
- lock (_stateLock)
- {
- _isWaitingForEspSettle = true;
- _stateData.IsWaitingForEspSettle = true;
- _stateData.WaitingForEspSettleStartedUtc = DateTime.UtcNow;
- _stateDirty = true;
- }
- _statePersistence.Save(_stateData);
-
- _waitingForEspSettleTimer?.Dispose();
- _waitingForEspSettleTimer = new Timer(
- OnEspSettleTimerExpired,
- null,
- TimeSpan.FromSeconds(EspSettleTimeoutSeconds),
- TimeSpan.FromMilliseconds(-1));
-
- ShadowProcessTrigger("ime_user_session_completed");
- return;
- }
- }
-
- TryEmitEnrollmentComplete("ime_pattern");
-
- // Shadow state machine: track IME user session completed
- ShadowProcessTrigger("ime_user_session_completed");
- }
-
- ///
- /// Checks whether a real user profile exists in C:\Users\ (beyond system accounts).
- /// Used to verify AccountSetup phase: during WhiteGlove pre-provisioning only defaultuser0
- /// exists; in user-driven enrollment the signed-in user's profile is already created.
- ///
- private bool HasRealUserProfile()
- {
- try
- {
- var usersDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
- // Navigate to the Users root (parent of the current profile)
- usersDir = Path.GetDirectoryName(usersDir); // e.g. C:\Users
- if (string.IsNullOrEmpty(usersDir) || !Directory.Exists(usersDir))
- usersDir = @"C:\Users";
-
- var excludedNames = new HashSet(StringComparer.OrdinalIgnoreCase)
- {
- "defaultuser0", "defaultuser1", "Default", "Default User",
- "Public", "All Users", "Administrator", "Guest"
- };
-
- foreach (var dir in Directory.GetDirectories(usersDir))
- {
- var name = Path.GetFileName(dir);
- if (excludedNames.Contains(name))
- continue;
- if (name.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase))
- continue;
-
- _logger.Debug($"EnrollmentTracker: HasRealUserProfile found '{name}'");
- return true;
- }
-
- return false;
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: HasRealUserProfile check failed: {ex.Message}");
- return false; // Fail safe: treat as no real user (suppress AccountSetup)
- }
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.cs
deleted file mode 100644
index 3e7a071e..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.cs
+++ /dev/null
@@ -1,814 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-using Microsoft.Win32;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
-{
- ///
- /// Central enrollment tracking orchestrator.
- /// Collects consolidated device info events at startup and manages ImeLogTracker
- /// for smart app installation tracking with strategic event emission.
- ///
- public partial class EnrollmentTracker : IDisposable
- {
- private readonly string _sessionId;
- private readonly string _tenantId;
- private readonly Action _emitEvent;
- private readonly AgentLogger _logger;
- private readonly string _imeLogFolder;
- private readonly List _imeLogPatterns;
- private readonly SystemSignals.EspAndHelloTracker _espAndHelloTracker;
-
- private ImeLogTracker _imeLogTracker;
- private Timer _summaryTimer;
- private bool _summaryTimerActive;
- private Timer _debugStateTimer;
- private bool _debugStateTimerActive;
-
- // ===== Thread-safety =====
- // All mutable state fields below are accessed from multiple threads:
- // - Timer callbacks (SummaryTimer, DebugStateTimer, safety timers) → ThreadPool
- // - ImeLogTracker event handlers (HandleEspPhaseChanged, HandleAppStateChanged, etc.) → FileSystemWatcher thread
- // - EspAndHelloTracker event handlers (OnHelloCompleted, OnFinalizingSetupPhaseTriggered, etc.) → COM thread
- // - MonitoringService (NotifyDesktopArrived) → desktop detection thread
- // - Task.Run (CollectDeviceInfo) → ThreadPool
- //
- // A single lock protects all shared fields. Lock sections contain ONLY field reads/writes —
- // never _emitEvent, _logger, timer operations, or external callbacks. This ensures:
- // - No deadlocks (no nested locking, no I/O under lock)
- // - Minimal contention (microsecond-level critical sections)
- // - Consistent multi-field reads (e.g., TryEmitEnrollmentComplete guard checks)
- private readonly object _stateLock = new object();
-
- private string _lastEspPhase; // Track last ESP phase to prevent duplicate events
- private bool _hasAutoSwitchedToAppsPhase; // Track if we've already auto-switched to apps phase for current ESP phase
- private string _enrollmentType = "v1"; // "v1" = Autopilot Classic/ESP, "v2" = Windows Device Preparation
- private IEnrollmentFlowHandler _flowHandler = EnrollmentFlowFactory.FromWireFormat("v1"); // Kept in sync with _enrollmentType via SetEnrollmentType()
- private bool _isWaitingForHello = false; // Track if we're waiting for Hello to complete before sending enrollment_complete
- private readonly bool _isBootstrapMode; // Agent started via bootstrap token (pre-MDM)
- private bool _sendTraceEvents = true; // Send Trace-severity events to backend for decision auditing
-
- // TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
- private string _lastShadowDiscrepancySignature;
- private bool _enrollmentStartDeviceInfoCollected = false; // Re-collect enrollment-dependent info once at first ESP phase
- private bool _finalDeviceInfoCollected = false; // Ensure final device info is emitted only once
- private string _lastEmittedSummaryHash; // Track last emitted state-breakdown to avoid redundant summary events
- private string _lastEmittedSummaryCompactHash; // Track completed+error counts only — changes here elevate severity to Info
-
- // ESP failure handling (Phase 1)
- private Timer _espFailureTimer;
- private string _pendingEspFailureType;
- private const int EspFailureGracePeriodSeconds = 60;
- private static readonly HashSet RecoverableEspFailureTypes = new HashSet(StringComparer.OrdinalIgnoreCase)
- {
- "ESPProgress_Timeout"
- };
-
- // Unified completion logic (Phase 3A)
- private bool _espEverSeen;
- private bool _espFinalExitSeen;
- private bool _enrollmentCompleteEmitted;
- private bool _desktopArrived;
- private bool _isHybridJoin;
- private readonly DateTime _agentStartTimeUtc = DateTime.UtcNow;
-
- // Device info readiness — guards against race where completion signals arrive before CollectDeviceInfo finishes
- private volatile bool _deviceInfoCollected;
- private string _pendingCompletionSource;
-
- // Device-Only ESP detection (Phase 3C)
- private Timer _deviceOnlyEspTimer;
- private const int DeviceOnlyEspTimerMinutes = 5;
-
- // ESP configuration from registry (FirstSync\SkipUserStatusPage / SkipDeviceStatusPage)
- // null = unknown (registry keys not present), true = skip, false = show
- private bool? _skipUserStatusPage;
- private bool? _skipDeviceStatusPage;
-
- // Autopilot deployment mode: 0=UserDriven, 1/2/3+=not yet confirmed mapping, null=unknown
- private int? _autopilotMode;
- private bool IsSelfDeploying => _autopilotMode == 1;
-
- // True when AAD join status shows a joined device with a real user email.
- // Detected via CollectAadJoinStatus() in DeviceInfoCollector.
- private bool _aadJoinedWithUser;
- // Soft indicator: AAD userEmail matches a transient provisioning placeholder pattern
- // (foouser@, autopilot@). These accounts appear during Autopilot pre-provisioning
- // (WhiteGlove) and MUST NOT be treated as a real AAD-joined user.
- private bool _fooUserDetected;
-
- // Set to true the moment the Shell-Core Event-62407 "WhiteGlove_Success" handler fires.
- // Distinct from (which is the
- // fire-once guard inside CompleteWhiteGlove and covers any completion path). Feeds
- // the WhiteGloveClassifier as the strongest positive signal.
- private bool _shellCoreWhiteGloveFired;
-
- // Signal-correlated WhiteGlove detection (Ebene 2.6):
- // Feeds the bestehenden OnWhiteGloveCompleted() callback when the Shell-Core detector
- // does not match but the session shows the WhiteGlove sealing pattern (all Device-apps done,
- // DeviceSetup category succeeded, system reboot observed, AAD Not Joined, no AccountSetup
- // progress, no desktop arrival). Requires ≥10 min stability to avoid false-positives.
- private DateTime? _signalCorrelatedWhiteGloveStableSince;
- private bool _signalCorrelatedWhiteGloveTriggered;
- private bool _systemRebootObserved;
- private const int SignalCorrelatedWhiteGloveStabilityMinutes = 10;
-
- ///
- /// Called by MonitoringService when a system_reboot_detected event is emitted
- /// (agent restart after an unclean exit / machine reboot). Used as an input signal
- /// for the signal-correlated WhiteGlove detection heuristic.
- ///
- public void NotifySystemRebootDetected()
- {
- lock (_stateLock)
- {
- _systemRebootObserved = true;
- }
- }
-
- ///
- /// Called after a successful RegisterSession. Compares the backend's authoritative
- /// validator verdict against the agent's registry-based enrollment-type detection
- /// and, on mismatch, emits an enrollment_type_mismatch warning event and
- /// switches the active flow handler to the backend's verdict.
- ///
- /// and
- /// are treated as type-neutral (no claim about Classic vs DevPrep) and do not trigger a switch.
- ///
- public void ReconcileWithBackendValidator(ValidatorType validatedBy)
- {
- EnrollmentType backendType = validatedBy switch
- {
- ValidatorType.AutopilotV1 => EnrollmentType.Classic,
- ValidatorType.DeviceAssociation => EnrollmentType.DevicePreparation,
- _ => EnrollmentType.Unknown
- };
-
- if (backendType == EnrollmentType.Unknown)
- return;
-
- string currentWire;
- lock (_stateLock) { currentWire = _enrollmentType; }
- var currentType = EnrollmentTypeExtensions.FromWireFormat(currentWire);
-
- if (currentType == backendType)
- {
- _logger.Verbose($"EnrollmentTracker: backend validator '{validatedBy}' matches registry-detected enrollment type '{currentWire}'.");
- return;
- }
-
- var newWire = backendType.ToWireFormat();
- _logger.Warning($"EnrollmentTracker: enrollment type mismatch — registry='{currentWire}', backend='{newWire}' (validator={validatedBy}). Switching to backend verdict.");
-
- lock (_stateLock)
- {
- _enrollmentType = newWire;
- _flowHandler = EnrollmentFlowFactory.Create(backendType);
- _stateData.EnrollmentType = newWire;
- _stateDirty = true;
- }
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "enrollment_type_mismatch",
- Severity = EventSeverity.Warning,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Registry-detected enrollment type '{currentWire}' differs from backend validator verdict '{newWire}' ({validatedBy}). Using backend verdict.",
- Data = new Dictionary
- {
- { "registryEnrollmentType", currentWire },
- { "backendEnrollmentType", newWire },
- { "backendValidator", validatedBy.ToString() }
- }
- });
- }
-
- // True when no interactive user session is expected.
- // Self-Deploying (AutopilotMode=1) is always device-only.
- // SkipUserStatusPage=true alone does NOT mean device-only — admins commonly skip
- // the user ESP page in user-driven enrollments. Only classify as device-only when
- // SkipUserStatusPage=true AND no user has AAD-joined (no aad_join_status with userEmail).
- private bool IsDeviceOnlyDeployment => IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser);
-
- ///
- /// Creates a read-only snapshot of current state for the shadow state machine.
- /// Must be called OUTSIDE _stateLock (takes its own lock internally).
- ///
- private CompletionContext SnapshotCompletionContext()
- {
- lock (_stateLock)
- {
- return new CompletionContext
- {
- EnrollmentType = _enrollmentType,
- IsHybridJoin = _isHybridJoin,
- AutopilotMode = _autopilotMode,
- SkipUserStatusPage = _skipUserStatusPage,
- AadJoinedWithUser = _aadJoinedWithUser,
- EspEverSeen = _espEverSeen,
- EspFinalExitSeen = _espFinalExitSeen,
- DesktopArrived = _desktopArrived,
- LastEspPhase = _lastEspPhase,
- DeviceInfoCollected = _deviceInfoCollected,
- HasHelloTracker = _espAndHelloTracker != null,
- IsHelloCompleted = _espAndHelloTracker?.IsHelloCompleted ?? false,
- IsHelloPolicyConfigured = _espAndHelloTracker?.IsPolicyConfigured ?? false,
- HasUnresolvedEspCategories = false, // Will be set by caller when available
- HasAccountSetupActivity = _espAndHelloTracker?.HasAccountSetupActivity ?? false,
- WhiteGloveStartDetected = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false,
- HasSaveWhiteGloveSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false,
- ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired,
- IsFooUserDetected = _fooUserDetected,
- AgentStartTimeUtc = _agentStartTimeUtc,
- EspFinalExitUtc = _stateData.EspFinalExitUtc,
- ImePatternSeenUtc = _stateData.ImePatternSeenUtc
- };
- }
- }
-
- ///
- /// Fires the shadow state machine trigger and compares the result with the actual state.
- /// Logs discrepancies as warnings. Does NOT change actual behavior.
- /// Also dual-writes the CompletionState to _stateData for persistence.
- ///
- private void ShadowProcessTrigger(string trigger, CompletionContext ctx = null)
- {
- try
- {
- ctx = ctx ?? SnapshotCompletionContext();
- var result = _completionSm.ProcessTrigger(trigger, ctx);
-
- // Dual-write: persist the explicit state alongside the boolean flags
- lock (_stateLock)
- {
- _stateData.CompletionState = _completionSm.CurrentState.ToString();
- }
-
- // Compare terminal state: shadow SM reached terminal vs actual _enrollmentCompleteEmitted.
- // Both sides must use the same definition of "terminal": the actual flag is set for
- // enrollment_complete, enrollment_failed AND the WG-redirect path — so shadow must match
- // that by accepting Completed, Failed and WhiteGloveCompleted as terminal states.
- bool actualTerminal;
- lock (_stateLock) { actualTerminal = _enrollmentCompleteEmitted; }
- bool shadowTerminal = _completionSm.CurrentState.IsTerminal();
-
- if (actualTerminal != shadowTerminal)
- {
- _logger.Warning($"EnrollmentTracker [SHADOW DISCREPANCY]: trigger='{trigger}', " +
- $"shadowState={_completionSm.CurrentState}, actualEmitted={actualTerminal}, " +
- $"shadowCompleted={shadowTerminal}");
-
- // TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
- // Emit a backend event (throttled to 1x per unique signature per session) so we can query
- // mismatches without relying on client-side logs. Severity=Warning so it ships independent of SendTraceEvents.
- EmitShadowDiscrepancyEvent(trigger, ctx, actualTerminal, shadowTerminal);
- }
- else if (result.Transitioned)
- {
- _logger.Verbose($"EnrollmentTracker [SHADOW]: trigger='{trigger}', " +
- $"{result.PreviousState} -> {result.NewState}");
- }
- }
- catch (Exception ex)
- {
- // Shadow mode must never crash the agent
- _logger.Debug($"EnrollmentTracker [SHADOW ERROR]: trigger='{trigger}', error={ex.Message}");
- }
- }
-
- // Safety-net timer for waiting_for_hello state
- private Timer _waitingForHelloSafetyTimer;
- private const int WaitingForHelloSafetyTimeoutSeconds = 420; // 7 min — longer than Hello chain (330s)
-
- // ESP provisioning settle wait: when IME pattern fires but ESP categories are still unresolved,
- // wait up to 30s for Windows to finalize the ESP provisioning status in the registry.
- private bool _isWaitingForEspSettle = false;
- private Timer _waitingForEspSettleTimer;
- private const int EspSettleTimeoutSeconds = 30;
-
- // Safety-net timer for device-only ESP completion (SkipUserStatusPage=true)
- private Timer _deviceOnlyCompletionSafetyTimer;
- private const int DeviceOnlyCompletionSafetyTimeoutSeconds = 420; // 7 min — same as Hello safety
-
- // State persistence for crash recovery
- private readonly EnrollmentStatePersistence _statePersistence;
- private EnrollmentStateData _stateData = new EnrollmentStateData();
- private bool _stateDirty;
-
- // Completion check throttling (max 1x/min per source)
- private readonly Dictionary _lastCompletionCheckBySource = new Dictionary();
-
- // Shadow state machine: runs in parallel with existing logic for behavioral verification.
- // Logs discrepancies but does NOT affect actual enrollment completion.
- private readonly CompletionStateMachine _completionSm = new CompletionStateMachine();
-
- // Default IME log folder
- private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs";
-
- private Telemetry.DeviceInfo.DeviceInfoCollector _deviceInfoCollector;
-
- // ConfigMgr (SCCM) co-management detection — fire-once guard
- private bool _configMgrDetected;
-
- ///
- /// Detects whether the Autopilot profile indicates Hybrid Azure AD Join
- /// by reading CloudAssignedDomainJoinMethod from the AutopilotPolicyCache registry key.
- /// Safe to call before EnrollmentTracker is instantiated.
- /// Returns true if CloudAssignedDomainJoinMethod == 1.
- ///
- public static bool DetectHybridJoinStatic()
- {
- try
- {
- using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"))
- {
- if (key != null)
- {
- var domainJoinMethod = key.GetValue("CloudAssignedDomainJoinMethod")?.ToString();
- return domainJoinMethod == "1";
- }
- }
- }
- catch { }
-
- return false;
- }
-
- ///
- /// Detects whether this is an Autopilot v1 (Classic ESP) or v2 (Windows Device Preparation) enrollment
- /// by reading the Autopilot registry keys. Safe to call before EnrollmentTracker is instantiated.
- /// Returns "v2" if WDP indicators are present, "v1" otherwise.
- ///
- public static string DetectEnrollmentTypeStatic()
- {
- try
- {
- using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings"))
- {
- if (key != null)
- {
- // CloudAssignedDeviceRegistration=2 signals WDP provisioning flow
- var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString();
- if (deviceReg == "2")
- return "v2";
-
- // CloudAssignedEspEnabled=0 means no ESP, characteristic of WDP
- var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString();
- if (espEnabled == "0")
- return "v2";
- }
- }
- }
- catch { }
-
- return "v1";
- }
-
- public EnrollmentTracker(
- string sessionId,
- string tenantId,
- Action emitEvent,
- AgentLogger logger,
- List imeLogPatterns,
- string imeLogFolderOverride = null,
- bool simulationMode = false,
- double speedFactor = 50,
- string imeMatchLogPath = null,
- SystemSignals.EspAndHelloTracker espAndHelloTracker = null,
- bool isBootstrapMode = false,
- bool sendTraceEvents = true)
- {
- _isBootstrapMode = isBootstrapMode;
- _sendTraceEvents = sendTraceEvents;
- _isHybridJoin = DetectHybridJoinStatic();
- _sessionId = sessionId;
- _tenantId = tenantId;
- _emitEvent = emitEvent;
- _logger = logger;
- _imeLogPatterns = imeLogPatterns ?? new List();
- _imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder;
- _espAndHelloTracker = espAndHelloTracker;
- _deviceInfoCollector = new Telemetry.DeviceInfo.DeviceInfoCollector(sessionId, tenantId, emitEvent, logger);
-
- // State persistence for crash recovery
- var stateDirectory = @"%ProgramData%\AutopilotMonitor\State";
- _statePersistence = new EnrollmentStatePersistence(stateDirectory, _logger);
-
- // Create ImeLogTracker with state persistence directory
- _imeLogTracker = new ImeLogTracker(_imeLogFolder, _imeLogPatterns, _logger, matchLogPath: imeMatchLogPath, stateDirectory: stateDirectory);
- _imeLogTracker.SimulationMode = simulationMode;
- _imeLogTracker.SpeedFactor = speedFactor;
-
- // Wire up callbacks
- _imeLogTracker.OnEspPhaseChanged = HandleEspPhaseChanged;
- _imeLogTracker.OnImeAgentVersion = HandleImeAgentVersion;
- _imeLogTracker.OnImeStarted = HandleImeStarted;
- _imeLogTracker.OnAppStateChanged = HandleAppStateChanged;
- _imeLogTracker.OnPoliciesDiscovered = HandlePoliciesDiscovered;
- _imeLogTracker.OnAllAppsCompleted = HandleAllAppsCompleted;
- _imeLogTracker.OnUserSessionCompleted = HandleUserSessionCompleted;
- _imeLogTracker.OnImeSessionChange = HandleImeSessionChange;
- _imeLogTracker.OnDoTelemetryReceived = HandleDoTelemetryReceived;
- _imeLogTracker.OnScriptCompleted = HandleScriptCompleted;
-
- // Subscribe to EspAndHelloTracker completion event if available
- if (_espAndHelloTracker != null)
- {
- _espAndHelloTracker.HelloCompleted += OnHelloCompleted;
- _espAndHelloTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
- _espAndHelloTracker.WhiteGloveCompleted += OnWhiteGloveCompleted;
- _espAndHelloTracker.EspFailureDetected += OnEspFailureDetected;
- _espAndHelloTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
-
- // Backfill recent ESP exit events that may have fired before we subscribed.
- // The EspAndHelloTracker is started by CollectorCoordinator before the EnrollmentTracker
- // is created, so esp_exiting events detected during that window are lost. Backfill recovers them.
- _espAndHelloTracker.BackfillRecentEspExitEvents();
- }
- }
-
- ///
- /// Updates the SendTraceEvents setting from remote config at runtime.
- ///
- public void UpdateSendTraceEvents(bool value) => _sendTraceEvents = value;
-
- ///
- /// Emits a Trace-severity event that captures an agent decision for backend-side troubleshooting.
- /// Always logged locally; only sent to the backend when SendTraceEvents is enabled.
- ///
- private void EmitTraceEvent(string decision, string reason, Dictionary context = null)
- {
- var data = new Dictionary
- {
- { "decision", decision },
- { "reason", reason }
- };
- if (context != null)
- {
- foreach (var kvp in context)
- data[kvp.Key] = kvp.Value;
- }
-
- _logger.Trace($"EnrollmentTracker: {decision} — {reason}");
-
- if (_sendTraceEvents)
- {
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "agent_trace",
- Severity = EventSeverity.Trace,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"{decision}: {reason}",
- Data = data
- });
- }
- }
-
- // TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
- ///
- /// Emits a shadow_discrepancy event when the shadow CompletionStateMachine disagrees
- /// with the actual _enrollmentCompleteEmitted flag. Throttled to one event per unique
- /// {trigger|shadowState|actualEmitted} signature per session to avoid flooding the backend
- /// while still surfacing every distinct divergence constellation.
- ///
- private void EmitShadowDiscrepancyEvent(string trigger, CompletionContext ctx, bool actualTerminal, bool shadowTerminal)
- {
- var shadowState = _completionSm.CurrentState.ToString();
- var signature = $"{trigger}|{shadowState}|{actualTerminal}";
- if (signature == _lastShadowDiscrepancySignature)
- return;
- _lastShadowDiscrepancySignature = signature;
-
- var data = new Dictionary
- {
- { "trigger", trigger ?? "(null)" },
- { "shadowState", shadowState },
- { "actualEmitted", actualTerminal },
- { "shadowCompleted", shadowTerminal },
- { "enrollmentType", ctx?.EnrollmentType },
- { "autopilotMode", ctx?.AutopilotMode },
- { "skipUserStatusPage", ctx?.SkipUserStatusPage },
- { "aadJoinedWithUser", ctx?.AadJoinedWithUser ?? false },
- { "isHybridJoin", ctx?.IsHybridJoin ?? false },
- { "isDeviceOnly", ctx?.IsDeviceOnly ?? false },
- { "espEverSeen", ctx?.EspEverSeen ?? false },
- { "espFinalExitSeen", ctx?.EspFinalExitSeen ?? false },
- { "desktopArrived", ctx?.DesktopArrived ?? false },
- { "lastEspPhase", ctx?.LastEspPhase },
- { "hasHelloTracker", ctx?.HasHelloTracker ?? false },
- { "isHelloCompleted", ctx?.IsHelloCompleted ?? false },
- { "isHelloPolicyConfigured", ctx?.IsHelloPolicyConfigured ?? false },
- { "hasUnresolvedEspCategories", ctx?.HasUnresolvedEspCategories ?? false }
- };
-
- try
- {
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = Constants.EventTypes.ShadowDiscrepancy,
- Severity = EventSeverity.Warning,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Shadow SM mismatch: trigger={trigger}, shadow={shadowState}, emitted={actualTerminal}",
- Data = data
- });
- }
- catch (Exception ex)
- {
- // Diagnostic event must never crash the agent
- _logger.Debug($"EnrollmentTracker [SHADOW EVENT ERROR]: {ex.Message}");
- }
- }
-
- ///
- /// Starts the enrollment tracker: collects device info and starts IME log tracking.
- ///
- public void Start()
- {
- _logger.Info("EnrollmentTracker: starting");
-
- // Load persisted state for crash recovery
- LoadState();
-
- // Start IME log tracking immediately — this is the critical enrollment watcher.
- // Device info collection (WMI + registry) runs in the background so it doesn't
- // delay the ImeLogTracker from catching early enrollment events.
- _imeLogTracker.Start();
-
- // Collect and emit device info asynchronously (3 WMI queries + 5+ registry reads)
- Task.Run(() =>
- {
- try
- {
- CollectDeviceInfo();
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: background device info collection failed: {ex.Message}");
- }
- });
-
- // Check for ConfigMgr co-management client (separate task, must not delay device info)
- Task.Run(() =>
- {
- try { CheckConfigMgrClient("startup"); }
- catch (Exception ex) { _logger.Debug($"EnrollmentTracker: startup ConfigMgr check failed: {ex.Message}"); }
- });
-
- // TODO: Überdenken ob ein 30s timer hier wirklich immer gut ist, hab einen Fall gesehen mit
- // laaanger Wartephase in WhiteGlove weil ein 24H2 feature update reinkam und installiert wurde
-
- // Start periodic summary timer (30s, starts when app tracking begins)
- _summaryTimer = new Timer(SummaryTimerCallback, null, Timeout.Infinite, Timeout.Infinite);
- _debugStateTimer = new Timer(DebugStateTimerCallback, null, Timeout.Infinite, Timeout.Infinite);
-
- _logger.Info("EnrollmentTracker: started");
- }
-
- ///
- /// Stops the enrollment tracker
- ///
- public void Stop()
- {
- _logger.Info("EnrollmentTracker: stopping");
-
- _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _imeLogTracker?.Stop();
-
- _logger.Info("EnrollmentTracker: stopped");
- }
-
- ///
- /// Updates IME log patterns (hot-reload from config change)
- ///
- public void UpdatePatterns(List newPatterns)
- {
- if (newPatterns != null)
- {
- _logger.Info("EnrollmentTracker: updating IME log patterns (hot-reload)");
- _imeLogTracker?.CompilePatterns(newPatterns);
- }
- }
-
- ///
- /// Access to the ImeLogTracker (for simulator to reference package states)
- ///
- public ImeLogTracker ImeTracker => _imeLogTracker;
-
- // ===== Device Info Collection =====
-
- private void CollectDeviceInfo()
- {
- var result = _deviceInfoCollector.CollectAll();
-
- bool wasDeviceOnly;
- lock (_stateLock)
- {
- wasDeviceOnly = IsDeviceOnlyDeployment;
- _enrollmentType = result.enrollmentType;
- _flowHandler = EnrollmentFlowFactory.FromWireFormat(result.enrollmentType);
- _skipUserStatusPage = result.skipUserStatusPage;
- _skipDeviceStatusPage = result.skipDeviceStatusPage;
- _autopilotMode = result.autopilotMode;
- _aadJoinedWithUser = result.hasAadJoinedUser;
- _fooUserDetected = result.isFooUserDetected;
- _stateData.EnrollmentType = _enrollmentType;
- _stateData.SkipUserStatusPage = _skipUserStatusPage;
- _stateData.SkipDeviceStatusPage = _skipDeviceStatusPage;
- _stateData.AutopilotMode = _autopilotMode;
- _stateData.AadJoinedWithUser = _aadJoinedWithUser;
- _stateData.FooUserDetected = _fooUserDetected;
- _stateData.IsHybridJoin = _isHybridJoin;
- _stateDirty = true;
- }
-
- // Snapshot values under lock for trace events (emitted outside lock)
- bool isDeviceOnly, isSelfDeploying, aadJoinedWithUser;
- int? autopilotMode;
- bool? skipUserStatusPage;
- string enrollmentType;
- lock (_stateLock)
- {
- isDeviceOnly = IsDeviceOnlyDeployment;
- isSelfDeploying = IsSelfDeploying;
- aadJoinedWithUser = _aadJoinedWithUser;
- autopilotMode = _autopilotMode;
- skipUserStatusPage = _skipUserStatusPage;
- enrollmentType = _enrollmentType;
- }
-
- _logger.Info($"EnrollmentTracker: enrollment type detected: {enrollmentType ?? "unknown"} " +
- $"(autopilotMode={autopilotMode}, skipUserStatusPage={skipUserStatusPage}, skipDeviceStatusPage={result.skipDeviceStatusPage}, aadJoinedWithUser={aadJoinedWithUser})");
-
- // Emit trace when AAD join with user is detected and reclassifies away from device-only
- if (aadJoinedWithUser && wasDeviceOnly && !isDeviceOnly)
- {
- EmitTraceEvent("user_session_detected_via_aad_join",
- "AAD join with user detected — user session active, using standard completion paths",
- new Dictionary
- {
- { "autopilotMode", autopilotMode },
- { "skipUserStatusPage", skipUserStatusPage },
- { "aadJoinedWithUser", true },
- { "isDeviceOnlyDeployment", false }
- });
- }
-
- if (isDeviceOnly)
- {
- var reason = isSelfDeploying
- ? "AutopilotMode=1 (Self-Deploying)"
- : $"SkipUserStatusPage=true (autopilotMode={autopilotMode})";
- EmitTraceEvent("device_only_deployment_detected",
- $"{reason} — Hello guard will be bypassed, provisioning status used as completion signal",
- new Dictionary
- {
- { "autopilotMode", autopilotMode },
- { "skipUserStatusPage", skipUserStatusPage },
- { "enrollmentType", enrollmentType },
- { "aadJoinedWithUser", aadJoinedWithUser },
- { "isSelfDeploying", isSelfDeploying },
- { "isDeviceOnlyDeployment", true }
- });
- }
-
- // Mark device info as collected and re-evaluate any deferred completion signal
- _deviceInfoCollected = true;
-
- string pendingSource;
- lock (_stateLock) { pendingSource = _pendingCompletionSource; _pendingCompletionSource = null; }
- if (pendingSource != null)
- {
- _logger.Info($"EnrollmentTracker: re-evaluating deferred completion signal '{pendingSource}' now that device info is available " +
- $"(isDeviceOnly={isDeviceOnly}, autopilotMode={autopilotMode}, skipUserStatusPage={skipUserStatusPage})");
- if (pendingSource == "device_setup_provisioning_complete")
- OnDeviceSetupProvisioningComplete(this, EventArgs.Empty);
- else
- TryEmitEnrollmentComplete(pendingSource);
- }
-
- // Shadow state machine: track device info collected
- ShadowProcessTrigger("device_info_collected");
- }
-
- ///
- /// Collects device info that may change during enrollment (e.g., BitLocker enabled via policy).
- /// Called at enrollment complete to capture final state.
- ///
- private void CollectDeviceInfoAtEnd()
- => _deviceInfoCollector.CollectAtEnd();
-
- ///
- /// Checks for ConfigMgr (SCCM) co-management client presence on the device.
- /// Fire-once: emits configmgr_client_detected event on first detection only.
- /// Called at startup, DeviceSetup, and AccountSetup — always via Task.Run to avoid blocking.
- ///
- private void CheckConfigMgrClient(string trigger)
- {
- if (_configMgrDetected) return;
-
- try
- {
- bool registryFound = false;
- bool directoryFound = false;
- string ccmVersion = null;
- string siteCode = null;
- string serviceState = "not_found";
-
- // 1. Registry: HKLM\SOFTWARE\Microsoft\CCM
- using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\CCM"))
- {
- if (key != null)
- registryFound = true;
- }
-
- // Version from CCMSetup
- using (var setupKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\CCMSetup"))
- ccmVersion = setupKey?.GetValue("LastValidVersion")?.ToString();
-
- // SiteCode from SMS Mobile Client
- using (var smsKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\SMS\Mobile Client"))
- siteCode = smsKey?.GetValue("AssignedSiteCode")?.ToString();
-
- // 2. Directory: C:\Windows\CCM
- directoryFound = Directory.Exists(@"C:\Windows\CCM");
-
- // 3. Service: CcmExec (via ServiceController, native .NET Framework 4.8)
- try
- {
- using (var sc = new System.ServiceProcess.ServiceController("CcmExec"))
- serviceState = sc.Status.ToString();
- }
- catch (InvalidOperationException) { } // service not installed
-
- // Compute confidence score — directory alone is too weak (could be remnant)
- int confidence = 0;
- if (directoryFound) confidence += 10;
- if (registryFound) confidence += 20;
- if (ccmVersion != null) confidence += 15;
- if (siteCode != null) confidence += 15;
- if (serviceState != "not_found") confidence += 20;
- if (serviceState == "Running") confidence += 20;
-
- // Nothing found — no event
- if (confidence == 0)
- return;
-
- _configMgrDetected = true;
-
- var versionSuffix = ccmVersion != null ? $" (v{ccmVersion})" : "";
- var siteSuffix = siteCode != null ? $", site {siteCode}" : "";
- var confidenceLabel = confidence >= 50 ? "high" : "low";
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "configmgr_client_detected",
- Severity = EventSeverity.Info,
- Source = "Agent",
- Phase = EnrollmentPhase.Unknown,
- Message = $"ConfigMgr (SCCM) client detected ({confidenceLabel} confidence: {confidence}/100) — co-managed device{versionSuffix}{siteSuffix}",
- Data = new Dictionary
- {
- { "ccmRegistryFound", registryFound },
- { "ccmDirectoryFound", directoryFound },
- { "ccmServiceState", serviceState },
- { "ccmVersion", ccmVersion ?? "unknown" },
- { "siteCode", siteCode ?? "unknown" },
- { "confidenceScore", confidence },
- { "detectedAt", trigger }
- }
- });
-
- _logger.Info($"ConfigMgr client detected (trigger={trigger}, confidence={confidence}, service={serviceState}, version={ccmVersion ?? "?"})");
- }
- catch (Exception ex)
- {
- _logger.Debug($"EnrollmentTracker: ConfigMgr check failed: {ex.Message}");
- }
- }
-
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs
deleted file mode 100644
index b14bbf1a..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using AutopilotMonitor.Shared.Models;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
-{
- public sealed class ClassicAutopilotFlow : IEnrollmentFlowHandler
- {
- public EnrollmentType SupportedType => EnrollmentType.Classic;
- public bool TracksEspPhases => true;
- public bool AppliesEspGateOnDesktopArrival => true;
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs
deleted file mode 100644
index 885760ea..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using AutopilotMonitor.Shared.Models;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
-{
- public sealed class DevicePreparationFlow : IEnrollmentFlowHandler
- {
- public EnrollmentType SupportedType => EnrollmentType.DevicePreparation;
- public bool TracksEspPhases => false;
- public bool AppliesEspGateOnDesktopArrival => false;
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs
deleted file mode 100644
index f5cc018b..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using AutopilotMonitor.Shared.Models;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
-{
- public static class EnrollmentFlowFactory
- {
- ///
- /// Returns the flow handler for the given enrollment type.
- /// falls back to the Classic flow
- /// (safe default — Classic is the long-standing behavior).
- ///
- public static IEnrollmentFlowHandler Create(EnrollmentType type) => type switch
- {
- EnrollmentType.DevicePreparation => new DevicePreparationFlow(),
- _ => new ClassicAutopilotFlow()
- };
-
- public static IEnrollmentFlowHandler FromWireFormat(string wireFormat)
- => Create(EnrollmentTypeExtensions.FromWireFormat(wireFormat));
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs
deleted file mode 100644
index 46feed49..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using AutopilotMonitor.Shared.Models;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
-{
- ///
- /// Encapsulates the policy decisions that differ between the Classic Autopilot (v1)
- /// flow and the Windows Autopilot Device Preparation (v2) flow.
- ///
- /// Today the flow handler only owns policy bits; tracker ownership remains in
- /// CollectorCoordinator. When DevPrep-specific trackers land, they will
- /// move into the corresponding flow handler.
- ///
- public interface IEnrollmentFlowHandler
- {
- EnrollmentType SupportedType { get; }
-
- ///
- /// True when the flow relies on ESP-phase signals for progress and completion.
- /// Classic: true. DevPrep (WDP): false (no ESP exists).
- ///
- bool TracksEspPhases { get; }
-
- ///
- /// True when completion triggered by desktop-arrival / desktop-hello must be
- /// blocked while ESP has been observed but has not reached its final exit.
- /// Classic: true (AccountSetup may still be running). DevPrep: false.
- ///
- bool AppliesEspGateOnDesktopArrival { get; }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs
index 63b51647..a8496864 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs
@@ -11,10 +11,8 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
@@ -74,9 +72,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private CancellationTokenSource _cts;
private bool _allAppsCompletedFired;
- // State persistence: saves tracker state to disk so agent restart continues
- // from the exact log position without re-parsing or re-building ignore lists.
- private readonly ImeTrackerStatePersistence _statePersistence;
+ // M4.5.a: ImeTrackerStatePersistence removed. V2 relies on SignalLog-replay + the
+ // SignalAdapter's fire-once dedup for resume semantics; fresh-start on restart is
+ // intentional. See plan §4.x M4.5 Legacy-Cleanup.
private bool _stateDirty;
// Standard GUID capture pattern used as {GUID} placeholder in patterns
@@ -150,11 +148,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_matchLogPath = matchLogPath;
_packageStates = new AppPackageStateList(logger);
- // State persistence setup
- if (!string.IsNullOrEmpty(stateDirectory))
- {
- _statePersistence = new ImeTrackerStatePersistence(stateDirectory, logger);
- }
+ // M4.5.a: state-persistence removed; stateDirectory retained in signature for
+ // backwards-compat with existing callers/tests but intentionally unused.
+ _ = stateDirectory;
if (!string.IsNullOrEmpty(_matchLogPath))
{
@@ -286,164 +282,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_pollingTask = null;
}
- #region State Persistence
+ #region State Persistence (M4.5.a: no-op stubs — legacy ImeTrackerStatePersistence removed)
+
+ // V2 resume semantics come from SignalLog-replay + SignalAdapter fire-once dedup.
+ // These stubs preserve the call-sites (LoadState / SaveState / DeleteState) across
+ // the polling loop + event handlers so the tracker compiles unchanged outside this
+ // region. See plan §4.x M4.5 Legacy-Cleanup.
- ///
- /// Loads persisted state from disk. Called on Start() to restore tracker state
- /// after an agent restart, so parsing continues exactly where it left off.
- ///
private void LoadState()
{
- if (_statePersistence == null) return;
-
- var state = _statePersistence.Load();
- if (state == null) return;
-
- // Restore phase tracking
- _currentPhaseOrder = state.CurrentPhaseOrder;
- _lastEspPhaseDetected = state.LastEspPhaseDetected;
- _allAppsCompletedFired = state.AllAppsCompletedFired;
- _logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase;
-
- // Restore seen app IDs
- _seenAppIds.Clear();
- if (state.SeenAppIds != null)
- {
- foreach (var id in state.SeenAppIds)
- _seenAppIds.Add(id);
- }
-
- // Restore ignore list
- if (state.IgnoreList != null)
- {
- foreach (var id in state.IgnoreList)
- _packageStates.AddToIgnoreList(id);
- }
-
- // Restore current package ID
- _packageStates.CurrentPackageId = state.CurrentPackageId;
-
- // Restore package states
- if (state.Packages != null)
- {
- _packageStates.Clear();
- foreach (var p in state.Packages)
- {
- var pkg = AppPackageState.Restore(
- p.Id, p.ListPos, p.Name,
- (AppRunAs)p.RunAs, (AppIntent)p.Intent, (AppTargeted)p.Targeted,
- p.DependsOn != null ? new HashSet(p.DependsOn) : new HashSet(),
- (AppInstallationState)p.InstallationState, p.DownloadingOrInstallingSeen,
- p.ProgressPercent, p.BytesDownloaded, p.BytesTotal,
- errorPatternId: p.ErrorPatternId, errorDetail: p.ErrorDetail, errorCode: p.ErrorCode,
- exitCode: p.ExitCode, hresultFromWin32: p.HResultFromWin32,
- doFileSize: p.DoFileSize, doTotalBytesDownloaded: p.DoTotalBytesDownloaded,
- doBytesFromPeers: p.DoBytesFromPeers, doPercentPeerCaching: p.DoPercentPeerCaching,
- doBytesFromLanPeers: p.DoBytesFromLanPeers, doBytesFromGroupPeers: p.DoBytesFromGroupPeers,
- doBytesFromInternetPeers: p.DoBytesFromInternetPeers, doDownloadMode: p.DoDownloadMode,
- doDownloadDuration: p.DoDownloadDuration, doBytesFromHttp: p.DoBytesFromHttp,
- hasDoTelemetry: p.HasDoTelemetry,
- appVersion: p.AppVersion, appType: p.AppType,
- attemptNumber: p.AttemptNumber, detectionResult: p.DetectionResult);
- _packageStates.Add(pkg);
- }
- }
-
- // Restore file positions
- if (state.FilePositions != null)
- {
- foreach (var kvp in state.FilePositions)
- {
- var fullPath = Path.Combine(_logFolder, kvp.Key);
- _positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize);
- }
- }
-
- // Re-activate patterns based on restored phase state
- ActivatePatterns(_logPhaseIsCurrentPhase, force: true);
-
- _logger.Info($"ImeLogTracker: state restored - phase: {_lastEspPhaseDetected ?? "(none)"} (order: {_currentPhaseOrder}), " +
- $"ignore list: {_packageStates.IgnoreList.Count}, packages: {_packageStates.Count}, " +
- $"file positions: {state.FilePositions?.Count ?? 0}");
+ // no-op — fresh start on restart
}
- ///
- /// Persists current tracker state to disk as JSON.
- /// Called after each polling cycle when state has changed.
- ///
private void SaveState()
{
- if (_statePersistence == null) return;
-
- // Build state DTO
- var state = new ImeTrackerStateData
- {
- CurrentPhaseOrder = _currentPhaseOrder,
- LastEspPhaseDetected = _lastEspPhaseDetected,
- AllAppsCompletedFired = _allAppsCompletedFired,
- LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase,
- SeenAppIds = _seenAppIds.ToList(),
- IgnoreList = _packageStates.IgnoreList.ToList(),
- CurrentPackageId = _packageStates.CurrentPackageId,
- Packages = _packageStates.Select(p => new PackageStateData
- {
- Id = p.Id,
- ListPos = p.ListPos,
- Name = p.Name,
- RunAs = (int)p.RunAs,
- Intent = (int)p.Intent,
- Targeted = (int)p.Targeted,
- DependsOn = p.DependsOn?.ToList() ?? new List(),
- InstallationState = (int)p.InstallationState,
- DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen,
- ProgressPercent = p.ProgressPercent,
- BytesDownloaded = p.BytesDownloaded,
- BytesTotal = p.BytesTotal,
- ErrorPatternId = p.ErrorPatternId,
- ErrorDetail = p.ErrorDetail,
- ErrorCode = p.ErrorCode,
- ExitCode = p.ExitCode,
- HResultFromWin32 = p.HResultFromWin32,
- DoFileSize = p.DoFileSize,
- DoTotalBytesDownloaded = p.DoTotalBytesDownloaded,
- DoBytesFromPeers = p.DoBytesFromPeers,
- DoPercentPeerCaching = p.DoPercentPeerCaching,
- DoBytesFromLanPeers = p.DoBytesFromLanPeers,
- DoBytesFromGroupPeers = p.DoBytesFromGroupPeers,
- DoBytesFromInternetPeers = p.DoBytesFromInternetPeers,
- DoDownloadMode = p.DoDownloadMode,
- DoDownloadDuration = p.DoDownloadDuration,
- DoBytesFromHttp = p.DoBytesFromHttp,
- HasDoTelemetry = p.HasDoTelemetry,
- AppVersion = p.AppVersion,
- AppType = p.AppType,
- AttemptNumber = p.AttemptNumber,
- DetectionResult = p.DetectionResult
- }).ToList(),
- FilePositions = new Dictionary()
- };
-
- // Store file positions by filename only (log folder is known)
- foreach (var kvp in _positionTracker.GetAllPositions())
- {
- var fileName = Path.GetFileName(kvp.Key);
- state.FilePositions[fileName] = new FilePositionData
- {
- Position = kvp.Value.Position,
- LastKnownSize = kvp.Value.LastKnownSize
- };
- }
-
- _statePersistence.Save(state);
+ // no-op — nothing to persist
}
- ///
- /// Deletes persisted state file. Called on enrollment complete to ensure
- /// a fresh state on the next enrollment cycle.
- ///
+ /// Kept as public no-op for API compatibility; callers treat it as a hint.
public void DeleteState()
{
- _statePersistence?.Delete();
+ // no-op
}
#endregion
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs
deleted file mode 100644
index 795d69cb..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs
+++ /dev/null
@@ -1,160 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using Newtonsoft.Json;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
-{
- ///
- /// Handles persistence of ImeLogTracker state to disk (JSON serialization).
- /// Extracted from ImeLogTracker to separate I/O concerns from log parsing.
- ///
- public class ImeTrackerStatePersistence
- {
- private readonly string _stateDirectory;
- private readonly string _stateFilePath;
- private readonly AgentLogger _logger;
-
- public ImeTrackerStatePersistence(string stateDirectory, AgentLogger logger)
- {
- _stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
- _stateFilePath = Path.Combine(_stateDirectory, "ime-tracker-state.json");
- _logger = logger;
- }
-
- public string StateFilePath => _stateFilePath;
-
- ///
- /// Loads persisted state from disk. Returns null if no state file exists or on error.
- ///
- public ImeTrackerStateData Load()
- {
- if (!File.Exists(_stateFilePath))
- {
- _logger.Info("ImeLogTracker: no persisted state found (fresh enrollment)");
- return null;
- }
-
- try
- {
- var json = File.ReadAllText(_stateFilePath);
- var state = JsonConvert.DeserializeObject(json);
- if (state == null)
- {
- _logger.Warning("ImeLogTracker: persisted state file was empty or invalid");
- return null;
- }
- return state;
- }
- catch (Exception ex)
- {
- _logger.Warning($"ImeLogTracker: failed to load persisted state, starting fresh: {ex.Message}");
- return null;
- }
- }
-
- ///
- /// Persists tracker state to disk as JSON (atomic write via temp file).
- ///
- public void Save(ImeTrackerStateData state)
- {
- try
- {
- Directory.CreateDirectory(_stateDirectory);
- var tempPath = _stateFilePath + ".tmp";
- File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented));
- File.Copy(tempPath, _stateFilePath, overwrite: true);
- try { File.Delete(tempPath); } catch { }
- }
- catch (Exception ex)
- {
- _logger.Debug($"ImeLogTracker: failed to save state: {ex.Message}");
- }
- }
-
- ///
- /// Deletes persisted state file.
- ///
- public void Delete()
- {
- try
- {
- if (File.Exists(_stateFilePath))
- {
- File.Delete(_stateFilePath);
- _logger.Info("ImeLogTracker: persisted state deleted");
- }
- }
- catch (Exception ex)
- {
- _logger.Debug($"ImeLogTracker: failed to delete state file: {ex.Message}");
- }
- }
- }
-
- // ---- DTOs for state serialization ----
-
- public class ImeTrackerStateData
- {
- public int CurrentPhaseOrder { get; set; }
- public string LastEspPhaseDetected { get; set; }
- public bool AllAppsCompletedFired { get; set; }
- public bool LogPhaseIsCurrentPhase { get; set; }
- public List SeenAppIds { get; set; }
- public List IgnoreList { get; set; }
- public string CurrentPackageId { get; set; }
- public List Packages { get; set; }
- public Dictionary FilePositions { get; set; }
- }
-
- public class PackageStateData
- {
- public string Id { get; set; }
- public int ListPos { get; set; }
- public string Name { get; set; }
- public int RunAs { get; set; }
- public int Intent { get; set; }
- public int Targeted { get; set; }
- public List DependsOn { get; set; }
- public int InstallationState { get; set; }
- public bool DownloadingOrInstallingSeen { get; set; }
- public int? ProgressPercent { get; set; }
- public long BytesDownloaded { get; set; }
- public long BytesTotal { get; set; }
-
- // Error context
- public string ErrorPatternId { get; set; }
- public string ErrorDetail { get; set; }
- public string ErrorCode { get; set; }
-
- // Installer result codes
- public string ExitCode { get; set; }
- public string HResultFromWin32 { get; set; }
-
- // Delivery Optimization telemetry
- public long DoFileSize { get; set; }
- public long DoTotalBytesDownloaded { get; set; }
- public long DoBytesFromPeers { get; set; }
- public int DoPercentPeerCaching { get; set; }
- public long DoBytesFromLanPeers { get; set; }
- public long DoBytesFromGroupPeers { get; set; }
- public long DoBytesFromInternetPeers { get; set; }
- public int DoDownloadMode { get; set; } = -1;
- public string DoDownloadDuration { get; set; }
- public long DoBytesFromHttp { get; set; }
- public bool HasDoTelemetry { get; set; }
-
- // App metadata (captured from IME logs for App Dashboard reporting)
- public string AppVersion { get; set; }
- public string AppType { get; set; }
- public int AttemptNumber { get; set; }
- public string DetectionResult { get; set; }
- }
-
- public class FilePositionData
- {
- public long Position { get; set; }
- public long LastKnownSize { get; set; }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogReplayService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogReplayService.cs
deleted file mode 100644
index 98012958..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogReplayService.cs
+++ /dev/null
@@ -1,154 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using AutopilotMonitor.Shared.Models;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
-{
- ///
- /// Replays a real Autopilot enrollment using IME log files with time compression.
- ///
- /// Key principle: Real data, no mocks.
- /// - Device info: collected from actual WMI/Registry on the machine running the replay
- /// - App tracking: replayed from real IME log files with configurable speed factor
- /// - Performance data: handled by PerformanceCollector (always on, real machine data)
- ///
- public class LogReplayService : IDisposable
- {
- private readonly AgentLogger _logger;
- private readonly string _sessionId;
- private readonly string _tenantId;
- private readonly Action _onEventCollected;
- private readonly string _replayLogDir;
- private readonly double _speedFactor;
- private readonly List _imeLogPatterns;
-
- private CancellationTokenSource _cancellationTokenSource;
- private Task _replayTask;
- private EnrollmentTracker _enrollmentTracker;
- private bool _disposed = false;
-
- public LogReplayService(
- string sessionId,
- string tenantId,
- Action onEventCollected,
- AgentLogger logger,
- string replayLogDir = null,
- double speedFactor = 50,
- List imeLogPatterns = null)
- {
- _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));
- _replayLogDir = replayLogDir;
- _speedFactor = speedFactor;
- _imeLogPatterns = imeLogPatterns ?? new List();
- }
-
- ///
- /// Starts log replay
- ///
- public void Start()
- {
- _logger.Info("Starting log replay service");
-
- if (string.IsNullOrEmpty(_replayLogDir) || !Directory.Exists(_replayLogDir))
- {
- _logger.Warning($"Replay log directory not found or not specified: {_replayLogDir}");
- _logger.Info("Log replay will only emit device info events (no log replay)");
- }
-
- _cancellationTokenSource = new CancellationTokenSource();
- _replayTask = Task.Run(() => RunReplay(_cancellationTokenSource.Token));
- }
-
- ///
- /// Stops the log replay
- ///
- public void Stop()
- {
- if (_cancellationTokenSource == null || _cancellationTokenSource.IsCancellationRequested)
- return;
-
- _logger.Info("Stopping log replay service");
-
- try
- {
- _cancellationTokenSource?.Cancel();
- _replayTask?.Wait(TimeSpan.FromSeconds(5));
- }
- catch (ObjectDisposedException) { }
- catch (AggregateException) { }
- }
-
- private async Task RunReplay(CancellationToken cancellationToken)
- {
- try
- {
- _logger.Info($"Starting log replay (real device data + IME log replay, speed factor: {_speedFactor}x)");
-
- _enrollmentTracker = new EnrollmentTracker(
- _sessionId,
- _tenantId,
- _onEventCollected,
- _logger,
- _imeLogPatterns,
- _replayLogDir,
- simulationMode: true,
- speedFactor: _speedFactor
- );
-
- _enrollmentTracker.Start();
- _logger.Info($"EnrollmentTracker started in replay mode (speed factor: {_speedFactor}x)");
-
- // Wait for log replay to complete or be cancelled
- var checkInterval = TimeSpan.FromSeconds(5);
- var maxWaitTime = TimeSpan.FromMinutes(10); // Safety timeout
- var elapsed = TimeSpan.Zero;
-
- while (!cancellationToken.IsCancellationRequested && elapsed < maxWaitTime)
- {
- await Task.Delay(checkInterval, cancellationToken);
- elapsed += checkInterval;
-
- var packageStates = _enrollmentTracker.ImeTracker?.PackageStates;
- if (packageStates != null && packageStates.CountAll > 0 && packageStates.IsAllCompleted())
- {
- _logger.Info($"Log replay: all {packageStates.CountAll} apps completed after {elapsed.TotalSeconds:F0}s");
- break;
- }
- }
-
- _logger.Info("Log replay completed (enrollment_complete will be emitted when IME log shows user session completion)");
- }
- catch (OperationCanceledException)
- {
- _logger.Info("Log replay cancelled");
- }
- catch (Exception ex)
- {
- _logger.Error("Error in log replay", ex);
- }
- }
-
- public void Dispose()
- {
- if (_disposed)
- return;
-
- Stop();
- _enrollmentTracker?.Dispose();
- _cancellationTokenSource?.Dispose();
- _disposed = true;
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs
index 432514f3..ba958e40 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs
@@ -10,10 +10,8 @@ using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/AnalyzerManager.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/AnalyzerManager.cs
deleted file mode 100644
index 635d516d..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/AnalyzerManager.cs
+++ /dev/null
@@ -1,139 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading.Tasks;
-using AutopilotMonitor.Agent.V2.Core.Configuration;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
-using AutopilotMonitor.Shared.Models;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
-{
- ///
- /// Manages agent analyzers: initialization from remote config, startup execution
- /// on a background thread, and shutdown execution with optional WhiteGlove part.
- ///
- public class AnalyzerManager
- {
- private readonly AgentConfiguration _configuration;
- private readonly AgentLogger _logger;
- private readonly Action _emitEvent;
- private readonly RemoteConfigService _remoteConfigService;
-
- private readonly List _analyzers = new List();
-
- public AnalyzerManager(
- AgentConfiguration configuration,
- AgentLogger logger,
- Action emitEvent,
- RemoteConfigService remoteConfigService)
- {
- _configuration = configuration;
- _logger = logger;
- _emitEvent = emitEvent;
- _remoteConfigService = remoteConfigService;
- }
-
- public void Initialize()
- {
- _analyzers.Clear();
-
- var analyzerConfig = _remoteConfigService?.CurrentConfig?.Analyzers ?? new AnalyzerConfiguration();
-
- if (analyzerConfig.EnableLocalAdminAnalyzer)
- {
- _analyzers.Add(new LocalAdminAnalyzer(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger,
- analyzerConfig.LocalAdminAllowedAccounts
- ));
- _logger.Info("LocalAdminAnalyzer registered");
- }
- else
- {
- _logger.Info("LocalAdminAnalyzer disabled by remote config");
- }
-
- if (analyzerConfig.EnableSoftwareInventoryAnalyzer)
- {
- _analyzers.Add(new SoftwareInventoryAnalyzer(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger
- ));
- _logger.Info("SoftwareInventoryAnalyzer registered");
- }
- else
- {
- _logger.Info("SoftwareInventoryAnalyzer disabled by remote config");
- }
-
- if (analyzerConfig.EnableIntegrityBypassAnalyzer)
- {
- _analyzers.Add(new IntegrityBypassAnalyzer(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger
- ));
- _logger.Info("IntegrityBypassAnalyzer registered");
- }
- else
- {
- _logger.Info("IntegrityBypassAnalyzer disabled by remote config");
- }
-
- _logger.Info($"Analyzers initialized: {_analyzers.Count} active");
- }
-
- public void RunStartup()
- {
- if (_analyzers.Count == 0)
- return;
-
- var analyzers = new List(_analyzers);
-
- _logger.Info($"Scheduling {analyzers.Count} startup analyzer(s) on background thread");
-
- Task.Run(() =>
- {
- foreach (var analyzer in analyzers)
- {
- try
- {
- analyzer.AnalyzeAtStartup();
- }
- catch (Exception ex)
- {
- _logger.Error($"Analyzer {analyzer.Name} threw during startup", ex);
- }
- }
- });
- }
-
- public void RunShutdown(int? whiteGlovePart = null)
- {
- if (_analyzers.Count == 0)
- return;
-
- _logger.Info($"Running {_analyzers.Count} shutdown analyzer(s) (whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})");
-
- foreach (var analyzer in _analyzers)
- {
- try
- {
- if (analyzer is SoftwareInventoryAnalyzer softwareAnalyzer)
- softwareAnalyzer.AnalyzeAtShutdown(whiteGlovePart);
- else
- analyzer.AnalyzeAtShutdown();
- }
- catch (Exception ex)
- {
- _logger.Error($"Analyzer {analyzer.Name} threw during shutdown", ex);
- }
- }
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CollectorCoordinator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CollectorCoordinator.cs
deleted file mode 100644
index 110096ce..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CollectorCoordinator.cs
+++ /dev/null
@@ -1,621 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using AutopilotMonitor.Agent.V2.Core.Configuration;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
-{
- ///
- /// Manages collector lifecycle: start/stop, idle detection, restart after activity,
- /// max lifetime timer, gather rules, and analyzers.
- ///
- public class CollectorCoordinator : IDisposable
- {
- private readonly AgentConfiguration _configuration;
- private readonly AgentLogger _logger;
- private readonly string _agentVersion;
- private readonly Action _emitEvent;
- private readonly Action> _emitTraceEvent;
- private readonly BackendApiClient _apiClient;
- private readonly EventSpool _spool;
- private readonly RemoteConfigService _remoteConfigService;
- private readonly Func _isTerminalEventSeen;
- private readonly string _previousExitType;
- private readonly DateTime? _lastBootTimeUtc;
- private readonly DateTime _agentStartTimeUtc;
-
- // Core event collectors (always on)
- private EspAndHelloTracker _espAndHelloTracker;
- private LogReplayService _logReplay;
-
- // Optional collectors (toggled via remote config)
- private DeliveryOptimizationCollector _deliveryOptimizationCollector;
- private StallProbeCollector _stallProbeCollector;
-
- // Periodic collectors + idle management
- private PeriodicCollectorManager _periodicManager;
-
- // Smart enrollment tracking
- private EnrollmentTracker _enrollmentTracker;
-
- // Gather rules
- private GatherRuleExecutor _gatherRuleExecutor;
-
- // Desktop arrival / IME watcher / Network change
- private DesktopArrivalDetector _desktopArrivalDetector;
- private AadJoinWatcher _aadJoinWatcher;
- private ImeProcessWatcher _imeProcessWatcher;
- private NetworkChangeDetector _networkChangeDetector;
-
- // Analyzers
- private AnalyzerManager _analyzerManager;
-
- // Max lifetime safety net
- private Timer _maxLifetimeTimer;
-
- public CollectorCoordinator(
- AgentConfiguration configuration,
- AgentLogger logger,
- string agentVersion,
- Action emitEvent,
- Action> emitTraceEvent,
- BackendApiClient apiClient,
- EventSpool spool,
- RemoteConfigService remoteConfigService,
- Func isTerminalEventSeen,
- string previousExitType,
- DateTime? lastBootTimeUtc,
- DateTime agentStartTimeUtc)
- {
- _configuration = configuration;
- _logger = logger;
- _agentVersion = agentVersion;
- _emitEvent = emitEvent;
- _emitTraceEvent = emitTraceEvent;
- _apiClient = apiClient;
- _spool = spool;
- _remoteConfigService = remoteConfigService;
- _isTerminalEventSeen = isTerminalEventSeen;
- _previousExitType = previousExitType;
- _lastBootTimeUtc = lastBootTimeUtc;
- _agentStartTimeUtc = agentStartTimeUtc;
- }
-
- ///
- /// Whether periodic collectors are currently paused due to idle timeout.
- ///
- public bool CollectorsIdleStopped => _periodicManager?.CollectorsIdleStopped ?? false;
-
- ///
- /// Called from EmitEvent when a non-periodic event is received.
- /// Updates idle tracking, resets stall probes, and restarts paused collectors.
- ///
- public void OnRealEventReceived()
- {
- _periodicManager?.OnRealEventReceived();
- }
-
- ///
- /// Cancels the max lifetime timer. Called when a terminal event is seen.
- ///
- public void CancelMaxLifetimeTimer()
- {
- _maxLifetimeTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- }
-
- ///
- /// Notifies the gather rule executor of a phase change.
- ///
- public void OnPhaseChanged(EnrollmentPhase phase)
- {
- try { _gatherRuleExecutor?.OnPhaseChanged(phase); }
- catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnPhaseChanged failed: {ex.Message}"); }
- }
-
- ///
- /// Notifies the gather rule executor of an event type (for on_event triggers).
- ///
- public void OnEvent(string eventType)
- {
- try { _gatherRuleExecutor?.OnEvent(eventType); }
- catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnEvent('{eventType}') failed: {ex.Message}"); }
- }
-
- ///
- /// Stops just the performance collector (used during WhiteGlove completion).
- ///
- public void StopPerformanceCollectorOnly()
- {
- _periodicManager?.StopPerformanceCollectorOnly();
- }
-
- ///
- /// Notifies the enrollment tracker that desktop has arrived.
- ///
- public void NotifyDesktopArrived()
- {
- _enrollmentTracker?.NotifyDesktopArrived();
- }
-
- /// Backend validator verdict cached when RegisterSession completes before the
- /// EnrollmentTracker is instantiated. Applied to the tracker in StartEventCollectors().
- private ValidatorType _pendingBackendValidator = ValidatorType.Unknown;
-
- ///
- /// Forwards the backend's authoritative validator verdict to the enrollment tracker
- /// so it can reconcile with its registry-based enrollment-type detection.
- /// If the tracker does not yet exist (RegisterSession runs before tracker start),
- /// the verdict is cached and applied when the tracker is created.
- ///
- public void ReconcileWithBackendValidator(ValidatorType validatedBy)
- {
- if (_enrollmentTracker != null)
- {
- _enrollmentTracker.ReconcileWithBackendValidator(validatedBy);
- }
- else
- {
- _pendingBackendValidator = validatedBy;
- }
- }
-
- ///
- /// Returns true for events generated by periodic timers (not real enrollment activity).
- ///
- public static bool IsPeriodicEvent(string eventType)
- {
- return PeriodicCollectorManager.IsPeriodicEvent(eventType);
- }
-
- public void StartEventCollectors()
- {
- _logger.Info("Starting event collectors");
-
- try
- {
- var helloTimeout = _remoteConfigService?.CurrentConfig?.Collectors?.HelloWaitTimeoutSeconds
- ?? _configuration.HelloWaitTimeoutSeconds;
- var modernDeploymentEnabled = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentWatcherEnabled ?? true;
- var modernDeploymentLevelMax = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentLogLevelMax ?? 3;
- var modernDeploymentBackfillEnabled = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentBackfillEnabled ?? true;
- var modernDeploymentBackfillLookbackMinutes = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentBackfillLookbackMinutes ?? 30;
- var modernDeploymentHarmlessEventIds = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentHarmlessEventIds;
- _espAndHelloTracker = new EspAndHelloTracker(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger,
- helloTimeout,
- modernDeploymentWatcherEnabled: modernDeploymentEnabled,
- modernDeploymentLogLevelMax: modernDeploymentLevelMax,
- modernDeploymentBackfillEnabled: modernDeploymentBackfillEnabled,
- modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
- stateDirectory: @"%ProgramData%\AutopilotMonitor\State",
- modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds
- );
- _espAndHelloTracker.Start();
-
- if (!string.IsNullOrEmpty(_configuration.ReplayLogDir))
- {
- _logger.Info($"Log replay mode enabled - starting log replay from: {_configuration.ReplayLogDir}");
- var imeLogPatterns = _remoteConfigService?.CurrentConfig?.ImeLogPatterns;
- _logReplay = new LogReplayService(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger,
- _configuration.ReplayLogDir,
- _configuration.ReplaySpeedFactor,
- imeLogPatterns
- );
- _logReplay.Start();
- _logger.Info("Log replay started");
- }
-
- _logger.Info("Core event collectors started successfully");
- }
- catch (Exception ex)
- {
- _logger.Error("Error starting event collectors", ex);
- }
- }
-
- public void StartOptionalCollectors()
- {
- var config = _remoteConfigService?.CurrentConfig;
- var collectors = config?.Collectors;
-
- _logger.Info("Starting collectors based on remote config");
-
- try
- {
- _periodicManager = new PeriodicCollectorManager(
- _configuration, _logger, _agentVersion,
- _emitEvent, _apiClient, _spool, _remoteConfigService);
- _periodicManager.Start();
-
- if (collectors?.StallProbeEnabled != false)
- {
- var probeThresholds = collectors?.StallProbeThresholdsMinutes ?? new[] { 2, 15, 30, 60, 180 };
- var probeTraceIndices = collectors?.StallProbeTraceIndices ?? new[] { 2, 3, 4 };
- var probeSources = collectors?.StallProbeSources ?? new[]
- {
- "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log"
- };
- var stalledAfterIndex = collectors?.SessionStalledAfterProbeIndex ?? 4;
- var harmlessMdmIds = collectors?.ModernDeploymentHarmlessEventIds ?? new[] { 100, 1005 };
- _stallProbeCollector = new StallProbeCollector(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger,
- probeThresholds,
- probeTraceIndices,
- probeSources,
- stalledAfterIndex,
- harmlessMdmIds);
- _logger.Info($"StallProbeCollector enabled: thresholds=[{string.Join(",", probeThresholds)}]min, traces=[{string.Join(",", probeTraceIndices)}], sources={probeSources.Length}");
- }
- else
- {
- _logger.Info("StallProbeCollector disabled via config");
- }
-
- _periodicManager.StallProbeCollector = _stallProbeCollector;
-
- if (string.IsNullOrEmpty(_configuration.ReplayLogDir))
- {
- var imeLogPatterns = config?.ImeLogPatterns;
- var imeMatchLogPath = string.IsNullOrEmpty(_configuration.ImeMatchLogPath)
- ? null
- : Environment.ExpandEnvironmentVariables(_configuration.ImeMatchLogPath);
-
- _enrollmentTracker = new EnrollmentTracker(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger,
- imeLogPatterns,
- _configuration.ImeLogPathOverride,
- imeMatchLogPath: imeMatchLogPath,
- espAndHelloTracker: _espAndHelloTracker,
- isBootstrapMode: _configuration.UseBootstrapTokenAuth,
- sendTraceEvents: _configuration.SendTraceEvents
- );
- _enrollmentTracker.Start();
- _logger.Info("EnrollmentTracker started — listening for IME patterns");
-
- if (_pendingBackendValidator != ValidatorType.Unknown)
- {
- _enrollmentTracker.ReconcileWithBackendValidator(_pendingBackendValidator);
- _pendingBackendValidator = ValidatorType.Unknown;
- }
-
- if (_previousExitType == "reboot_kill" && _lastBootTimeUtc.HasValue)
- {
- _enrollmentTracker.NotifySystemRebootDetected();
- }
-
- if (_stallProbeCollector != null)
- {
- _stallProbeCollector.EspFailureDetected += (sender, failureType) =>
- {
- try
- {
- _enrollmentTracker?.ReportEspFailureFromExternalSource(failureType);
- }
- catch (Exception ex)
- {
- _logger.Error("Failed to forward stall probe failure to EnrollmentTracker", ex);
- }
- };
- }
- }
-
- if (collectors?.EnableDeliveryOptimizationCollector != false && _enrollmentTracker != null)
- {
- var doInterval = collectors?.DeliveryOptimizationIntervalSeconds ?? 3;
- _deliveryOptimizationCollector = new DeliveryOptimizationCollector(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger,
- doInterval,
- () => _enrollmentTracker?.ImeTracker?.PackageStates,
- app => _enrollmentTracker?.ImeTracker?.OnDoTelemetryReceived?.Invoke(app),
- Environment.ExpandEnvironmentVariables(Constants.LogDirectory)
- );
- _deliveryOptimizationCollector.Start();
- _logger.Info($"DeliveryOptimizationCollector started dormant (interval={doInterval}s, wakes on download)");
-
- var existingHandler = _enrollmentTracker.ImeTracker.OnAppStateChanged;
- var doCollector = _deliveryOptimizationCollector;
- _enrollmentTracker.ImeTracker.OnAppStateChanged = (pkg, oldState, newState) =>
- {
- existingHandler?.Invoke(pkg, oldState, newState);
- if (newState >= AppInstallationState.Downloading && newState <= AppInstallationState.Installing)
- doCollector?.WakeUp();
- };
- }
-
- _desktopArrivalDetector = new DesktopArrivalDetector(_logger);
- _desktopArrivalDetector.DesktopArrived += OnDesktopArrived;
- _desktopArrivalDetector.OnTraceEvent = (decision, reason, context) =>
- _emitTraceEvent("DesktopArrivalDetector", decision, reason, context);
- _desktopArrivalDetector.Start();
- _logger.Info("DesktopArrivalDetector started — monitoring for real user desktop");
-
- _aadJoinWatcher = new AadJoinWatcher(_logger);
- _aadJoinWatcher.PlaceholderUserDetected += OnAadPlaceholderUserDetected;
- _aadJoinWatcher.AadUserJoined += OnAadUserJoined;
- _aadJoinWatcher.Start();
- _logger.Info("AadJoinWatcher started — monitoring JoinInfo registry for late AAD user");
-
- _imeProcessWatcher = new ImeProcessWatcher(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger
- );
- _imeProcessWatcher.Start();
- _logger.Info("ImeProcessWatcher started — watching for IntuneManagementExtension.exe exit");
-
- _networkChangeDetector = new NetworkChangeDetector(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger,
- _configuration.ApiBaseUrl
- );
- _networkChangeDetector.Start();
- _logger.Info("NetworkChangeDetector started — monitoring for network changes");
-
- var maxLifetimeMinutes = collectors?.AgentMaxLifetimeMinutes ?? _configuration.AgentMaxLifetimeMinutes;
- if (maxLifetimeMinutes > 0)
- {
- _maxLifetimeTimer = new Timer(
- MaxLifetimeTimerCallback,
- null,
- TimeSpan.FromMinutes(maxLifetimeMinutes),
- Timeout.InfiniteTimeSpan);
- _logger.Info($"Agent max lifetime timer armed: {maxLifetimeMinutes} min");
- }
- else
- {
- _logger.Info("Agent max lifetime timer disabled (0)");
- }
- }
- catch (Exception ex)
- {
- _logger.Error("Error starting optional collectors", ex);
- }
- }
-
- public void StopOptionalCollectors()
- {
- _periodicManager?.Stop();
-
- _maxLifetimeTimer?.Dispose();
- _maxLifetimeTimer = null;
-
- if (_desktopArrivalDetector != null)
- {
- _desktopArrivalDetector.DesktopArrived -= OnDesktopArrived;
- _desktopArrivalDetector.Dispose();
- _desktopArrivalDetector = null;
- }
-
- if (_aadJoinWatcher != null)
- {
- _aadJoinWatcher.PlaceholderUserDetected -= OnAadPlaceholderUserDetected;
- _aadJoinWatcher.AadUserJoined -= OnAadUserJoined;
- _aadJoinWatcher.Dispose();
- _aadJoinWatcher = null;
- }
-
- _imeProcessWatcher?.Dispose();
- _imeProcessWatcher = null;
-
- _networkChangeDetector?.Dispose();
- _networkChangeDetector = null;
-
- _deliveryOptimizationCollector?.Stop();
- _deliveryOptimizationCollector?.Dispose();
- _deliveryOptimizationCollector = null;
-
- _enrollmentTracker?.Stop();
- _enrollmentTracker?.Dispose();
- _enrollmentTracker = null;
- }
-
- public void StopEventCollectors()
- {
- _logger.Info("Stopping event collectors");
-
- try
- {
- _espAndHelloTracker?.Stop();
- _espAndHelloTracker?.Dispose();
-
- _logReplay?.Stop();
- _logReplay?.Dispose();
-
- StopOptionalCollectors();
-
- _gatherRuleExecutor?.Dispose();
- _gatherRuleExecutor = null;
-
- _logger.Info("Event collectors stopped");
- }
- catch (Exception ex)
- {
- _logger.Error("Error stopping event collectors", ex);
- }
- }
-
- public void StartGatherRuleExecutor()
- {
- var config = _remoteConfigService?.CurrentConfig;
- if (config?.GatherRules == null || config.GatherRules.Count == 0)
- {
- _logger.Info("No gather rules to execute");
- return;
- }
-
- try
- {
- _gatherRuleExecutor = new GatherRuleExecutor(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger,
- _configuration.ImeLogPathOverride
- );
- _gatherRuleExecutor.UnrestrictedMode = _configuration.UnrestrictedMode;
- _gatherRuleExecutor.UpdateRules(config.GatherRules);
- _logger.Info($"GatherRuleExecutor started with {config.GatherRules.Count} rule(s)");
- }
- catch (Exception ex)
- {
- _logger.Error("Error starting gather rule executor", ex);
- }
- }
-
- public void InitializeAnalyzers()
- {
- _analyzerManager = new AnalyzerManager(_configuration, _logger, _emitEvent, _remoteConfigService);
- _analyzerManager.Initialize();
- }
-
- public void RunStartupAnalyzers()
- {
- _analyzerManager?.RunStartup();
- }
-
- public void RunShutdownAnalyzers(int? whiteGlovePart = null)
- {
- _analyzerManager?.RunShutdown(whiteGlovePart);
- }
-
- ///
- /// Updates the EnrollmentTracker's trace event setting.
- ///
- public void UpdateSendTraceEvents(bool sendTraceEvents)
- {
- _enrollmentTracker?.UpdateSendTraceEvents(sendTraceEvents);
- }
-
- private void MaxLifetimeTimerCallback(object state)
- {
- if (_isTerminalEventSeen())
- {
- _logger.Info("Max lifetime timer fired but terminal event already seen — ignoring");
- return;
- }
-
- var uptimeMinutes = (DateTime.UtcNow - _agentStartTimeUtc).TotalMinutes;
- _logger.Warning($"Agent max lifetime expired after {uptimeMinutes:F0} minutes — emitting enrollment_failed");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "enrollment_failed",
- Severity = EventSeverity.Error,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Complete,
- Message = $"Agent max lifetime expired ({uptimeMinutes:F0} min) — enrollment did not complete in time",
- Data = new Dictionary
- {
- { "failureType", "agent_timeout" },
- { "failureSource", "max_lifetime_timer" },
- { "agentUptimeMinutes", Math.Round(uptimeMinutes, 1) }
- },
- ImmediateUpload = true
- });
- }
-
- private void OnDesktopArrived(object sender, EventArgs e)
- {
- if (_isTerminalEventSeen())
- {
- _logger.Debug("Desktop arrived but terminal event already seen — ignoring");
- return;
- }
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "desktop_arrived",
- Severity = EventSeverity.Info,
- Source = "DesktopArrivalDetector",
- Phase = EnrollmentPhase.Unknown,
- Message = "User desktop detected (explorer.exe under real user)",
- ImmediateUpload = true
- });
-
- _enrollmentTracker?.NotifyDesktopArrived();
- }
-
- public void Dispose()
- {
- _periodicManager?.Dispose();
- _maxLifetimeTimer?.Dispose();
- _espAndHelloTracker?.Dispose();
- _logReplay?.Dispose();
- _deliveryOptimizationCollector?.Dispose();
- _enrollmentTracker?.Dispose();
- _networkChangeDetector?.Dispose();
- _gatherRuleExecutor?.Dispose();
- if (_desktopArrivalDetector != null)
- {
- _desktopArrivalDetector.DesktopArrived -= OnDesktopArrived;
- _desktopArrivalDetector.Dispose();
- }
- if (_aadJoinWatcher != null)
- {
- _aadJoinWatcher.PlaceholderUserDetected -= OnAadPlaceholderUserDetected;
- _aadJoinWatcher.AadUserJoined -= OnAadUserJoined;
- _aadJoinWatcher.Dispose();
- }
- _imeProcessWatcher?.Dispose();
- }
-
- private void OnAadPlaceholderUserDetected(object sender, AadPlaceholderUserDetectedEventArgs e)
- {
- try
- {
- _enrollmentTracker?.NotifyAadPlaceholderUserDetected(e.UserEmail);
- }
- catch (Exception ex)
- {
- _logger.Error("CollectorCoordinator: OnAadPlaceholderUserDetected failed", ex);
- }
- }
-
- private void OnAadUserJoined(object sender, AadUserJoinedEventArgs e)
- {
- try
- {
- _enrollmentTracker?.NotifyAadUserJoinedLate(e.UserEmail);
- }
- catch (Exception ex)
- {
- _logger.Error("CollectorCoordinator: OnAadUserJoined failed", ex);
- }
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs
index 20cdaf90..197b3c2b 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs
@@ -11,10 +11,8 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/MonitoringService.cs
deleted file mode 100644
index 74c26136..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/MonitoringService.cs
+++ /dev/null
@@ -1,1282 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.IO;
-using System.Security.Cryptography;
-using System.Threading;
-using System.Threading.Tasks;
-using AutopilotMonitor.Agent.V2.Core.Configuration;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
-{
- ///
- /// Thin coordinator for the Autopilot Monitor Agent. Delegates to:
- /// — upload, retry, auth-failure circuit breaker.
- /// — collector lifecycle, idle detection, analyzers.
- /// — terminal event shutdown sequences.
- ///
- public class MonitoringService : IDisposable
- {
- private readonly AgentConfiguration _configuration;
- private readonly AgentLogger _logger;
- private readonly string _agentVersion;
- private readonly EventSpool _spool;
- private readonly BackendApiClient _apiClient;
- private readonly ManualResetEventSlim _completionEvent = new(false);
- private long _eventSequence; // Initialized from persistence + spool ceiling in constructor
- private readonly SessionPersistence _sessionPersistence;
-
- // Composed orchestrators (extracted from partial classes)
- private EventUploadOrchestrator _uploadOrchestrator;
- private CollectorCoordinator _collectorCoordinator;
- private EnrollmentCompletionHandler _completionHandler;
- private EnrollmentPhase? _lastPhase = null;
-
-
- // Remote config
- private RemoteConfigService _remoteConfigService;
-
- // Cleanup/self-destruct
- private readonly CleanupService _cleanupService;
-
- // Diagnostics package upload
- private readonly DiagnosticsPackageService _diagnosticsService;
-
- private readonly DateTime _agentStartTimeUtc = DateTime.UtcNow;
- private bool _enrollmentTerminalEventSeen;
- private bool _isWhiteGlovePart2;
-
- // Admin override detected during session registration (agent restart after admin action)
- private string _pendingAdminAction;
-
- // Previous exit classification (crash detection)
- private readonly string _previousExitType; // "clean" | "exception_crash" | "hard_kill" | "reboot_kill" | "first_run"
- private readonly string _previousCrashException; // exception type name (only for exception_crash)
- private readonly DateTime? _lastBootTimeUtc; // boot time from Event Log (only for reboot_kill)
- private long _persistedSequenceAtStartup; // for detecting spool ceiling recovery
-
- private EmergencyReporter _emergencyReporter;
- private DistressReporter _distressReporter;
-
- // UnrestrictedMode audit: tracks whether the first config apply has happened
- private bool _isFirstConfigApply = true;
- private int? _deferredSecurityAuditConfigVersion; // deferred until after agent_started
-
- // Runtime self-update trigger (hash mismatch recovery)
- // Set by the agent host (Program.cs) to inject the SelfUpdater implementation since
- // .Core cannot reference the .Agent exe project. Once-per-process guard via Interlocked.
- private static int _integrityUpdateAttempted; // 0 = not yet, 1 = attempted
- ///
- /// Callback invoked by the runtime hash-mismatch path to trigger a forced self-update.
- /// Host wires this to SelfUpdater.CheckAndApplyUpdateAsync(..., forceUpdate: true,
- /// triggerReason: "runtime_hash_mismatch", downloadTimeoutMsOverride: 60_000).
- /// Receives the backend-provided ZIP SHA-256 hash so the updater can use it as the
- /// trusted-channel integrity source.
- ///
- ///
- /// Host-provided runtime self-update trigger. Parameters: (backend ZIP hash, allowDowngrade).
- /// The hash seeds 's trusted-channel verification; the flag decides
- /// whether a strictly older version advertised by the backend may replace the running binary.
- ///
- public Func RuntimeSelfUpdateTriggerAsync { get; set; }
-
- public MonitoringService(AgentConfiguration configuration, AgentLogger logger, string agentVersion,
- string previousExitType = "first_run", string previousCrashException = null,
- DateTime? lastBootTimeUtc = null)
- {
- _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- _agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion;
- _previousExitType = previousExitType ?? "first_run";
- _previousCrashException = previousCrashException;
- _lastBootTimeUtc = lastBootTimeUtc;
-
- if (!_configuration.IsValid())
- {
- throw new InvalidOperationException("Invalid agent configuration");
- }
-
- _spool = new EventSpool(_configuration.SpoolDirectory);
-
- // DistressReporter is created BEFORE BackendApiClient so it's available even if cert loading fails.
- // It uses its own plain HttpClient (no cert, no mTLS) for the pre-auth distress endpoint.
- var hwInfo = Security.HardwareInfo.GetHardwareInfo(_logger);
- _distressReporter = new DistressReporter(
- _configuration.ApiBaseUrl, _configuration.TenantId,
- hwInfo.Manufacturer, hwInfo.Model, hwInfo.SerialNumber,
- _agentVersion, _logger);
-
- _apiClient = new BackendApiClient(_configuration.ApiBaseUrl, _configuration, _logger, _agentVersion);
- _emergencyReporter = new EmergencyReporter(_apiClient, _configuration.SessionId, _configuration.TenantId, agentVersion, _logger);
-
- // Detect cert-missing at construction time and send distress signal
- if (_configuration.UseClientCertAuth && _apiClient.ClientCertificate == null)
- {
- _ = _distressReporter.TrySendAsync(
- DistressErrorType.AuthCertificateMissing,
- "MDM certificate not found in LocalMachine or CurrentUser store");
- }
- _cleanupService = new CleanupService(_configuration, _logger);
- _diagnosticsService = new DiagnosticsPackageService(_configuration, _logger, _apiClient);
-
- // Initialize sequence from persistence, with spool-ceiling crash recovery
- var dataDirectory = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
- _sessionPersistence = new SessionPersistence(dataDirectory);
- _eventSequence = _sessionPersistence.LoadSequence();
- _persistedSequenceAtStartup = _eventSequence;
- var spoolMax = _spool.GetMaxSequence();
- if (spoolMax > _eventSequence)
- {
- _logger.Info($"Spool ceiling ({spoolMax}) > persisted sequence ({_eventSequence}), advancing");
- _eventSequence = spoolMax;
- }
- _logger.Info($"Event sequence initialized at {_eventSequence}");
-
- // Create upload orchestrator (timers start when Start() is called)
- _uploadOrchestrator = new EventUploadOrchestrator(
- _configuration, _logger, _spool, _apiClient,
- _emergencyReporter, _distressReporter, _cleanupService,
- EmitEvent, EmitShutdownEvent,
- () => _enrollmentTerminalEventSeen);
-
- // Create enrollment completion handler
- _completionHandler = new EnrollmentCompletionHandler(
- _configuration, _logger, _agentVersion,
- EmitEvent, EmitShutdownEvent,
- () => _uploadOrchestrator.UploadEventsAsync(),
- () => _uploadOrchestrator.StopTimers(),
- _cleanupService, _diagnosticsService, _sessionPersistence, _spool);
-
- // Subscribe to spool events for batched upload
- _spool.EventsAvailable += _uploadOrchestrator.OnEventsAvailable;
-
- // Wire server-action dispatcher. Handlers delegate to existing services:
- // rotate_config → RemoteConfigService.FetchConfigAsync()
- // request_diagnostics → DiagnosticsPackageService.CreateAndUploadAsync(best-effort)
- // terminate_session → HandleTerminateSessionAsync (optional grace period + upload + exit)
- var dispatcher = new ServerActionDispatcher(
- _configuration,
- _logger,
- rotateConfigAsync: async () =>
- {
- try
- {
- var cfg = await _remoteConfigService.FetchConfigAsync();
- return cfg != null;
- }
- catch (Exception ex)
- {
- _logger.Warning($"rotate_config fetch failed: {ex.Message}");
- return false;
- }
- },
- uploadDiagnosticsAsync: async (suffix) =>
- {
- try
- {
- return await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix);
- }
- catch (Exception ex)
- {
- _logger.Warning($"request_diagnostics upload failed: {ex.Message}");
- return new DiagnosticsUploadResult { ErrorCode = "exception: " + ex.Message };
- }
- },
- onTerminateRequested: HandleTerminateSessionAsync,
- emitEvent: EmitEvent);
-
- _uploadOrchestrator.SetServerActionDispatcher(dispatcher);
-
- _logger.Info("MonitoringService initialized");
- }
-
- ///
- /// Unified shutdown handler for ALL server-initiated terminations: KO-rule terminate_session,
- /// legacy DeviceKillSignal (synthesized), and legacy AdminAction (synthesized).
- ///
- /// Self-destruct policy: the agent always respects .
- /// In production that's true, so every terminal path removes the scheduled task + agent files.
- /// Debug/dev builds may flip it off; a forceSelfDestruct param in the action overrides
- /// the local setting for security-driven kills (CVE response, rogue-device containment).
- ///
- /// Parameters honored from :
- /// gracePeriodSeconds — 0-300, default 30. Time to keep collecting before shutdown.
- /// uploadDiagnostics — "true"/"false". Best-effort diagnostics upload pre-shutdown.
- /// adminOutcome — "Succeeded"/"Failed". Emits a synthetic enrollment_* event
- /// instead of the generic agent_shutdown event (preserves the
- /// session timeline's terminal-event semantics).
- /// forceSelfDestruct — "true". Overrides local config to force self-destruct (kill).
- /// origin — free-form tag carried through for observability.
- ///
- /// Idempotent: repeated deliveries of the same termination intent are no-ops after the first.
- ///
- private async Task HandleTerminateSessionAsync(ServerAction action)
- {
- if (_enrollmentTerminalEventSeen)
- {
- string originTag = "-";
- if (action.Params != null && action.Params.TryGetValue("origin", out var o) && !string.IsNullOrEmpty(o))
- originTag = o;
- _logger.Info($"terminate_session (origin={originTag}) received but session already terminal — skipping");
- return;
- }
-
- _enrollmentTerminalEventSeen = true;
-
- // --- Parse params ---
- var graceSeconds = 30;
- var wantDiagnostics = false;
- var forceSelfDestruct = false;
- string adminOutcome = null;
- string origin = "server_action";
-
- if (action.Params != null)
- {
- if (action.Params.TryGetValue("gracePeriodSeconds", out var graceStr)
- && int.TryParse(graceStr, out var parsedGrace)
- && parsedGrace >= 0 && parsedGrace <= 300)
- {
- graceSeconds = parsedGrace;
- }
- if (action.Params.TryGetValue("uploadDiagnostics", out var diagStr))
- wantDiagnostics = string.Equals(diagStr, "true", StringComparison.OrdinalIgnoreCase);
- if (action.Params.TryGetValue("forceSelfDestruct", out var forceStr))
- forceSelfDestruct = string.Equals(forceStr, "true", StringComparison.OrdinalIgnoreCase);
- if (action.Params.TryGetValue("adminOutcome", out var outcomeStr))
- adminOutcome = outcomeStr;
- if (action.Params.TryGetValue("origin", out var originStr) && !string.IsNullOrEmpty(originStr))
- origin = originStr;
- }
-
- _logger.Warning($"=== SERVER-REQUESTED TERMINATION origin={origin} reason='{action.Reason}' rule={action.RuleId ?? "-"} grace={graceSeconds}s upload={wantDiagnostics} force={forceSelfDestruct} outcome={adminOutcome ?? "-"} ===");
-
- // --- Grace period: keep collectors running so late events get flushed ---
- if (graceSeconds > 0)
- await Task.Delay(TimeSpan.FromSeconds(graceSeconds)).ConfigureAwait(false);
-
- _uploadOrchestrator.StopTimers();
-
- // --- Best-effort diagnostics upload (only if requested AND tenant has SAS configured) ---
- if (wantDiagnostics)
- {
- try
- {
- await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: adminOutcome == "Succeeded", fileNameSuffix: "terminated-by-server");
- }
- catch (Exception ex)
- {
- _logger.Warning($"terminate_session: diagnostics upload failed — {ex.Message}");
- }
- }
-
- // --- Emit the terminal event appropriate for the origin ---
- if (!string.IsNullOrEmpty(adminOutcome))
- {
- // Admin portal outcome: preserve the session timeline's terminal-event semantics
- // by emitting enrollment_complete / enrollment_failed instead of a generic shutdown.
- var succeeded = string.Equals(adminOutcome, "Succeeded", StringComparison.OrdinalIgnoreCase);
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = succeeded ? "enrollment_complete" : "enrollment_failed",
- Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning,
- Source = "AdminOverride",
- Phase = EnrollmentPhase.Complete,
- Message = $"Session {adminOutcome.ToLower()} by administrator — cleanup initiated",
- Timestamp = DateTime.UtcNow,
- Data = new Dictionary
- {
- { "adminAction", adminOutcome },
- { "origin", origin }
- }
- });
- }
- else
- {
- EmitShutdownEvent(
- "server_terminated",
- $"Agent terminated by server action (rule={action.RuleId ?? "-"}): {action.Reason}",
- new Dictionary
- {
- { "actionType", action.Type },
- { "ruleId", action.RuleId ?? string.Empty },
- { "gracePeriodSeconds", graceSeconds },
- { "uploadDiagnostics", wantDiagnostics },
- { "origin", origin },
- { "forceSelfDestruct", forceSelfDestruct }
- });
- }
-
- // --- Final flush of the spool ---
- try
- {
- await _uploadOrchestrator.UploadEventsAsync();
- }
- catch (Exception ex)
- {
- _logger.Warning($"terminate_session: final flush failed — {ex.Message}");
- }
-
- // --- Self-destruct policy: config rules, forceSelfDestruct overrides ---
- // No zombies in Prod: SelfDestructOnComplete defaults to true, so every terminal path
- // removes the scheduled task + agent files. Dev builds that flip it to false still get
- // process exit; a security-driven kill overrides even that.
- if (forceSelfDestruct)
- {
- _logger.Warning("forceSelfDestruct=true — overriding local SelfDestructOnComplete setting");
- _configuration.SelfDestructOnComplete = true;
- }
-
- if (_configuration.SelfDestructOnComplete)
- {
- try
- {
- _cleanupService.ExecuteSelfDestruct();
- }
- catch (Exception ex)
- {
- _logger.Error("ExecuteSelfDestruct threw — falling through to process exit", ex);
- }
- }
- else
- {
- _logger.Warning("SelfDestructOnComplete=false — agent is being kept alive for debugging (not recommended in production)");
- }
-
- _completionEvent.Set();
- }
-
- ///
- /// Starts the monitoring service
- ///
- public void Start()
- {
- _logger.Info("Starting monitoring service");
-
- // Fetch remote config (collector toggles + gather rules) from backend
- FetchRemoteConfig();
-
- // Create collector coordinator (needs RemoteConfigService from FetchRemoteConfig)
- _collectorCoordinator = new CollectorCoordinator(
- _configuration, _logger, _agentVersion,
- EmitEvent, EmitTraceEvent,
- _apiClient, _spool, _remoteConfigService,
- () => _enrollmentTerminalEventSeen,
- _previousExitType, _lastBootTimeUtc, _agentStartTimeUtc);
-
- // Register session with backend — must succeed before collectors start
- var registrationSucceeded = RegisterSessionAsync().GetAwaiter().GetResult();
-
- if (!registrationSucceeded)
- {
- _logger.Error("=== SESSION REGISTRATION FAILED — collectors will NOT start to prevent orphaned events ===");
- return;
- }
-
- // Check if admin already terminated this session before we restarted
- if (!string.IsNullOrEmpty(_pendingAdminAction))
- {
- var succeeded = string.Equals(_pendingAdminAction, "Succeeded", StringComparison.OrdinalIgnoreCase);
- _logger.Warning($"=== ADMIN OVERRIDE on startup: Session already marked as {_pendingAdminAction} by administrator — running cleanup only ===");
-
- // Start spool watcher briefly so the terminal event gets spooled for upload
- _spool.StartWatching();
-
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = succeeded ? "enrollment_complete" : "enrollment_failed",
- Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning,
- Source = "AdminOverride",
- Phase = EnrollmentPhase.Complete,
- Message = $"Session {_pendingAdminAction.ToLower()} by administrator (detected on restart) — cleanup initiated",
- Timestamp = DateTime.UtcNow,
- Data = new Dictionary
- {
- { "adminAction", _pendingAdminAction }
- }
- });
- // EmitEvent triggers HandleEnrollmentComplete which handles cleanup + exit
- return;
- }
-
- // Start FileSystemWatcher for efficient event detection
- _spool.StartWatching();
- _uploadOrchestrator.Start();
- _logger.Info("FileSystemWatcher started for efficient event upload");
-
- // Emit agent_started event with startup context so the portal shows how the agent was launched
- var spoolCeilingTriggered = _eventSequence > _persistedSequenceAtStartup;
- var startupData = new Dictionary
- {
- { "agentVersion", _agentVersion },
- { "commandLineArgs", _configuration.CommandLineArgs ?? "(none)" },
- { "isBootstrapSession", _configuration.UseBootstrapTokenAuth },
- { "awaitEnrollment", _configuration.AwaitEnrollment },
- { "selfDestructOnComplete", _configuration.SelfDestructOnComplete },
- { "certAuth", _configuration.UseClientCertAuth },
- { "agentMaxLifetimeMinutes", _configuration.AgentMaxLifetimeMinutes },
- { "diagnosticsUploadMode", _configuration.DiagnosticsUploadMode ?? "Off" },
- { "previousExitType", _previousExitType },
- { "unsentSpoolEvents", _spool.GetCount() },
- { "spoolCeilingRecovery", spoolCeilingTriggered }
- };
- if (_previousCrashException != null)
- startupData["previousCrashException"] = _previousCrashException;
-
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "agent_started",
- Severity = EventSeverity.Info,
- Source = "Agent",
- Phase = EnrollmentPhase.Start,
- Message = "Autopilot Monitor Agent started",
- Data = startupData,
- ImmediateUpload = true
- });
-
- // If previous exit was caused by a system reboot, emit a timeline event for visibility
- if (_previousExitType == "reboot_kill" && _lastBootTimeUtc.HasValue)
- {
- var timeSinceBoot = DateTime.UtcNow - _lastBootTimeUtc.Value;
- var bootDisplay = timeSinceBoot.TotalSeconds < 120
- ? $"{(int)timeSinceBoot.TotalSeconds}s"
- : $"{(int)timeSinceBoot.TotalMinutes}min";
-
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "system_reboot_detected",
- Severity = EventSeverity.Info,
- Source = "Agent",
- // Phase intentionally left as Unknown — see EnrollmentEvent.Phase docs.
- // Only phase-transition events set Phase; this is an informational event.
- Message = $"System rebooted {bootDisplay} ago (boot: {_lastBootTimeUtc.Value:HH:mm:ss} UTC) — previous agent terminated by reboot",
- Data = new Dictionary
- {
- { "bootTimeUtc", _lastBootTimeUtc.Value.ToString("O") },
- { "secondsSinceBoot", (int)timeSinceBoot.TotalSeconds },
- { "previousExitType", "reboot_kill" }
- },
- ImmediateUpload = true
- });
- }
-
- // Consume self-update markers (updated / skipped / checked) left by SelfUpdater on the
- // previous startup and emit a single agent_version_check event. Dedup suppresses
- // repeated up_to_date events for the same latestVersion within the same session.
- EmitVersionCheckEventFromMarkers();
-
- // Emit deferred security_audit (initial UnrestrictedMode) now that agent_started has its sequence
- if (_deferredSecurityAuditConfigVersion.HasValue)
- {
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "security_audit",
- Severity = EventSeverity.Info,
- Source = "MonitoringService",
- Message = "UnrestrictedMode enabled (initial config)",
- Data = new Dictionary
- {
- ["unrestrictedMode"] = true,
- ["source"] = "initial_config",
- ["configVersion"] = _deferredSecurityAuditConfigVersion.Value
- },
- ImmediateUpload = true
- });
- _deferredSecurityAuditConfigVersion = null;
- }
-
- // WhiteGlove Part 2 detection: if the previous boot completed pre-provisioning,
- // emit a whiteglove_resumed event so the backend transitions Pending → InProgress.
- if (_sessionPersistence.IsWhiteGloveResume())
- {
- _isWhiteGlovePart2 = true;
- _logger.Info("WhiteGlove Part 2 detected — emitting whiteglove_resumed event");
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "whiteglove_resumed",
- Severity = EventSeverity.Info,
- Source = "Agent",
- Phase = EnrollmentPhase.Start,
- Message = "WhiteGlove Part 2 — user enrollment started after pre-provisioning",
- ImmediateUpload = true
- });
- _sessionPersistence.ClearWhiteGloveComplete();
- _sessionPersistence.ResetSessionCreatedAt();
- }
-
- // Collect and emit device geo-location asynchronously — HTTP call, not on critical path
- if (_configuration.EnableGeoLocation)
- {
- Task.Run(EmitGeoLocationEvent);
- }
-
- // NTP time check — diagnostic, not critical path
- Task.Run(EmitNtpTimeCheckEvent);
-
- // Start event collectors (HelloCollector + optional based on remote config)
- _collectorCoordinator.StartEventCollectors();
-
- // Start optional collectors based on remote config (PerformanceCollector)
- _collectorCoordinator.StartOptionalCollectors();
-
- // Start gather rule executor
- _collectorCoordinator.StartGatherRuleExecutor();
-
- // Initialize and run startup analyzers (security/configuration checks)
- _collectorCoordinator.InitializeAnalyzers();
- _collectorCoordinator.RunStartupAnalyzers();
-
- _logger.Info("Monitoring service started");
- }
-
- private void EmitGeoLocationEvent()
- {
- try
- {
- var attempt = GeoLocationService.GetLocationAsync(_logger).Result;
- if (attempt.Location != null)
- {
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "device_location",
- Severity = EventSeverity.Info,
- Source = "Network",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Device location: {attempt.Location.City}, {attempt.Location.Region}, {attempt.Location.Country} (via {attempt.Location.Source})",
- Data = attempt.Location.ToDictionary(),
- ImmediateUpload = true
- });
- }
- else
- {
- // All providers failed — send trace event with error details for analysis
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "agent_trace",
- Severity = EventSeverity.Warning,
- Source = "Network",
- Phase = EnrollmentPhase.Unknown,
- Message = "Geo-location lookup failed: all providers unreachable",
- Data = new Dictionary
- {
- { "decision", "geo_location_failed" },
- { "reason", "All geo-location providers failed after retry" },
- { "primaryError", attempt.PrimaryError ?? "unknown" },
- { "primaryRetryError", attempt.PrimaryRetryError ?? "unknown" },
- { "fallbackError", attempt.FallbackError ?? "unknown" },
- { "primaryProvider", "ipinfo.io" },
- { "fallbackProvider", "ifconfig.co" }
- }
- });
- }
-
- // Auto-set timezone if enabled and IANA timezone available from geolocation
- if (_configuration.EnableTimezoneAutoSet && !string.IsNullOrEmpty(attempt.Location?.Timezone))
- {
- try
- {
- var tzResult = TimezoneService.TrySetTimezone(attempt.Location.Timezone, _logger);
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "timezone_auto_set",
- Severity = tzResult.Success ? EventSeverity.Info : EventSeverity.Warning,
- Source = "Network",
- Phase = EnrollmentPhase.Unknown,
- Message = tzResult.Success
- ? $"Timezone set to {tzResult.WindowsTimezoneId} (from {tzResult.IanaTimezone})"
- : $"Timezone auto-set failed: {tzResult.Error}",
- Data = new Dictionary
- {
- { "ianaTimezone", tzResult.IanaTimezone ?? "" },
- { "windowsTimezoneId", tzResult.WindowsTimezoneId ?? "unknown" },
- { "previousTimezone", tzResult.PreviousTimezone ?? "unknown" },
- { "success", tzResult.Success },
- { "error", tzResult.Error ?? "" }
- }
- });
- }
- catch (Exception tzEx)
- {
- _logger.Warning($"Timezone auto-set failed: {tzEx.Message}");
- }
- }
- }
- catch (Exception ex)
- {
- _logger.Warning($"Failed to collect geo-location: {ex.Message}");
- }
- }
-
- private void EmitNtpTimeCheckEvent()
- {
- try
- {
- var ntpServer = string.IsNullOrEmpty(_configuration.NtpServer) ? "time.windows.com" : _configuration.NtpServer;
- var result = NtpTimeCheckService.CheckTime(ntpServer, _logger);
-
- if (result.Success)
- {
- var severity = Math.Abs(result.OffsetSeconds) > 60
- ? EventSeverity.Warning
- : EventSeverity.Info;
-
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "ntp_time_check",
- Severity = severity,
- Source = "Network",
- Phase = EnrollmentPhase.Unknown,
- Message = $"NTP time check: offset {result.OffsetSeconds:F2}s from {ntpServer}",
- Data = new Dictionary
- {
- { "ntpServer", ntpServer },
- { "offsetSeconds", result.OffsetSeconds },
- { "ntpTimeUtc", result.NtpTime?.ToString("o") ?? "" },
- { "localTimeUtc", result.LocalTime?.ToString("o") ?? "" }
- }
- });
- }
- else
- {
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "ntp_time_check",
- Severity = EventSeverity.Warning,
- Source = "Network",
- Phase = EnrollmentPhase.Unknown,
- Message = $"NTP time check failed: {result.Error}",
- Data = new Dictionary
- {
- { "ntpServer", ntpServer },
- { "error", result.Error ?? "unknown" }
- }
- });
- }
- }
- catch (Exception ex)
- {
- _logger.Warning($"NTP time check failed: {ex.Message}");
- }
- }
-
-
- ///
- /// Fetches remote configuration from the backend API
- ///
- private void FetchRemoteConfig()
- {
- try
- {
- _remoteConfigService = new RemoteConfigService(_apiClient, _configuration.TenantId, _logger, _emergencyReporter, _distressReporter);
- _remoteConfigService.FetchConfigAsync().Wait(TimeSpan.FromSeconds(15));
- ApplyRuntimeSettingsFromRemoteConfig();
- VerifyAgentBinaryIntegrity();
- }
- catch (Exception ex)
- {
- _logger.Warning($"Failed to fetch remote config (using defaults): {ex.Message}");
- }
- }
-
- ///
- /// Post-config integrity check: verifies that the running agent binary matches
- /// the SHA-256 hash provided by the backend (LatestAgentExeSha256).
- /// Closes the trust gap where the self-update at startup only had the version.json hash
- /// (same blob storage origin as the ZIP — single point of compromise).
- ///
- private void VerifyAgentBinaryIntegrity()
- {
- try
- {
- var expectedHash = _remoteConfigService?.CurrentConfig?.LatestAgentExeSha256;
- if (string.IsNullOrWhiteSpace(expectedHash))
- {
- _logger.Debug("Post-config integrity check: no backend EXE hash available — skipping");
- return;
- }
-
- var exePath = Process.GetCurrentProcess().MainModule?.FileName;
- if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath))
- {
- _logger.Warning("Post-config integrity check: could not determine agent exe path");
- return;
- }
-
- string actualHash;
- using (var sha256 = SHA256.Create())
- using (var stream = File.OpenRead(exePath))
- {
- var hashBytes = sha256.ComputeHash(stream);
- actualHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
- }
-
- if (string.Equals(actualHash, expectedHash.ToLowerInvariant(), StringComparison.Ordinal))
- {
- _logger.Info($"Post-config integrity check: SHA-256 verified OK ({actualHash.Substring(0, 12)}...)");
- }
- else
- {
- _logger.Error($"Post-config integrity check: SHA-256 MISMATCH — expected={expectedHash.Substring(0, 12)}..., actual={actualHash.Substring(0, 12)}...");
-
- _ = _emergencyReporter?.TrySendAsync(
- AgentErrorType.IntegrityCheckFailed,
- $"Binary hash mismatch: expected={expectedHash.Substring(0, 12)}..., actual={actualHash.Substring(0, 12)}...");
-
- // Trigger a forced self-update on hash mismatch (once per process).
- // Runs as fire-and-forget on a background task so monitoring keeps running —
- // on success, SelfUpdater calls Environment.Exit via RestartAgent;
- // on failure we log and continue with the stale binary until next restart.
- TriggerSelfUpdateOnMismatch();
- }
- }
- catch (Exception ex)
- {
- _logger.Warning($"Post-config integrity check failed: {ex.Message}");
- }
- }
-
- ///
- /// Invokes the host-provided delegate on a
- /// background task. Guarded by _integrityUpdateAttempted so we only try once
- /// per agent process (prevents download loops if the update repeatedly fails).
- ///
- private void TriggerSelfUpdateOnMismatch()
- {
- if (Interlocked.Exchange(ref _integrityUpdateAttempted, 1) == 1)
- {
- _logger.Info("Self-update: already attempted in this process — skipping");
- return;
- }
-
- if (RuntimeSelfUpdateTriggerAsync == null)
- {
- _logger.Warning("Self-update: no RuntimeSelfUpdateTriggerAsync wired by host — cannot recover from hash mismatch");
- return;
- }
-
- // Hand the ZIP hash from the current config to the updater as trusted-channel source.
- // Null is acceptable — SelfUpdater falls back to the version.json hash.
- var zipHash = _remoteConfigService?.CurrentConfig?.LatestAgentSha256;
- // Forward-only gate: admin must opt-in per tenant for a downgrade to apply.
- var allowDowngrade = _remoteConfigService?.CurrentConfig?.AllowAgentDowngrade ?? false;
-
- Task.Run(async () =>
- {
- try
- {
- _logger.Info($"Self-update: triggering forced update due to runtime hash mismatch (allowDowngrade={allowDowngrade})");
- await RuntimeSelfUpdateTriggerAsync(zipHash, allowDowngrade).ConfigureAwait(false);
- _logger.Warning("Self-update: returned without restart — update did not apply, agent continues with stale binary");
- }
- catch (Exception ex)
- {
- _logger.Error("Self-update: unexpected failure during forced update", ex);
- }
- });
- }
-
- private void ApplyRuntimeSettingsFromRemoteConfig()
- {
- var config = _remoteConfigService?.CurrentConfig;
- if (config == null) return;
-
- _configuration.UploadIntervalSeconds = config.UploadIntervalSeconds;
- _configuration.SelfDestructOnComplete = config.SelfDestructOnComplete;
- _configuration.KeepLogFile = config.KeepLogFile;
- _configuration.EnableGeoLocation = config.EnableGeoLocation;
-
- // ImeMatchLog: use default path when enabled
- _configuration.ImeMatchLogPath = config.EnableImeMatchLog
- ? Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath)
- : null;
-
- _configuration.MaxAuthFailures = config.MaxAuthFailures;
- _configuration.AuthFailureTimeoutMinutes = config.AuthFailureTimeoutMinutes;
- _configuration.RebootOnComplete = config.RebootOnComplete;
- _configuration.RebootDelaySeconds = config.RebootDelaySeconds;
- _configuration.MaxBatchSize = config.MaxBatchSize;
- _configuration.DiagnosticsUploadEnabled = config.DiagnosticsUploadEnabled;
- _configuration.DiagnosticsUploadMode = config.DiagnosticsUploadMode;
- _configuration.DiagnosticsLogPaths = config.DiagnosticsLogPaths ?? new System.Collections.Generic.List();
-
- // Apply log level from remote config
- if (Enum.TryParse(config.LogLevel, ignoreCase: true, out var remoteLogLevel))
- {
- var oldLogLevel = _configuration.LogLevel;
- _configuration.LogLevel = remoteLogLevel;
- _logger.SetLogLevel(remoteLogLevel);
- if (oldLogLevel != remoteLogLevel)
- _logger.Info($"Log level changed: {oldLogLevel} -> {remoteLogLevel}");
- }
-
- _configuration.ShowEnrollmentSummary = config.ShowEnrollmentSummary;
- _configuration.EnrollmentSummaryTimeoutSeconds = config.EnrollmentSummaryTimeoutSeconds;
- _configuration.EnrollmentSummaryBrandingImageUrl = config.EnrollmentSummaryBrandingImageUrl;
- _configuration.EnrollmentSummaryLaunchRetrySeconds = config.EnrollmentSummaryLaunchRetrySeconds;
- _configuration.NtpServer = config.NtpServer;
- _configuration.EnableTimezoneAutoSet = config.EnableTimezoneAutoSet;
- _configuration.SendTraceEvents = config.SendTraceEvents;
- AuditUnrestrictedModeChange(config);
- _collectorCoordinator?.UpdateSendTraceEvents(config.SendTraceEvents);
-
- _logger.Info("Applied runtime settings from remote config");
- _logger.Info($" uploadIntervalSeconds={_configuration.UploadIntervalSeconds}, selfDestructOnComplete={_configuration.SelfDestructOnComplete}, keepLogFile={_configuration.KeepLogFile}");
- _logger.Info($" enableGeoLocation={_configuration.EnableGeoLocation}, imeMatchLogPath={_configuration.ImeMatchLogPath ?? "(disabled)"}");
- _logger.Info($" maxAuthFailures={_configuration.MaxAuthFailures}, authFailureTimeoutMinutes={_configuration.AuthFailureTimeoutMinutes}");
- _logger.Info($" logLevel={_configuration.LogLevel}, rebootOnComplete={_configuration.RebootOnComplete}, maxBatchSize={_configuration.MaxBatchSize}");
- _logger.Info($" showEnrollmentSummary={_configuration.ShowEnrollmentSummary}, summaryTimeoutSeconds={_configuration.EnrollmentSummaryTimeoutSeconds}");
- _logger.Info($" ntpServer={_configuration.NtpServer}, enableTimezoneAutoSet={_configuration.EnableTimezoneAutoSet}");
- }
-
- private void AuditUnrestrictedModeChange(AgentConfigResponse config)
- {
- var previous = _configuration.UnrestrictedMode;
- _configuration.UnrestrictedMode = config.UnrestrictedMode;
-
- if (_isFirstConfigApply)
- {
- _isFirstConfigApply = false;
- if (config.UnrestrictedMode)
- {
- // Defer initial security_audit until after agent_started so sequence numbers are correct
- _logger.Info("UnrestrictedMode enabled via tenant configuration (initial config) — audit deferred until after agent_started");
- _deferredSecurityAuditConfigVersion = config.ConfigVersion;
- }
- }
- else if (config.UnrestrictedMode != previous)
- {
- if (config.UnrestrictedMode)
- {
- // Runtime activation — potential manipulation, emit Critical
- _logger.Warning($"SECURITY: UnrestrictedMode changed to TRUE during runtime (configVersion={config.ConfigVersion})");
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- EventType = "security_audit",
- Severity = EventSeverity.Critical,
- Source = "MonitoringService",
- Message = "UnrestrictedMode activated during runtime — potential config manipulation",
- Data = new Dictionary
- {
- ["unrestrictedMode"] = true,
- ["previousValue"] = false,
- ["source"] = "config_refresh",
- ["configVersion"] = config.ConfigVersion
- },
- ImmediateUpload = true
- });
- }
- else
- {
- _logger.Info("UnrestrictedMode disabled during runtime");
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- EventType = "security_audit",
- Severity = EventSeverity.Info,
- Source = "MonitoringService",
- Message = "UnrestrictedMode deactivated during runtime",
- Data = new Dictionary
- {
- ["unrestrictedMode"] = false,
- ["previousValue"] = true,
- ["source"] = "config_refresh",
- ["configVersion"] = config.ConfigVersion
- },
- ImmediateUpload = true
- });
- }
- }
- }
-
- ///
- /// Consumes any self-update markers left by SelfUpdater and emits a single
- /// agent_version_check event. Three marker variants map to the outcomes:
- ///
- /// - self-update-info.json → outcome=updated
- /// - self-update-skipped.json → outcome=skipped or check_failed
- /// - self-update-checked.json → outcome=up_to_date (subject to session-scoped dedup)
- ///
- /// Best-effort — failures are logged but never block startup. See
- /// for the pure logic + dedup rules.
- ///
- private void EmitVersionCheckEventFromMarkers()
- {
- var result = VersionCheckEventBuilder.TryBuild(
- _configuration.SessionId,
- _configuration.TenantId,
- _agentStartTimeUtc);
-
- if (!string.IsNullOrEmpty(result.ParseError))
- {
- _logger.Warning($"Could not process version-check marker: {result.ParseError}");
- return;
- }
-
- if (result.Deduped)
- {
- _logger.Verbose($"agent_version_check skipped (dedup: up_to_date, same session, same latestVersion)");
- return;
- }
-
- if (result.Event != null)
- {
- _logger.Info($"Agent version check: outcome={result.Outcome}");
- EmitEvent(result.Event);
- }
- }
-
- // ──────────────────────────────────────────────────────────────────
- // Event emission (central nerve — all collectors route through here)
- // ──────────────────────────────────────────────────────────────────
-
- public void EmitEvent(EnrollmentEvent evt)
- {
- evt.Sequence = Interlocked.Increment(ref _eventSequence);
- _spool.Add(evt);
- if (evt.Severity <= EventSeverity.Debug)
- _logger.Verbose($"Event emitted: {evt.EventType} - {evt.Message}");
- else
- _logger.Info($"Event emitted: {evt.EventType} - {evt.Message}");
-
- // Track real enrollment activity for idle timeout
- if (!string.IsNullOrEmpty(evt.EventType) && !CollectorCoordinator.IsPeriodicEvent(evt.EventType))
- {
- _collectorCoordinator?.OnRealEventReceived();
- }
-
- // Persist sequence periodically (every 50 events) + always on critical events
- if (evt.Sequence % 50 == 0 ||
- evt.EventType == "whiteglove_complete" ||
- evt.EventType == "enrollment_complete" ||
- evt.EventType == "enrollment_failed")
- {
- _sessionPersistence.SaveSequence(evt.Sequence);
- }
-
- // Cancel max lifetime timer on terminal events
- if (evt.EventType == "whiteglove_complete" ||
- evt.EventType == "enrollment_complete" ||
- evt.EventType == "enrollment_failed")
- {
- _enrollmentTerminalEventSeen = true;
- _collectorCoordinator?.CancelMaxLifetimeTimer();
- }
-
- // Track phase transitions for logging and gather rule notifications
- if (evt.Phase != _lastPhase)
- {
- if (evt.Phase == EnrollmentPhase.Unknown)
- _logger.Debug($"Phase transition: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}");
- else
- _logger.Info($"Phase transition: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}");
- _lastPhase = evt.Phase;
-
- _collectorCoordinator?.OnPhaseChanged(evt.Phase);
- }
-
- // Notify gather rule executor of event type (for on_event triggers)
- if (!string.IsNullOrEmpty(evt.EventType))
- {
- _collectorCoordinator?.OnEvent(evt.EventType);
- }
-
- // Check for WhiteGlove completion — agent exits gracefully, session stays open
- if (evt.EventType == "whiteglove_complete")
- {
- _logger.Info("WhiteGlove pre-provisioning complete. Starting graceful shutdown sequence.");
-
- _collectorCoordinator.StopPerformanceCollectorOnly();
- _collectorCoordinator.StopEventCollectors();
- _spool.StopWatching();
- _uploadOrchestrator.StopTimers();
-
- Task.Run(() => _completionHandler.HandleWhiteGloveComplete(
- () => _collectorCoordinator.StopEventCollectors(),
- part => _collectorCoordinator.RunShutdownAnalyzers(part),
- () => Interlocked.Read(ref _eventSequence)));
-
- return;
- }
-
- // Check for enrollment completion events
- if (evt.EventType == "enrollment_complete" || evt.EventType == "enrollment_failed")
- {
- var enrollmentSucceeded = evt.EventType == "enrollment_complete";
- _logger.Info($"Enrollment completion detected: {evt.EventType}");
-
- _completionHandler.DeleteSessionId();
-
- _logger.Info("Stopping all collectors after enrollment completion...");
- _collectorCoordinator.StopEventCollectors();
- _uploadOrchestrator.StopTimers();
-
- Task.Run(async () =>
- {
- try
- {
- await _uploadOrchestrator.UploadEventsAsync();
- _logger.Info("Final event upload after enrollment completion done");
- }
- catch (Exception ex)
- {
- _logger.Warning($"Final upload after enrollment completion failed: {ex.Message}");
- }
- });
-
- Task.Run(() => _completionHandler.HandleEnrollmentComplete(
- enrollmentSucceeded, _isWhiteGlovePart2,
- () => _collectorCoordinator.StopEventCollectors(),
- part => _collectorCoordinator.RunShutdownAnalyzers(part)));
- return;
- }
-
- // Immediate upload when explicitly requested by the emitter, or as a safety net for
- // unhandled errors. All other events batch via the debounce timer.
- if (evt.ImmediateUpload || evt.Severity >= EventSeverity.Error)
- {
- _logger.Info($"Triggering immediate upload for {evt.EventType} (bypassing debounce)");
- Task.Run(() => _uploadOrchestrator.UploadEventsAsync());
- }
- }
-
- private void EmitTraceEvent(string source, string decision, string reason, Dictionary context = null)
- {
- _logger.Trace($"{source}: {decision} — {reason}");
-
- if (!_configuration.SendTraceEvents)
- return;
-
- var data = new Dictionary
- {
- { "decision", decision },
- { "reason", reason }
- };
- if (context != null)
- {
- foreach (var kvp in context)
- data[kvp.Key] = kvp.Value;
- }
-
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "agent_trace",
- Severity = EventSeverity.Trace,
- Source = source,
- Phase = EnrollmentPhase.Unknown,
- Message = $"{decision}: {reason}",
- Data = data
- });
- }
-
- private void EmitShutdownEvent(string reason, string message, Dictionary extraData = null)
- {
- var data = new Dictionary
- {
- { "reason", reason },
- { "agentVersion", _agentVersion },
- { "uptimeMinutes", Math.Round((DateTime.UtcNow - _agentStartTimeUtc).TotalMinutes, 1) }
- };
- if (extraData != null)
- {
- foreach (var kvp in extraData)
- data[kvp.Key] = kvp.Value;
- }
-
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "agent_shutdown",
- Severity = EventSeverity.Info,
- Source = "Agent",
- Phase = _lastPhase ?? EnrollmentPhase.Unknown,
- Message = message,
- Data = data
- });
- }
-
- // ──────────────────────────────────────────────────────────────────
- // Session registration
- // ──────────────────────────────────────────────────────────────────
-
- ///
- /// Registers the session with the backend. Returns true on success, false on final failure.
- /// On failure, collectors MUST NOT be started to prevent orphaned events.
- ///
- private async Task RegisterSessionAsync()
- {
- const int maxAttempts = 5;
-
- var registration = new SessionRegistration
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- SerialNumber = DeviceInfoProvider.GetSerialNumber(),
- Manufacturer = DeviceInfoProvider.GetManufacturer(),
- Model = DeviceInfoProvider.GetModel(),
- DeviceName = Environment.MachineName,
- OsName = DeviceInfoProvider.GetOsName(),
- OsBuild = DeviceInfoProvider.GetOsBuild(),
- OsDisplayVersion = DeviceInfoProvider.GetOsDisplayVersion(),
- OsEdition = DeviceInfoProvider.GetOsEdition(),
- OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name,
- StartedAt = DateTime.UtcNow,
- AgentVersion = _agentVersion,
- EnrollmentType = EnrollmentTracker.DetectEnrollmentTypeStatic(),
- IsHybridJoin = EnrollmentTracker.DetectHybridJoinStatic(),
- IsUserDriven = true
- };
-
- for (int attempt = 1; attempt <= maxAttempts; attempt++)
- {
- try
- {
- _logger.Info($"Registering session with backend (attempt {attempt}/{maxAttempts})");
-
- var response = await _apiClient.RegisterSessionAsync(registration);
-
- if (response.Success)
- {
- _logger.Info($"Session registered successfully: {response.SessionId}");
-
- // Reconcile registry-detected enrollment type with the backend's authoritative
- // validator verdict. Mismatches emit an enrollment_type_mismatch warning event
- // and live-switch the flow handler on the EnrollmentTracker.
- try
- {
- _collectorCoordinator?.ReconcileWithBackendValidator(response.ValidatedBy);
- }
- catch (Exception reconcileEx)
- {
- _logger.Warning($"ReconcileWithBackendValidator failed (non-fatal): {reconcileEx.Message}");
- }
-
- if (!string.IsNullOrEmpty(response.AdminAction))
- {
- _logger.Warning($"Session already marked as {response.AdminAction} by administrator — will run cleanup after startup");
- _pendingAdminAction = response.AdminAction;
- }
-
- await Security.BootstrapConfigCleanup.TryDeleteIfCertReadyAsync(
- _configuration,
- _logger,
- _agentVersion).ConfigureAwait(false);
-
- return true;
- }
-
- _logger.Warning($"Session registration failed: {response.Message}");
- }
- catch (BackendAuthException ex)
- {
- _logger.Error($"Session registration authentication failed: {ex.Message}");
- _uploadOrchestrator.HandleAuthFailure(ex.StatusCode);
- return false;
- }
- catch (Exception ex)
- {
- _logger.Error($"Failed to register session (attempt {attempt}/{maxAttempts})", ex);
-
- if (attempt == maxAttempts)
- {
- _ = _emergencyReporter.TrySendAsync(
- AgentErrorType.RegisterSessionFailed,
- ex.Message);
- return false;
- }
- }
-
- var delaySeconds = (int)Math.Pow(2, attempt);
- _logger.Info($"Retrying session registration in {delaySeconds}s");
- await Task.Delay(delaySeconds * 1000);
- }
-
- return false;
- }
-
- // ──────────────────────────────────────────────────────────────────
- // Lifecycle (Stop, WaitForCompletion, TriggerCleanup, Dispose)
- // ──────────────────────────────────────────────────────────────────
-
- public void Stop()
- {
- _logger.Info("Stopping monitoring service");
-
- _spool.StopWatching();
- _collectorCoordinator.StopEventCollectors();
- _collectorCoordinator.RunShutdownAnalyzers();
-
- EmitShutdownEvent("manual_stop", "Autopilot Monitor Agent stopped");
-
- _uploadOrchestrator.UploadEventsAsync().Wait(TimeSpan.FromSeconds(10));
-
- _logger.Info("Monitoring service stopped");
- _completionEvent.Set();
- }
-
- public void WaitForCompletion()
- {
- _completionEvent.Wait();
- }
-
- public void TriggerCleanup()
- {
- _logger.Info("TriggerCleanup invoked - executing cleanup without enrollment monitoring");
-
- if (_configuration.SelfDestructOnComplete)
- {
- _cleanupService.ExecuteSelfDestruct();
- }
- else
- {
- _logger.Info("SelfDestructOnComplete is disabled - nothing to clean up");
- }
- }
-
- public void Dispose()
- {
- _uploadOrchestrator?.Dispose();
- _collectorCoordinator?.Dispose();
- _apiClient?.Dispose();
- _spool?.Dispose();
- _remoteConfigService?.Dispose();
- _distressReporter?.Dispose();
- _completionEvent.Dispose();
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PeriodicCollectorManager.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PeriodicCollectorManager.cs
deleted file mode 100644
index 2630396a..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PeriodicCollectorManager.cs
+++ /dev/null
@@ -1,311 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using AutopilotMonitor.Agent.V2.Core.Configuration;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
-{
- ///
- /// Manages periodic collectors (PerformanceCollector, AgentSelfMetricsCollector) and their
- /// idle lifecycle: stops them after inactivity, restarts when real enrollment activity resumes.
- ///
- public class PeriodicCollectorManager : IDisposable
- {
- private readonly AgentConfiguration _configuration;
- private readonly AgentLogger _logger;
- private readonly string _agentVersion;
- private readonly Action _emitEvent;
- private readonly BackendApiClient _apiClient;
- private readonly EventSpool _spool;
- private readonly RemoteConfigService _remoteConfigService;
-
- private PerformanceCollector _performanceCollector;
- private AgentSelfMetricsCollector _agentSelfMetricsCollector;
-
- private DateTime _lastRealEventTime = DateTime.UtcNow;
- private bool _collectorsIdleStopped;
- private int _collectorIdleTimeoutMinutes = 15;
- private Timer _idleCheckTimer;
-
- ///
- /// Optional reference to the StallProbeCollector for resetting/running probes
- /// during idle checks. Set after creation in StartOptionalCollectors.
- ///
- public StallProbeCollector StallProbeCollector { get; set; }
-
- public PeriodicCollectorManager(
- AgentConfiguration configuration,
- AgentLogger logger,
- string agentVersion,
- Action emitEvent,
- BackendApiClient apiClient,
- EventSpool spool,
- RemoteConfigService remoteConfigService)
- {
- _configuration = configuration;
- _logger = logger;
- _agentVersion = agentVersion;
- _emitEvent = emitEvent;
- _apiClient = apiClient;
- _spool = spool;
- _remoteConfigService = remoteConfigService;
- }
-
- ///
- /// Whether periodic collectors are currently paused due to idle timeout.
- ///
- public bool CollectorsIdleStopped => _collectorsIdleStopped;
-
- ///
- /// Starts the performance and metrics collectors and arms the idle check timer.
- /// Called from CollectorCoordinator.StartOptionalCollectors.
- ///
- public void Start()
- {
- var config = _remoteConfigService?.CurrentConfig;
- var collectors = config?.Collectors;
-
- var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
- _performanceCollector = new PerformanceCollector(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger,
- perfInterval
- );
- _performanceCollector.Start();
- _logger.Info($"PerformanceCollector started (interval={perfInterval}s)");
-
- if (collectors?.EnableAgentSelfMetrics != false)
- {
- var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
- _agentSelfMetricsCollector = new AgentSelfMetricsCollector(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _apiClient.NetworkMetrics,
- _spool,
- _logger,
- _agentVersion,
- selfMetricsInterval
- );
- _agentSelfMetricsCollector.Start();
- _logger.Info($"AgentSelfMetricsCollector started (interval={selfMetricsInterval}s)");
- }
-
- _collectorIdleTimeoutMinutes = collectors?.CollectorIdleTimeoutMinutes ?? 15;
- _lastRealEventTime = DateTime.UtcNow;
- _collectorsIdleStopped = false;
-
- _idleCheckTimer = new Timer(
- IdleCheckCallback,
- null,
- TimeSpan.FromSeconds(60),
- TimeSpan.FromSeconds(60)
- );
- if (_collectorIdleTimeoutMinutes > 0)
- _logger.Info($"Collector idle timeout enabled: {_collectorIdleTimeoutMinutes} min");
- else
- _logger.Info("Collector idle timeout disabled (0)");
- }
-
- ///
- /// Called when a non-periodic event is received. Updates idle tracking,
- /// resets stall probes, and restarts paused collectors.
- ///
- public void OnRealEventReceived()
- {
- _lastRealEventTime = DateTime.UtcNow;
-
- try { StallProbeCollector?.ResetProbes(); }
- catch (Exception ex) { _logger.Verbose($"StallProbeCollector.ResetProbes failed: {ex.Message}"); }
-
- if (_collectorsIdleStopped)
- {
- RestartPeriodicCollectors();
- }
- }
-
- ///
- /// Stops just the performance collector (used during WhiteGlove completion).
- ///
- public void StopPerformanceCollectorOnly()
- {
- _performanceCollector?.Stop();
- _performanceCollector?.Dispose();
- _performanceCollector = null;
- }
-
- ///
- /// Stops all periodic collectors and the idle check timer.
- ///
- public void Stop()
- {
- _idleCheckTimer?.Dispose();
- _idleCheckTimer = null;
-
- _performanceCollector?.Stop();
- _performanceCollector?.Dispose();
- _performanceCollector = null;
-
- _agentSelfMetricsCollector?.Stop();
- _agentSelfMetricsCollector?.Dispose();
- _agentSelfMetricsCollector = null;
- }
-
- ///
- /// Returns true for events generated by periodic timers (not real enrollment activity).
- ///
- public static bool IsPeriodicEvent(string eventType)
- {
- return eventType == "performance_snapshot" ||
- eventType == "agent_metrics_snapshot" ||
- eventType == "performance_collector_stopped" ||
- eventType == "agent_metrics_collector_stopped" ||
- eventType == Constants.EventTypes.StallProbeCheck ||
- eventType == Constants.EventTypes.StallProbeResult ||
- eventType == Constants.EventTypes.SessionStalled;
- }
-
- private void IdleCheckCallback(object state)
- {
- var idleMinutes = (DateTime.UtcNow - _lastRealEventTime).TotalMinutes;
-
- try
- {
- StallProbeCollector?.CheckAndRunProbes(idleMinutes);
- }
- catch (Exception ex)
- {
- _logger.Error("StallProbeCollector.CheckAndRunProbes threw", ex);
- }
-
- if (_collectorsIdleStopped)
- return;
-
- if (idleMinutes < _collectorIdleTimeoutMinutes)
- return;
-
- _logger.Info($"Collector idle timeout reached ({_collectorIdleTimeoutMinutes} min, idle for {idleMinutes:F0} min) — stopping periodic collectors");
-
- if (_performanceCollector != null)
- {
- _performanceCollector.Stop();
- _performanceCollector.Dispose();
- _performanceCollector = null;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "performance_collector_stopped",
- Severity = EventSeverity.Info,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Performance collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
- Data = new Dictionary
- {
- { "reason", "idle_timeout" },
- { "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
- { "idleMinutes", Math.Round(idleMinutes, 1) }
- }
- });
- }
-
- if (_agentSelfMetricsCollector != null)
- {
- _agentSelfMetricsCollector.Stop();
- _agentSelfMetricsCollector.Dispose();
- _agentSelfMetricsCollector = null;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "agent_metrics_collector_stopped",
- Severity = EventSeverity.Info,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Unknown,
- Message = $"AgentSelfMetrics collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
- Data = new Dictionary
- {
- { "reason", "idle_timeout" },
- { "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
- { "idleMinutes", Math.Round(idleMinutes, 1) }
- }
- });
- }
-
- _collectorsIdleStopped = true;
- }
-
- private void RestartPeriodicCollectors()
- {
- var config = _remoteConfigService?.CurrentConfig;
- var collectors = config?.Collectors;
-
- _logger.Info("Restarting periodic collectors — new enrollment activity detected after idle stop");
-
- try
- {
- var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
- _performanceCollector = new PerformanceCollector(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _logger,
- perfInterval
- );
- _performanceCollector.Start();
-
- if (collectors?.EnableAgentSelfMetrics != false)
- {
- var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
- _agentSelfMetricsCollector = new AgentSelfMetricsCollector(
- _configuration.SessionId,
- _configuration.TenantId,
- _emitEvent,
- _apiClient.NetworkMetrics,
- _spool,
- _logger,
- _agentVersion,
- selfMetricsInterval
- );
- _agentSelfMetricsCollector.Start();
- }
-
- _lastRealEventTime = DateTime.UtcNow;
- _collectorsIdleStopped = false;
- if (_collectorIdleTimeoutMinutes > 0)
- {
- _idleCheckTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
- }
-
- _logger.Info("Periodic collectors restarted successfully");
- }
- catch (Exception ex)
- {
- _logger.Error("Error restarting periodic collectors", ex);
- }
- }
-
- public void Dispose()
- {
- _idleCheckTimer?.Dispose();
- _performanceCollector?.Dispose();
- _agentSelfMetricsCollector?.Dispose();
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SessionPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SessionPersistence.cs
deleted file mode 100644
index 7de0fa05..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SessionPersistence.cs
+++ /dev/null
@@ -1,279 +0,0 @@
-using System;
-using System.IO;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
-{
- ///
- /// Manages persistent session ID storage to maintain tracking across agent restarts
- ///
- public class SessionPersistence
- {
- ///
- /// Maximum age (hours) of a session.id file before the orphan guard treats it as
- /// stale and discards it. Matches AgentConfiguration.AbsoluteMaxSessionHours.
- /// A session.id younger than this whose session.created is missing is assumed to be
- /// from the current enrollment (file lost during reboot/crash) and will be recovered.
- ///
- internal const double OrphanGuardMaxAgeHours = 48;
-
- private readonly string _sessionFilePath;
- private readonly string _sequenceFilePath;
- private readonly string _whiteGloveMarkerPath;
- private readonly string _sessionCreatedPath;
- private readonly object _lockObject = new object();
-
- ///
- /// Protected constructor for testability (Moq proxy creation).
- ///
- protected SessionPersistence() { }
-
- public SessionPersistence(string dataDirectory)
- {
- if (string.IsNullOrWhiteSpace(dataDirectory))
- {
- throw new ArgumentNullException(nameof(dataDirectory));
- }
-
- if (!Directory.Exists(dataDirectory))
- {
- Directory.CreateDirectory(dataDirectory);
- }
-
- _sessionFilePath = Path.Combine(dataDirectory, "session.id");
- _sequenceFilePath = Path.Combine(dataDirectory, "session.seq");
- _whiteGloveMarkerPath = Path.Combine(dataDirectory, "whiteglove.complete");
- _sessionCreatedPath = Path.Combine(dataDirectory, "session.created");
- }
-
- ///
- /// Loads existing session ID from disk or creates a new one if none exists
- ///
- /// Session ID (GUID string)
- public string LoadOrCreateSessionId()
- {
- lock (_lockObject)
- {
- try
- {
- // Check if session file exists and is valid
- if (File.Exists(_sessionFilePath))
- {
- // Guard: if session.id exists but session.created does not, check
- // whether this is an orphaned session from a previous enrollment
- // (ProgramData survived OS reset/reinstall) or a current session
- // whose session.created was lost during a reboot/crash.
- // Distinguish by file age: recent session.id (< OrphanGuardMaxAgeHours)
- // is likely from the current enrollment → recover by initializing
- // session.created and resuming. Old session.id is a true orphan → discard.
- if (!File.Exists(_sessionCreatedPath))
- {
- var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(_sessionFilePath);
- if (fileAge.TotalHours < OrphanGuardMaxAgeHours)
- {
- // Recent session — recover: initialize session.created and resume
- var sessionId = File.ReadAllText(_sessionFilePath).Trim();
- if (Guid.TryParse(sessionId, out _))
- {
- SaveSessionCreatedAt(DateTime.UtcNow);
- return sessionId;
- }
- }
-
- // Old session or invalid GUID — true orphan, discard
- try { File.Delete(_sessionFilePath); } catch { }
- try { File.Delete(_sequenceFilePath); } catch { }
- }
- else
- {
- var sessionId = File.ReadAllText(_sessionFilePath).Trim();
-
- // Validate it's a valid GUID format
- if (Guid.TryParse(sessionId, out _))
- {
- return sessionId;
- }
- }
- }
-
- // Create new session ID if file doesn't exist or is invalid
- var newSessionId = Guid.NewGuid().ToString();
- File.WriteAllText(_sessionFilePath, newSessionId);
- SaveSessionCreatedAt(DateTime.UtcNow);
- return newSessionId;
- }
- catch (Exception ex)
- {
- // If we can't read/write the session file, generate a new ID in memory
- // Log this error if logger is available
- throw new InvalidOperationException($"Failed to load or create session ID at {_sessionFilePath}", ex);
- }
- }
- }
-
- ///
- /// Deletes the persisted session ID file
- /// This should be called when enrollment is complete
- ///
- public virtual void DeleteSession()
- {
- lock (_lockObject)
- {
- try
- {
- if (File.Exists(_sessionFilePath))
- File.Delete(_sessionFilePath);
- if (File.Exists(_sequenceFilePath))
- File.Delete(_sequenceFilePath);
- if (File.Exists(_whiteGloveMarkerPath))
- File.Delete(_whiteGloveMarkerPath);
- if (File.Exists(_sessionCreatedPath))
- File.Delete(_sessionCreatedPath);
- }
- catch (Exception)
- {
- // Suppress deletion errors - not critical if cleanup fails
- // The file will be overwritten on next start anyway
- }
- }
- }
-
- ///
- /// Checks if a persisted session ID exists
- ///
- /// True if session file exists
- public bool SessionExists()
- {
- lock (_lockObject)
- {
- return File.Exists(_sessionFilePath);
- }
- }
-
- ///
- /// Loads the persisted sequence counter. Returns 0 if no file exists or content is invalid.
- ///
- public long LoadSequence()
- {
- lock (_lockObject)
- {
- try
- {
- if (File.Exists(_sequenceFilePath))
- {
- var content = File.ReadAllText(_sequenceFilePath).Trim();
- if (long.TryParse(content, out var sequence) && sequence >= 0)
- return sequence;
- }
- }
- catch { }
- return 0;
- }
- }
-
- ///
- /// Persists the current sequence counter to disk.
- /// Called periodically and before graceful shutdown (WhiteGlove).
- ///
- public virtual void SaveSequence(long sequence)
- {
- lock (_lockObject)
- {
- try { File.WriteAllText(_sequenceFilePath, sequence.ToString()); }
- catch { } // Non-fatal: worst case, next boot starts with a small gap
- }
- }
-
- ///
- /// Deletes the persisted sequence file.
- /// Called alongside DeleteSession when enrollment completes.
- ///
- public void DeleteSequence()
- {
- lock (_lockObject)
- {
- try { if (File.Exists(_sequenceFilePath)) File.Delete(_sequenceFilePath); }
- catch { }
- }
- }
-
- ///
- /// Persists a marker indicating WhiteGlove Part 1 completed.
- /// On the next boot the agent reads this marker, emits a whiteglove_resumed
- /// event, and clears it — giving the backend an explicit signal to transition
- /// the session from Pending to InProgress for Part 2.
- ///
- public virtual void SaveWhiteGloveComplete()
- {
- lock (_lockObject)
- {
- try { File.WriteAllText(_whiteGloveMarkerPath, "1"); }
- catch { }
- }
- }
-
- ///
- /// Returns true if a WhiteGlove Part 1 marker exists, indicating this boot is Part 2.
- ///
- public bool IsWhiteGloveResume()
- {
- lock (_lockObject)
- {
- return File.Exists(_whiteGloveMarkerPath);
- }
- }
-
- ///
- /// Persists the session creation timestamp (UTC). Called once when a new session is created.
- ///
- public void SaveSessionCreatedAt(DateTime utcTimestamp)
- {
- lock (_lockObject)
- {
- try { File.WriteAllText(_sessionCreatedPath, utcTimestamp.ToString("O")); }
- catch { }
- }
- }
-
- ///
- /// Loads the persisted session creation timestamp. Returns null if file is missing or invalid.
- ///
- public DateTime? LoadSessionCreatedAt()
- {
- lock (_lockObject)
- {
- try
- {
- if (File.Exists(_sessionCreatedPath))
- {
- var content = File.ReadAllText(_sessionCreatedPath).Trim();
- if (DateTime.TryParse(content, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt))
- return dt.ToUniversalTime();
- }
- }
- catch { }
- return null;
- }
- }
-
- ///
- /// Resets the session creation timestamp to now (UTC).
- /// Used on WhiteGlove Part 2 resume to give the user enrollment phase a fresh timer.
- ///
- public void ResetSessionCreatedAt()
- {
- SaveSessionCreatedAt(DateTime.UtcNow);
- }
-
- ///
- /// Removes the WhiteGlove marker after the whiteglove_resumed event has been emitted.
- ///
- public void ClearWhiteGloveComplete()
- {
- lock (_lockObject)
- {
- try { if (File.Exists(_whiteGloveMarkerPath)) File.Delete(_whiteGloveMarkerPath); }
- catch { }
- }
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserProfileResolver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserProfileResolver.cs
index acb8480b..8b440057 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserProfileResolver.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserProfileResolver.cs
@@ -3,10 +3,8 @@ using System.Diagnostics;
using System.IO;
using System.Management;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs
index 57e33b13..0754b8aa 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs
@@ -10,10 +10,8 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs
index 86041752..099ccdb8 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs
@@ -8,10 +8,8 @@ using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs
index 72c359d6..6411c094 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs
@@ -3,10 +3,8 @@ using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs
index d2efbe17..b744b66c 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs
@@ -6,10 +6,8 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs
index 584ed8de..2f05d620 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs
@@ -6,10 +6,8 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
{
@@ -22,7 +20,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
{
private readonly string _agentVersion;
private readonly NetworkMetrics _networkMetrics;
- private readonly EventSpool _spool;
// Previous sample for delta calculations
private TimeSpan _prevCpuTime;
@@ -34,14 +31,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
string tenantId,
Action onEventCollected,
NetworkMetrics networkMetrics,
- EventSpool spool,
AgentLogger logger,
string agentVersion = "unknown",
int intervalSeconds = 60)
: base(sessionId, tenantId, onEventCollected, logger, intervalSeconds)
{
_networkMetrics = networkMetrics ?? throw new ArgumentNullException(nameof(networkMetrics));
- _spool = spool;
_agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion;
}
@@ -106,11 +101,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
}
// --- Spool queue depth ---
- if (_spool != null)
- {
- try { data["spool_queue_depth"] = _spool.GetCount(); }
- catch { }
- }
+ // M4.5.a: removed legacy EventSpool-based queue-depth metric. V2 equivalent
+ // (TelemetrySpool.LastAssignedItemId - LastUploadedItemId) would require Transport
+ // wiring that is out of scope for this collector; deferred to M4.5.b when
+ // DefaultComponentFactory introduces the new telemetry wiring.
// --- Network delta ---
try
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs
index 22febc77..85a0c805 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs
@@ -11,10 +11,8 @@ using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventSpool.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventSpool.cs
deleted file mode 100644
index 01b8dd06..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventSpool.cs
+++ /dev/null
@@ -1,232 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using AutopilotMonitor.Shared.Models;
-using Newtonsoft.Json;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
-{
- ///
- /// Manages offline event storage (spool) for resilient uploads
- ///
- public class EventSpool : IDisposable
- {
- private readonly string _spoolDirectory;
- private readonly object _lockObject = new object();
- private readonly FileSystemWatcher _fileWatcher;
-
- ///
- /// Event raised when new events are added to the spool
- ///
- public event EventHandler EventsAvailable;
-
- ///
- /// Protected constructor for testability (Moq proxy creation).
- ///
- protected EventSpool() { }
-
- public EventSpool(string spoolDirectory)
- {
- _spoolDirectory = spoolDirectory;
-
- if (!Directory.Exists(_spoolDirectory))
- {
- Directory.CreateDirectory(_spoolDirectory);
- }
-
- // Initialize FileSystemWatcher for efficient event detection
- _fileWatcher = new FileSystemWatcher(_spoolDirectory)
- {
- Filter = "event_*.json",
- NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime,
- EnableRaisingEvents = false // Will be enabled when service starts
- };
-
- _fileWatcher.Created += OnFileCreated;
- }
-
- ///
- /// Starts watching for new events
- ///
- public void StartWatching()
- {
- _fileWatcher.EnableRaisingEvents = true;
- }
-
- ///
- /// Stops watching for new events
- ///
- public virtual void StopWatching()
- {
- _fileWatcher.EnableRaisingEvents = false;
- }
-
- private void OnFileCreated(object sender, FileSystemEventArgs e)
- {
- // Raise event to trigger immediate upload
- EventsAvailable?.Invoke(this, EventArgs.Empty);
- }
-
- ///
- /// Adds an event to the spool
- ///
- public void Add(EnrollmentEvent evt)
- {
- lock (_lockObject)
- {
- var fileName = $"event_{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence}.json";
- var filePath = Path.Combine(_spoolDirectory, fileName);
-
- var json = JsonConvert.SerializeObject(evt, Formatting.None);
- File.WriteAllText(filePath, json);
- }
- }
-
- ///
- /// Gets a batch of events from the spool (oldest first, sorted by sequence)
- ///
- public virtual List GetBatch(int maxBatchSize)
- {
- lock (_lockObject)
- {
- var events = new List();
- var files = Directory.GetFiles(_spoolDirectory, "event_*.json")
- .Select(f => new { Path = f, Sequence = ExtractSequenceFromFilename(f) })
- .OrderBy(x => x.Sequence)
- .Take(maxBatchSize)
- .Select(x => x.Path)
- .ToList();
-
- foreach (var file in files)
- {
- try
- {
- var json = File.ReadAllText(file);
- var evt = JsonConvert.DeserializeObject(json);
- if (evt != null)
- {
- events.Add(evt);
- }
- }
- catch
- {
- // Skip corrupted files
- }
- }
-
- return events;
- }
- }
-
- ///
- /// Extracts the sequence number from event filename
- /// Filename format: event_{timestamp}_{sequence}.json
- ///
- private long ExtractSequenceFromFilename(string filePath)
- {
- try
- {
- var fileName = Path.GetFileNameWithoutExtension(filePath);
- var parts = fileName.Split('_');
- if (parts.Length >= 3)
- {
- if (long.TryParse(parts[2], out var sequence))
- return sequence;
- }
- }
- catch
- {
- // Fallback to 0 if parsing fails
- }
- return 0;
- }
-
- ///
- /// Removes events from the spool after successful upload
- ///
- public virtual void RemoveEvents(List events)
- {
- lock (_lockObject)
- {
- foreach (var evt in events)
- {
- // Reconstruct the exact filename used in Add() — no glob, no cross-boot collision
- var fileName = $"event_{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence}.json";
- var filePath = Path.Combine(_spoolDirectory, fileName);
- try
- {
- if (File.Exists(filePath))
- File.Delete(filePath);
- }
- catch
- {
- // Ignore deletion errors — file may already be gone
- }
- }
- }
- }
-
- ///
- /// Gets the count of events in the spool
- ///
- public virtual int GetCount()
- {
- lock (_lockObject)
- {
- return Directory.GetFiles(_spoolDirectory, "event_*.json").Length;
- }
- }
-
- ///
- /// Clears all events from the spool
- ///
- public void Clear()
- {
- lock (_lockObject)
- {
- var files = Directory.GetFiles(_spoolDirectory, "event_*.json");
- foreach (var file in files)
- {
- try
- {
- File.Delete(file);
- }
- catch
- {
- // Ignore deletion errors
- }
- }
- }
- }
-
- ///
- /// Returns the highest sequence number found in spool files, or 0 if empty.
- /// Used at startup for crash recovery: ensures new sequences never collide
- /// with unsent events still in the spool.
- ///
- public long GetMaxSequence()
- {
- lock (_lockObject)
- {
- long max = 0;
- foreach (var file in Directory.GetFiles(_spoolDirectory, "event_*.json"))
- {
- var seq = ExtractSequenceFromFilename(file);
- if (seq > max) max = seq;
- }
- return max;
- }
- }
-
- public void Dispose()
- {
- if (_fileWatcher != null)
- {
- _fileWatcher.EnableRaisingEvents = false;
- _fileWatcher.Created -= OnFileCreated;
- _fileWatcher.Dispose();
- }
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventUploadOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventUploadOrchestrator.cs
deleted file mode 100644
index 125292fa..00000000
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventUploadOrchestrator.cs
+++ /dev/null
@@ -1,381 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using AutopilotMonitor.Agent.V2.Core.Configuration;
-using AutopilotMonitor.Agent.V2.Core.Logging;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
-using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-
-namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
-{
- ///
- /// Manages event upload lifecycle: debouncing, batched upload, auth-failure circuit breaker,
- /// and admin signal detection (kill, block, override) from upload responses.
- ///
- public class EventUploadOrchestrator : IDisposable
- {
- private readonly AgentConfiguration _configuration;
- private readonly AgentLogger _logger;
- private readonly EventSpool _spool;
- private readonly BackendApiClient _apiClient;
- private readonly EmergencyReporter _emergencyReporter;
- private readonly DistressReporter _distressReporter;
- private readonly CleanupService _cleanupService;
- private readonly Action _emitEvent;
- private readonly Action> _emitShutdownEvent;
- private readonly Func _isTerminalEventSeen;
- private ServerActionDispatcher _serverActionDispatcher;
-
- private readonly Timer _uploadTimer;
- private readonly Timer _debounceTimer;
- private readonly object _timerLock = new object();
- private readonly SemaphoreSlim _uploadSemaphore = new SemaphoreSlim(1, 1);
-
- private int _consecutiveAuthFailures;
- private DateTime? _firstAuthFailureTime;
- private int _consecutiveUploadFailures;
-
- public EventUploadOrchestrator(
- AgentConfiguration configuration,
- AgentLogger logger,
- EventSpool spool,
- BackendApiClient apiClient,
- EmergencyReporter emergencyReporter,
- DistressReporter distressReporter,
- CleanupService cleanupService,
- Action emitEvent,
- Action> emitShutdownEvent,
- Func isTerminalEventSeen)
- {
- _configuration = configuration;
- _logger = logger;
- _spool = spool;
- _apiClient = apiClient;
- _emergencyReporter = emergencyReporter;
- _distressReporter = distressReporter;
- _cleanupService = cleanupService;
- _emitEvent = emitEvent;
- _emitShutdownEvent = emitShutdownEvent;
- _isTerminalEventSeen = isTerminalEventSeen;
-
- _debounceTimer = new Timer(
- DebounceTimerCallback,
- null,
- Timeout.Infinite,
- Timeout.Infinite
- );
-
- _uploadTimer = new Timer(
- UploadTimerCallback,
- null,
- Timeout.InfiniteTimeSpan,
- Timeout.InfiniteTimeSpan
- );
- }
-
- ///
- /// Starts the upload and fallback timers. Call after spool watcher is started.
- ///
- public void Start()
- {
- _uploadTimer.Change(
- TimeSpan.FromMinutes(1),
- TimeSpan.FromMinutes(5)
- );
- _logger.Info("EventUploadOrchestrator started (debounce + fallback timers)");
- }
-
- ///
- /// Wires the server-action dispatcher. Called by after the
- /// orchestrator is constructed, once all other services exist. Optional — if null, actions
- /// in ingest responses are simply ignored.
- ///
- public void SetServerActionDispatcher(ServerActionDispatcher dispatcher)
- {
- _serverActionDispatcher = dispatcher;
- }
-
- ///
- /// Stops all upload timers. Called during terminal event processing to prevent
- /// further automatic uploads while the completion handler drives uploads manually.
- ///
- public void StopTimers()
- {
- _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- }
-
- ///
- /// Called when new events are available in the spool (FileSystemWatcher detected new files).
- /// Uses debouncing to batch events before uploading.
- ///
- public void OnEventsAvailable(object sender, EventArgs e)
- {
- _logger.Debug("FileSystemWatcher detected new events, starting/resetting debounce timer");
-
- lock (_timerLock)
- {
- _debounceTimer.Change(
- TimeSpan.FromSeconds(_configuration.UploadIntervalSeconds),
- Timeout.InfiniteTimeSpan
- );
- }
- }
-
- private void DebounceTimerCallback(object state)
- {
- _logger.Debug("Debounce timer expired, uploading batched events");
- Task.Run(() => UploadEventsAsync());
- }
-
- private void UploadTimerCallback(object state)
- {
- Task.Run(() => UploadEventsAsync());
- }
-
- public async Task UploadEventsAsync()
- {
- if (!_uploadSemaphore.Wait(0))
- {
- _logger.Verbose("UploadEventsAsync: skipped — concurrent upload already in flight");
- return;
- }
-
- try
- {
- var events = _spool.GetBatch(_configuration.MaxBatchSize);
-
- if (events.Count == 0)
- {
- _logger.Debug("No events to upload");
- return;
- }
-
- var request = new IngestEventsRequest
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Events = events
- };
-
- var response = await _apiClient.IngestEventsAsync(request);
-
- // DeviceBlocked is intentionally NOT synthesized into a ServerAction: it's a
- // non-terminal quarantine (UnblockAt can auto-resume the session later). Treat it
- // as a straight "stop uploads for now" signal.
- if (response.DeviceBlocked)
- {
- var unblockMsg = response.UnblockAt.HasValue
- ? $" until {response.UnblockAt.Value:yyyy-MM-dd HH:mm:ss} UTC"
- : string.Empty;
- _logger.Warning($"=== DEVICE BLOCKED by administrator{unblockMsg}. Stopping all uploads for this session. ===");
-
- StopTimers();
- return;
- }
-
- // Synthesize legacy kill + admin-override signals into ServerActions so the whole
- // "server told us to stop" surface runs through a single shutdown handler. This
- // removes three divergent code paths (kill / admin / terminate) and guarantees
- // every terminal path respects SelfDestructOnComplete — no more zombie agents
- // after the operator clicks Mark-Failed in the portal.
- List synthesized = null;
-
- if (response.DeviceKillSignal)
- {
- synthesized = new List
- {
- new ServerAction
- {
- Type = ServerActionTypes.TerminateSession,
- Reason = "DeviceKillSignal from administrator",
- QueuedAt = DateTime.UtcNow,
- Params = new Dictionary
- {
- // Kill overrides everything: force self-destruct even if local config says otherwise,
- // and no grace period — the operator decided this device must go NOW.
- { "forceSelfDestruct", "true" },
- { "gracePeriodSeconds", "0" },
- { "origin", "kill_signal" }
- }
- }
- };
- }
- else if (!string.IsNullOrEmpty(response.AdminAction) && !_isTerminalEventSeen())
- {
- // Admin marked the session terminal from the portal (Mark-Failed / Mark-Succeeded).
- // Soft shutdown: config decides whether to self-destruct — but in Prod
- // SelfDestructOnComplete=true, so no zombies.
- synthesized = new List
- {
- new ServerAction
- {
- Type = ServerActionTypes.TerminateSession,
- Reason = $"Admin marked session as {response.AdminAction}",
- QueuedAt = DateTime.UtcNow,
- Params = new Dictionary
- {
- { "adminOutcome", response.AdminAction },
- { "gracePeriodSeconds", "0" },
- { "origin", "admin_action" }
- }
- }
- };
- }
-
- // Merge synthesized signals with real server actions, then dispatch once.
- // Order: synthesized first (they're the highest-priority stop signals).
- if (synthesized != null || (response.Actions != null && response.Actions.Count > 0))
- {
- var batch = synthesized ?? new List();
- if (response.Actions != null) batch.AddRange(response.Actions);
-
- if (_serverActionDispatcher != null)
- {
- try
- {
- await _serverActionDispatcher.DispatchAsync(batch);
- }
- catch (Exception ex)
- {
- _logger.Error($"ServerActionDispatcher.DispatchAsync threw (batch aborted)", ex);
- }
- }
- else if (synthesized != null)
- {
- // Defensive: if a synthesized kill/admin signal arrives before the dispatcher
- // was wired, fall back to a bare-minimum shutdown so we don't leave a zombie.
- _logger.Error("Kill/Admin signal received but no dispatcher wired — forcing exit");
- StopTimers();
- _configuration.SelfDestructOnComplete = true;
- _cleanupService.ExecuteSelfDestruct();
- ExitProcess(1);
- }
- return;
- }
-
- if (response.Success)
- {
- _spool.RemoveEvents(events);
- _logger.Info($"Uploaded {response.EventsProcessed} events");
-
- if (_consecutiveAuthFailures > 0 || _consecutiveUploadFailures > 0)
- _logger.Info($"Upload success — resetting failure counters (auth={_consecutiveAuthFailures}, upload={_consecutiveUploadFailures})");
- _consecutiveAuthFailures = 0;
- _firstAuthFailureTime = null;
- _consecutiveUploadFailures = 0;
- }
- else
- {
- _logger.Warning($"Upload failed: {response.Message}");
- }
- }
- catch (BackendAuthException ex)
- {
- _logger.Error($"Upload authentication failed: {ex.Message}");
- HandleAuthFailure(ex.StatusCode);
- }
- catch (Exception ex)
- {
- _consecutiveUploadFailures++;
- _logger.Error("Error uploading events", ex);
-
- _logger.Info($"Upload failure #{_consecutiveUploadFailures}: {ex.Message}");
-
- if (_consecutiveUploadFailures == EmergencyReporter.ConsecutiveFailureThreshold)
- {
- _ = _emergencyReporter.TrySendAsync(
- AgentErrorType.IngestFailed,
- ex.Message,
- httpStatusCode: null,
- sequenceNumber: 0);
- }
- }
- finally
- {
- _uploadSemaphore.Release();
- }
- }
-
- ///
- /// Handles an authentication failure by incrementing the counter and shutting down
- /// the agent if the configured threshold is reached. Called from upload path and
- /// session registration path.
- ///
- public void HandleAuthFailure(int httpStatusCode = 0)
- {
- _consecutiveAuthFailures++;
-
- if (_firstAuthFailureTime == null)
- _firstAuthFailureTime = DateTime.UtcNow;
-
- if (_consecutiveAuthFailures == 1 && _distressReporter != null)
- {
- var distressType = httpStatusCode == 401
- ? DistressErrorType.AuthCertificateRejected
- : DistressErrorType.DeviceNotRegistered;
-
- _ = _distressReporter.TrySendAsync(distressType,
- $"Backend returned {httpStatusCode} during event upload",
- httpStatusCode: httpStatusCode);
- }
-
- _logger.Warning($"Authentication failure {_consecutiveAuthFailures}" +
- (_configuration.MaxAuthFailures > 0 ? $"/{_configuration.MaxAuthFailures}" : "") +
- $" (first failure at {_firstAuthFailureTime.Value:HH:mm:ss})");
-
- if (_configuration.MaxAuthFailures > 0 && _consecutiveAuthFailures >= _configuration.MaxAuthFailures)
- {
- _logger.Error($"=== AGENT SHUTDOWN: {_consecutiveAuthFailures} consecutive authentication failures (401/403). " +
- "The device is not authorized to send data to Autopilot Monitor. " +
- "Check client certificate and Autopilot device validation in your tenant configuration. ===");
-
- _emitShutdownEvent(
- "auth_failure",
- $"Agent terminated: {_consecutiveAuthFailures} consecutive authentication failures exceeded threshold ({_configuration.MaxAuthFailures})",
- new Dictionary
- {
- { "consecutiveFailures", _consecutiveAuthFailures },
- { "shutdownTrigger", "max_attempts" },
- { "maxAuthFailures", _configuration.MaxAuthFailures }
- });
- ExitProcess(1);
- }
-
- if (_configuration.AuthFailureTimeoutMinutes > 0 && _firstAuthFailureTime.HasValue)
- {
- var elapsed = DateTime.UtcNow - _firstAuthFailureTime.Value;
- if (elapsed.TotalMinutes >= _configuration.AuthFailureTimeoutMinutes)
- {
- _logger.Error($"=== AGENT SHUTDOWN: Authentication failures persisted for {elapsed.TotalMinutes:F0} minutes " +
- $"(timeout: {_configuration.AuthFailureTimeoutMinutes} min). " +
- "The device is not authorized to send data to Autopilot Monitor. " +
- "Check client certificate and Autopilot device validation in your tenant configuration. ===");
-
- _emitShutdownEvent(
- "auth_failure",
- $"Agent terminated: authentication failures persisted for {elapsed.TotalMinutes:F0} minutes (timeout: {_configuration.AuthFailureTimeoutMinutes} min)",
- new Dictionary
- {
- { "consecutiveFailures", _consecutiveAuthFailures },
- { "shutdownTrigger", "timeout" },
- { "authFailureTimeoutMinutes", _configuration.AuthFailureTimeoutMinutes },
- { "elapsedMinutes", Math.Round(elapsed.TotalMinutes, 1) }
- });
- ExitProcess(1);
- }
- }
- }
-
- internal virtual void ExitProcess(int code) => Environment.Exit(code);
-
- public void Dispose()
- {
- _uploadTimer?.Dispose();
- _debounceTimer?.Dispose();
- }
- }
-}