From 8ca845c9cb2248465c2fbc20ab20dc93bfddf9c0 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 20 Apr 2026 22:21:47 +0200 Subject: [PATCH] refactor(v2core): M4.5.a legacy dead-code cleanup (~9360 LoC removed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope: alle Enrollment/Transport/Runtime-Glue-Files aus dem Legacy-M2- Import, die durch DecisionEngine+Reducer (M3) und Transport/Telemetry (M4.1) abgeloest sind und keine aktiven V2-Konsumenten mehr haben (Program.cs ist noch M2-Skeleton, null Test-Refs auf Legacy). Delete: - Monitoring/Runtime/{MonitoringService, SessionPersistence, CollectorCoordinator, PeriodicCollectorManager, AnalyzerManager} - Monitoring/Enrollment/{EnrollmentTracker.*cs (4 partials), EnrollmentStatePersistence} - Monitoring/Enrollment/Flows/ (4 files, Ordner weg) - Monitoring/Enrollment/Completion/ (6 files, Ordner weg) - Monitoring/Enrollment/Ime/{ImeTrackerStatePersistence, LogReplayService} - Monitoring/Transport/{EventSpool, EventUploadOrchestrator} Fix peripherals (Import-Pruning inline): - ImeLogTracker: ImeTrackerStatePersistence-Referenzen entfernt, Load/ Save/DeleteState sind no-op stubs. V2 resumed ueber SignalLog-Replay + SignalAdapter fire-once dedup. - AgentSelfMetricsCollector: EventSpool-Param + spool_queue_depth- Metric entfernt. V2-Equivalent via TelemetrySpool kommt in M4.5.b. - 10 Files: dead using-Imports auf Enrollment.Flows / .Completion entfernt (sed-Batch). Keep (aktive V2-Konsumenten): - BackendApiClient, EmergencyReporter, DistressReporter → RemoteConfigService.FetchConfigAsync() bleibt auf GetAgentConfig bis M5 oder V2-Slim-Client - NetworkMetrics (Utility), ImeLogTracker + Ime/-Support (aktiver V2-Collector) - Peripheral capabilities (per feedback_v2_kernel_only_refactor): DiagnosticsPackageService, CleanupService, PerformanceCollector, DeliveryOptimizationCollector, SelfUpdater, ServerActionDispatcher, RegistryWatcher, UserProfileResolver, UserSessionProcessLauncher, VersionCheckEventBuilder, NetworkChangeDetector, Analyzers Inventory-Greps alle 0 (nur noch README.md + Kommentar-Refs): EnrollmentTracker class, CompletionStateMachine, IEnrollmentFlowHandler, EventSpool, EventUploadOrchestrator, MonitoringService (Code), CollectorCoordinator, PeriodicCollectorManager, AnalyzerManager. V2-Isolation-Grep (using AutopilotMonitor.Agent.Core): 0. Build gruen, Tests: 286/287 (pre-existing Flake SignalIngressTests.Null_observer_is_supported_and_back_pressure_is_silent, M4.5.c-Scope). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Enrollment/Completion/CompletionGuards.cs | 96 - .../Completion/CompletionStateMachine.cs | 820 -------- .../Completion/EnrollmentCompletionHandler.cs | 393 ---- .../Completion/EnrollmentCompletionState.cs | 120 -- .../Completion/WhiteGloveClassifier.cs | 150 -- .../Completion/WhiteGloveSignals.cs | 63 - .../Enrollment/EnrollmentStatePersistence.cs | 151 -- .../EnrollmentTracker.CompletionLogic.cs | 1813 ----------------- .../EnrollmentTracker.Diagnostics.cs | 381 ---- .../EnrollmentTracker.EventHandlers.cs | 768 ------- .../Enrollment/EnrollmentTracker.cs | 814 -------- .../Enrollment/Flows/ClassicAutopilotFlow.cs | 11 - .../Enrollment/Flows/DevicePreparationFlow.cs | 11 - .../Enrollment/Flows/EnrollmentFlowFactory.cs | 21 - .../Flows/IEnrollmentFlowHandler.cs | 30 - .../Enrollment/Ime/ImeLogTracker.cs | 173 +- .../Ime/ImeTrackerStatePersistence.cs | 160 -- .../Enrollment/Ime/LogReplayService.cs | 154 -- .../ProvisioningStatusTracker.cs | 2 - .../Monitoring/Runtime/AnalyzerManager.cs | 139 -- .../Runtime/CollectorCoordinator.cs | 621 ------ .../Runtime/DiagnosticsPackageService.cs | 2 - .../Monitoring/Runtime/MonitoringService.cs | 1282 ------------ .../Runtime/PeriodicCollectorManager.cs | 311 --- .../Monitoring/Runtime/SessionPersistence.cs | 279 --- .../Monitoring/Runtime/UserProfileResolver.cs | 2 - .../DeviceInfo/DeviceInfoCollector.cs | 2 - .../Gather/Collectors/LogParserCollector.cs | 2 - .../Telemetry/Gather/GatherRuleContext.cs | 2 - .../Telemetry/Gather/GatherRuleExecutor.cs | 2 - .../Periodic/AgentSelfMetricsCollector.cs | 14 +- .../Periodic/DeliveryOptimizationCollector.cs | 2 - .../Monitoring/Transport/EventSpool.cs | 232 --- .../Transport/EventUploadOrchestrator.cs | 381 ---- 34 files changed, 20 insertions(+), 9384 deletions(-) delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogReplayService.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/AnalyzerManager.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CollectorCoordinator.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/MonitoringService.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PeriodicCollectorManager.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SessionPersistence.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventSpool.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventUploadOrchestrator.cs 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(); - } - } -}