refactor(v2core): M4.5.a legacy dead-code cleanup (~9360 LoC removed)

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 22:21:47 +02:00
parent aca66a79c0
commit 8ca845c9cb
34 changed files with 20 additions and 9384 deletions
@@ -1,96 +0,0 @@
using System;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// 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.
/// </summary>
internal static class CompletionGuards
{
/// <summary>
/// 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)
/// </summary>
/// <remarks>
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
/// line: helloResolved = _espAndHelloTracker == null || ...IsHelloCompleted || !...IsPolicyConfigured || isDeviceOnly
/// </remarks>
public static bool IsHelloResolved(bool hasTracker, bool isHelloCompleted,
bool isPolicyConfigured, bool isDeviceOnly)
{
return !hasTracker
|| isHelloCompleted
|| !isPolicyConfigured
|| isDeviceOnly;
}
/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
/// line: espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") &amp;&amp; enrollmentType != "v2" &amp;&amp; espEverSeen &amp;&amp; !espFinalExitSeen
/// </remarks>
public static bool IsEspGateBlocking(string source, string enrollmentType,
bool espEverSeen, bool espFinalExitSeen)
{
return (source == "desktop_arrival" || source == "desktop_hello")
&& enrollmentType != "v2"
&& espEverSeen
&& !espFinalExitSeen;
}
/// <summary>
/// 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)
/// </summary>
/// <remarks>
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
/// lines: if (_isHybridJoin &amp;&amp; source == "esp_hello_composite") { ... hybridRebootGateBlocked = !hasImeCompletion &amp;&amp; !agentRestartedAfterEspExit; }
/// </remarks>
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;
}
/// <summary>
/// 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
/// </summary>
/// <remarks>
/// Source: EnrollmentTracker.cs, property IsDeviceOnlyDeployment
/// line: IsSelfDeploying || (_skipUserStatusPage == true &amp;&amp; !_aadJoinedWithUser)
/// </remarks>
public static bool IsDeviceOnlyDeployment(int? autopilotMode, bool? skipUserStatusPage,
bool aadJoinedWithUser)
{
bool isSelfDeploying = autopilotMode == 1;
return isSelfDeploying || (skipUserStatusPage == true && !aadJoinedWithUser);
}
}
}
@@ -1,820 +0,0 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// 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.
/// </summary>
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
// <see cref="WhiteGloveClassifier"/>.
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; }
}
/// <summary>
/// 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.
/// </summary>
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<string> SignalsToRecord { get; set; }
}
/// <summary>
/// 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.
/// </summary>
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; } } }
/// <summary>
/// Reconstructs state from persisted EnrollmentStateData (crash recovery).
/// Must be called before Start() if recovering from a crash.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<string> { "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<string>() };
// 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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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) =====
/// <summary>
/// Checks all three completion guards for a given source.
/// </summary>
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;
}
/// <summary>
/// Completion check with Hello always resolved (caller has just confirmed Hello completed).
/// Only checks ESP gate and hybrid reboot gate.
/// </summary>
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;
}
}
}
@@ -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
{
/// <summary>
/// Handles enrollment terminal events: enrollment_complete, enrollment_failed, and
/// whiteglove_complete shutdown sequences including diagnostics upload, summary dialog,
/// self-destruct, and reboot.
/// </summary>
public class EnrollmentCompletionHandler
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly string _agentVersion;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly Action<string, string, Dictionary<string, object>> _emitShutdownEvent;
private readonly Func<Task> _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<EnrollmentEvent> emitEvent,
Action<string, string, Dictionary<string, object>> emitShutdownEvent,
Func<Task> 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;
}
/// <summary>
/// Handles normal enrollment completion (success or failure).
/// Stops collectors, runs shutdown analyzers, uploads diagnostics, optionally self-destructs.
/// </summary>
public async Task HandleEnrollmentComplete(
bool enrollmentSucceeded,
bool isWhiteGlovePart2,
Action stopCollectors,
Action<int?> 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<string, object>
{
{ "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<string, object>
{
{ "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);
}
}
/// <summary>
/// Graceful shutdown after WhiteGlove (Pre-Provisioning) completes.
/// Does NOT delete the session ID — the session must survive for Part 2.
/// </summary>
public async Task HandleWhiteGloveComplete(
Action stopCollectors,
Action<int?> runShutdownAnalyzers,
Func<long> 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<string, object>
{
{ "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);
}
/// <summary>
/// Deletes the persisted session ID and sequence files.
/// </summary>
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<string, object>
{
{ "uploadMode", mode }
}
});
await _uploadEventsAsync();
var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix);
var success = uploadResult?.Success == true;
var resultData = new Dictionary<string, object>
{
{ "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}");
}
}
}
}
@@ -1,120 +0,0 @@
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// 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.
/// </summary>
internal enum EnrollmentCompletionState
{
/// <summary>
/// Initial state. No completion-relevant signals received.
/// Waiting for: ESP phase event, desktop arrival, or device setup provisioning.
/// </summary>
Idle,
/// <summary>
/// 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.
/// </summary>
EspActive,
/// <summary>
/// ESP exited from AccountSetup (or desktop-arrival backup). espFinalExitSeen=true.
/// Waiting for: Hello resolution, IME user session completion, or safety timeout.
/// </summary>
EspExitedAwaitingCompletion,
/// <summary>
/// IME user session completed but Hello is configured and not yet resolved.
/// isWaitingForHello=true. Safety timer running (420s / 7 min).
/// </summary>
WaitingForHello,
/// <summary>
/// IME user session completed but ESP provisioning categories not yet resolved in registry.
/// isWaitingForEspSettle=true. Settle timer running (30s).
/// </summary>
WaitingForEspSettle,
/// <summary>
/// Desktop arrived but ESP is still active (ESP gate blocking completion).
/// Waiting for: ESP final exit.
/// </summary>
DesktopArrivedEspBlocking,
/// <summary>
/// Desktop arrived, no ESP blocking (ESP exited, never seen, or v2). Hello may be pending.
/// Waiting for: Hello resolution or direct completion.
/// </summary>
DesktopArrivedAwaitingHello,
/// <summary>
/// Device-only deployment detected (Self-Deploying or SkipUserStatusPage=true without AAD user).
/// Waiting for provisioning complete or IME completion. Hello guard bypassed.
/// </summary>
DeviceOnlyAwaitingCompletion,
/// <summary>
/// Device-only ESP exit detected but completion didn't fire immediately (Hello pending).
/// Safety timer running (420s / 7 min).
/// </summary>
DeviceOnlySafetyWait,
/// <summary>
/// Hybrid join: esp_hello_composite blocked by reboot gate.
/// Waiting for: IME user session completion or agent restart confirmation after ESP exit.
/// </summary>
HybridRebootGateBlocked,
/// <summary>
/// Device info not yet collected when a completion signal arrived.
/// Completion source deferred until CollectDeviceInfo finishes (pendingCompletionSource set).
/// </summary>
DeferredForDeviceInfo,
/// <summary>
/// Terminal: enrollment_complete event emitted. No further transitions possible.
/// </summary>
Completed,
/// <summary>
/// Terminal: enrollment_failed event emitted. No further transitions possible.
/// </summary>
Failed,
/// <summary>
/// Terminal: whiteglove_complete event emitted. No further transitions possible.
/// </summary>
WhiteGloveCompleted
}
/// <summary>
/// Extension methods for EnrollmentCompletionState.
/// </summary>
internal static class EnrollmentCompletionStateExtensions
{
/// <summary>
/// Returns true if the state is terminal (Completed, Failed, or WhiteGloveCompleted).
/// No further transitions are allowed from terminal states.
/// </summary>
public static bool IsTerminal(this EnrollmentCompletionState state)
{
return state == EnrollmentCompletionState.Completed
|| state == EnrollmentCompletionState.Failed
|| state == EnrollmentCompletionState.WhiteGloveCompleted;
}
/// <summary>
/// Returns true if the state is a waiting state (actively waiting for a signal or timeout).
/// </summary>
public static bool IsWaiting(this EnrollmentCompletionState state)
{
return state == EnrollmentCompletionState.WaitingForHello
|| state == EnrollmentCompletionState.WaitingForEspSettle
|| state == EnrollmentCompletionState.DeviceOnlySafetyWait
|| state == EnrollmentCompletionState.HybridRebootGateBlocked
|| state == EnrollmentCompletionState.DeferredForDeviceInfo;
}
}
}
@@ -1,150 +0,0 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// Confidence level for a WhiteGlove Part 1 classification.
/// </summary>
internal enum WhiteGloveConfidence
{
/// <summary>Score below <see cref="WhiteGloveClassifier.LowThreshold"/> — definitely not WhiteGlove.</summary>
None,
/// <summary>Score between low and high thresholds — ambiguous. Routed as NOT WhiteGlove (asymmetric-conservative).</summary>
Weak,
/// <summary>Score at or above <see cref="WhiteGloveClassifier.HighThreshold"/> — classify as WhiteGlove Part 1.</summary>
Strong,
}
/// <summary>
/// Result of a <see cref="WhiteGloveClassifier.Classify"/> call. Contains the
/// final routing decision, the numeric score and the contributing factors for
/// observability / tuning.
/// </summary>
internal sealed class WhiteGloveClassification
{
public WhiteGloveConfidence Confidence { get; set; }
public int Score { get; set; }
public List<string> ContributingFactors { get; set; } = new List<string>();
public string Reason { get; set; }
/// <summary>
/// Single Boolean the caller should read to decide whether to route to
/// <c>whiteglove_complete</c> (Pending) instead of the normal completion path.
/// Only true when <see cref="Confidence"/> is <see cref="WhiteGloveConfidence.Strong"/>.
/// </summary>
public bool ShouldRouteToWhiteGlovePart1 { get; set; }
}
/// <summary>
/// Scoring-based WhiteGlove Part 1 classifier — modelled on the backend
/// <c>AnalyzeRule.BaseConfidence + ConfidenceFactors + ConfidenceThreshold</c> pattern.
///
/// Asymmetric-conservative decision rule: only a <see cref="WhiteGloveConfidence.Strong"/>
/// score routes to WhiteGlove. A <see cref="WhiteGloveConfidence.Weak"/> 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
/// <c>enrollment_complete</c> early and the later user sign-in triggers a new session —
/// the user-visible cost of false-negative is much lower than false-positive.
/// </summary>
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<string>();
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),
};
}
}
}
@@ -1,63 +0,0 @@
using System;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// Snapshot of all signals relevant for WhiteGlove (Part 1) classification.
/// Populated under _stateLock in the caller and consumed outside the lock by
/// <see cref="WhiteGloveClassifier.Classify"/> — 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 <see cref="FromContext"/> to keep both
/// decision paths in sync.
/// </summary>
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; }
/// <summary>
/// Builds a signal snapshot from a <see cref="CompletionContext"/>. Used by the
/// CompletionStateMachine so that state-machine decisions use the exact same
/// inputs as the EnrollmentTracker's direct decisions.
/// </summary>
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,
};
}
}
}
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Loads persisted state from disk. Returns null if no state file exists or on error.
/// </summary>
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<EnrollmentStateData>(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;
}
}
/// <summary>
/// Persists tracker state to disk as JSON (atomic write via temp file).
/// </summary>
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}");
}
}
/// <summary>
/// Deletes persisted state file.
/// </summary>
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<string> SignalsSeen { get; set; } = new List<string>();
// ESP provisioning settle wait state (crash recovery)
public bool IsWaitingForEspSettle { get; set; }
public DateTime? WaitingForEspSettleStartedUtc { get; set; }
/// <summary>
/// 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.
/// </summary>
public string CompletionState { get; set; }
}
}
@@ -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
{
/// <summary>
/// Partial: Device info collection at key lifecycle points, app tracking summaries,
/// debug state timer, and final status file writing.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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}");
}
}
/// <summary>
/// Emits app_tracking_summary only if state-breakdown counters changed since last emission.
/// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop).
/// </summary>
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)
});
}
/// <summary>
/// Emits app_tracking_summary unconditionally (for final summary on completion).
/// </summary>
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();
}
/// <summary>
/// Writes an enrollment complete marker to the state directory.
/// This marker is checked on agent restart to handle cleanup retry if scheduled task fails.
/// </summary>
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}");
}
}
/// <summary>
/// Writes a comprehensive final-status.json with all app states, signals, and error summary.
/// Automatically included in diagnostics uploads for post-mortem analysis.
/// </summary>
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<string, string>();
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<string, object>();
// 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<Dictionary<string, object>>();
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<string, int>();
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<string, object>
{
{ "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<string, object>
{
{ "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}");
}
}
}
}
@@ -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
{
/// <summary>
/// Partial: IME log event handlers (ESP phase, app state, script completion, etc.).
/// </summary>
public partial class EnrollmentTracker
{
/// <summary>
/// Wraps _emitEvent and automatically injects the current ImeLogTracker patternId into event Data.
/// Use this for all events emitted from ImeLogTracker callback handlers.
/// </summary>
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<string, object>();
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<string, object>
{
{ "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<string, object> { { "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<string, object> { { "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<string, object> { { "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<string, object> { { "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<string, object> { { "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<string, object> { { "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<string, object>(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<string, object>(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<string, object>
{
["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<string, object> { { "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<string, object> { { "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<string, object>
{
{ "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");
}
/// <summary>
/// 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.
/// </summary>
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<string>(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)
}
}
}
}
@@ -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
{
/// <summary>
/// Central enrollment tracking orchestrator.
/// Collects consolidated device info events at startup and manages ImeLogTracker
/// for smart app installation tracking with strategic event emission.
/// </summary>
public partial class EnrollmentTracker : IDisposable
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly AgentLogger _logger;
private readonly string _imeLogFolder;
private readonly List<ImeLogPattern> _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<string> RecoverableEspFailureTypes = new HashSet<string>(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 <see cref="_signalCorrelatedWhiteGloveTriggered"/> (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;
/// <summary>
/// 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.
/// </summary>
public void NotifySystemRebootDetected()
{
lock (_stateLock)
{
_systemRebootObserved = true;
}
}
/// <summary>
/// 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 <c>enrollment_type_mismatch</c> warning event and
/// switches the active flow handler to the backend's verdict.
///
/// <see cref="ValidatorType.CorporateIdentifier"/> and <see cref="ValidatorType.Bootstrap"/>
/// are treated as type-neutral (no claim about Classic vs DevPrep) and do not trigger a switch.
/// </summary>
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<string, object>
{
{ "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);
/// <summary>
/// Creates a read-only snapshot of current state for the shadow state machine.
/// Must be called OUTSIDE _stateLock (takes its own lock internally).
/// </summary>
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
};
}
}
/// <summary>
/// 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.
/// </summary>
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<string, DateTime> _lastCompletionCheckBySource = new Dictionary<string, DateTime>();
// 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;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<EnrollmentEvent> emitEvent,
AgentLogger logger,
List<ImeLogPattern> 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<ImeLogPattern>();
_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();
}
}
/// <summary>
/// Updates the SendTraceEvents setting from remote config at runtime.
/// </summary>
public void UpdateSendTraceEvents(bool value) => _sendTraceEvents = value;
/// <summary>
/// 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.
/// </summary>
private void EmitTraceEvent(string decision, string reason, Dictionary<string, object> context = null)
{
var data = new Dictionary<string, object>
{
{ "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
/// <summary>
/// Emits a <c>shadow_discrepancy</c> event when the shadow CompletionStateMachine disagrees
/// with the actual <c>_enrollmentCompleteEmitted</c> 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.
/// </summary>
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<string, object>
{
{ "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}");
}
}
/// <summary>
/// Starts the enrollment tracker: collects device info and starts IME log tracking.
/// </summary>
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");
}
/// <summary>
/// Stops the enrollment tracker
/// </summary>
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");
}
/// <summary>
/// Updates IME log patterns (hot-reload from config change)
/// </summary>
public void UpdatePatterns(List<ImeLogPattern> newPatterns)
{
if (newPatterns != null)
{
_logger.Info("EnrollmentTracker: updating IME log patterns (hot-reload)");
_imeLogTracker?.CompilePatterns(newPatterns);
}
}
/// <summary>
/// Access to the ImeLogTracker (for simulator to reference package states)
/// </summary>
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<string, object>
{
{ "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<string, object>
{
{ "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");
}
/// <summary>
/// Collects device info that may change during enrollment (e.g., BitLocker enabled via policy).
/// Called at enrollment complete to capture final state.
/// </summary>
private void CollectDeviceInfoAtEnd()
=> _deviceInfoCollector.CollectAtEnd();
/// <summary>
/// Checks for ConfigMgr (SCCM) co-management client presence on the device.
/// Fire-once: emits <c>configmgr_client_detected</c> event on first detection only.
/// Called at startup, DeviceSetup, and AccountSetup — always via Task.Run to avoid blocking.
/// </summary>
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<string, object>
{
{ "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}");
}
}
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -1,21 +0,0 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
{
public static class EnrollmentFlowFactory
{
/// <summary>
/// Returns the flow handler for the given enrollment type.
/// <see cref="EnrollmentType.Unknown"/> falls back to the Classic flow
/// (safe default — Classic is the long-standing behavior).
/// </summary>
public static IEnrollmentFlowHandler Create(EnrollmentType type) => type switch
{
EnrollmentType.DevicePreparation => new DevicePreparationFlow(),
_ => new ClassicAutopilotFlow()
};
public static IEnrollmentFlowHandler FromWireFormat(string wireFormat)
=> Create(EnrollmentTypeExtensions.FromWireFormat(wireFormat));
}
}
@@ -1,30 +0,0 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
{
/// <summary>
/// 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
/// <c>CollectorCoordinator</c>. When DevPrep-specific trackers land, they will
/// move into the corresponding flow handler.
/// </summary>
public interface IEnrollmentFlowHandler
{
EnrollmentType SupportedType { get; }
/// <summary>
/// True when the flow relies on ESP-phase signals for progress and completion.
/// Classic: true. DevPrep (WDP): false (no ESP exists).
/// </summary>
bool TracksEspPhases { get; }
/// <summary>
/// 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.
/// </summary>
bool AppliesEspGateOnDesktopArrival { get; }
}
}
@@ -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.
/// <summary>
/// Loads persisted state from disk. Called on Start() to restore tracker state
/// after an agent restart, so parsing continues exactly where it left off.
/// </summary>
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<string>(p.DependsOn) : new HashSet<string>(),
(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
}
/// <summary>
/// Persists current tracker state to disk as JSON.
/// Called after each polling cycle when state has changed.
/// </summary>
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<string>(),
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<string, FilePositionData>()
};
// 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
}
/// <summary>
/// Deletes persisted state file. Called on enrollment complete to ensure
/// a fresh state on the next enrollment cycle.
/// </summary>
/// <summary>Kept as public no-op for API compatibility; callers treat it as a hint.</summary>
public void DeleteState()
{
_statePersistence?.Delete();
// no-op
}
#endregion
@@ -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
{
/// <summary>
/// Handles persistence of ImeLogTracker state to disk (JSON serialization).
/// Extracted from ImeLogTracker to separate I/O concerns from log parsing.
/// </summary>
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;
/// <summary>
/// Loads persisted state from disk. Returns null if no state file exists or on error.
/// </summary>
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<ImeTrackerStateData>(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;
}
}
/// <summary>
/// Persists tracker state to disk as JSON (atomic write via temp file).
/// </summary>
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}");
}
}
/// <summary>
/// Deletes persisted state file.
/// </summary>
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<string> SeenAppIds { get; set; }
public List<string> IgnoreList { get; set; }
public string CurrentPackageId { get; set; }
public List<PackageStateData> Packages { get; set; }
public Dictionary<string, FilePositionData> 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<string> 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; }
}
}
@@ -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
{
/// <summary>
/// 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)
/// </summary>
public class LogReplayService : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly string _replayLogDir;
private readonly double _speedFactor;
private readonly List<ImeLogPattern> _imeLogPatterns;
private CancellationTokenSource _cancellationTokenSource;
private Task _replayTask;
private EnrollmentTracker _enrollmentTracker;
private bool _disposed = false;
public LogReplayService(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
string replayLogDir = null,
double speedFactor = 50,
List<ImeLogPattern> 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<ImeLogPattern>();
}
/// <summary>
/// Starts log replay
/// </summary>
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));
}
/// <summary>
/// Stops the log replay
/// </summary>
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;
}
}
}
@@ -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
{
@@ -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
{
/// <summary>
/// Manages agent analyzers: initialization from remote config, startup execution
/// on a background thread, and shutdown execution with optional WhiteGlove part.
/// </summary>
public class AnalyzerManager
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly RemoteConfigService _remoteConfigService;
private readonly List<IAgentAnalyzer> _analyzers = new List<IAgentAnalyzer>();
public AnalyzerManager(
AgentConfiguration configuration,
AgentLogger logger,
Action<EnrollmentEvent> 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<IAgentAnalyzer>(_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);
}
}
}
}
}
@@ -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
{
/// <summary>
/// Manages collector lifecycle: start/stop, idle detection, restart after activity,
/// max lifetime timer, gather rules, and analyzers.
/// </summary>
public class CollectorCoordinator : IDisposable
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly string _agentVersion;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly Action<string, string, string, Dictionary<string, object>> _emitTraceEvent;
private readonly BackendApiClient _apiClient;
private readonly EventSpool _spool;
private readonly RemoteConfigService _remoteConfigService;
private readonly Func<bool> _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<EnrollmentEvent> emitEvent,
Action<string, string, string, Dictionary<string, object>> emitTraceEvent,
BackendApiClient apiClient,
EventSpool spool,
RemoteConfigService remoteConfigService,
Func<bool> 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;
}
/// <summary>
/// Whether periodic collectors are currently paused due to idle timeout.
/// </summary>
public bool CollectorsIdleStopped => _periodicManager?.CollectorsIdleStopped ?? false;
/// <summary>
/// Called from EmitEvent when a non-periodic event is received.
/// Updates idle tracking, resets stall probes, and restarts paused collectors.
/// </summary>
public void OnRealEventReceived()
{
_periodicManager?.OnRealEventReceived();
}
/// <summary>
/// Cancels the max lifetime timer. Called when a terminal event is seen.
/// </summary>
public void CancelMaxLifetimeTimer()
{
_maxLifetimeTimer?.Change(Timeout.Infinite, Timeout.Infinite);
}
/// <summary>
/// Notifies the gather rule executor of a phase change.
/// </summary>
public void OnPhaseChanged(EnrollmentPhase phase)
{
try { _gatherRuleExecutor?.OnPhaseChanged(phase); }
catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnPhaseChanged failed: {ex.Message}"); }
}
/// <summary>
/// Notifies the gather rule executor of an event type (for on_event triggers).
/// </summary>
public void OnEvent(string eventType)
{
try { _gatherRuleExecutor?.OnEvent(eventType); }
catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnEvent('{eventType}') failed: {ex.Message}"); }
}
/// <summary>
/// Stops just the performance collector (used during WhiteGlove completion).
/// </summary>
public void StopPerformanceCollectorOnly()
{
_periodicManager?.StopPerformanceCollectorOnly();
}
/// <summary>
/// Notifies the enrollment tracker that desktop has arrived.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
public void ReconcileWithBackendValidator(ValidatorType validatedBy)
{
if (_enrollmentTracker != null)
{
_enrollmentTracker.ReconcileWithBackendValidator(validatedBy);
}
else
{
_pendingBackendValidator = validatedBy;
}
}
/// <summary>
/// Returns true for events generated by periodic timers (not real enrollment activity).
/// </summary>
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);
}
/// <summary>
/// Updates the EnrollmentTracker's trace event setting.
/// </summary>
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<string, object>
{
{ "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);
}
}
}
}
@@ -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
{
File diff suppressed because it is too large Load Diff
@@ -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
{
/// <summary>
/// Manages periodic collectors (PerformanceCollector, AgentSelfMetricsCollector) and their
/// idle lifecycle: stops them after inactivity, restarts when real enrollment activity resumes.
/// </summary>
public class PeriodicCollectorManager : IDisposable
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly string _agentVersion;
private readonly Action<EnrollmentEvent> _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;
/// <summary>
/// Optional reference to the StallProbeCollector for resetting/running probes
/// during idle checks. Set after creation in StartOptionalCollectors.
/// </summary>
public StallProbeCollector StallProbeCollector { get; set; }
public PeriodicCollectorManager(
AgentConfiguration configuration,
AgentLogger logger,
string agentVersion,
Action<EnrollmentEvent> emitEvent,
BackendApiClient apiClient,
EventSpool spool,
RemoteConfigService remoteConfigService)
{
_configuration = configuration;
_logger = logger;
_agentVersion = agentVersion;
_emitEvent = emitEvent;
_apiClient = apiClient;
_spool = spool;
_remoteConfigService = remoteConfigService;
}
/// <summary>
/// Whether periodic collectors are currently paused due to idle timeout.
/// </summary>
public bool CollectorsIdleStopped => _collectorsIdleStopped;
/// <summary>
/// Starts the performance and metrics collectors and arms the idle check timer.
/// Called from CollectorCoordinator.StartOptionalCollectors.
/// </summary>
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)");
}
/// <summary>
/// Called when a non-periodic event is received. Updates idle tracking,
/// resets stall probes, and restarts paused collectors.
/// </summary>
public void OnRealEventReceived()
{
_lastRealEventTime = DateTime.UtcNow;
try { StallProbeCollector?.ResetProbes(); }
catch (Exception ex) { _logger.Verbose($"StallProbeCollector.ResetProbes failed: {ex.Message}"); }
if (_collectorsIdleStopped)
{
RestartPeriodicCollectors();
}
}
/// <summary>
/// Stops just the performance collector (used during WhiteGlove completion).
/// </summary>
public void StopPerformanceCollectorOnly()
{
_performanceCollector?.Stop();
_performanceCollector?.Dispose();
_performanceCollector = null;
}
/// <summary>
/// Stops all periodic collectors and the idle check timer.
/// </summary>
public void Stop()
{
_idleCheckTimer?.Dispose();
_idleCheckTimer = null;
_performanceCollector?.Stop();
_performanceCollector?.Dispose();
_performanceCollector = null;
_agentSelfMetricsCollector?.Stop();
_agentSelfMetricsCollector?.Dispose();
_agentSelfMetricsCollector = null;
}
/// <summary>
/// Returns true for events generated by periodic timers (not real enrollment activity).
/// </summary>
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<string, object>
{
{ "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<string, object>
{
{ "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();
}
}
}
@@ -1,279 +0,0 @@
using System;
using System.IO;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Manages persistent session ID storage to maintain tracking across agent restarts
/// </summary>
public class SessionPersistence
{
/// <summary>
/// Maximum age (hours) of a session.id file before the orphan guard treats it as
/// stale and discards it. Matches <c>AgentConfiguration.AbsoluteMaxSessionHours</c>.
/// 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.
/// </summary>
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();
/// <summary>
/// Protected constructor for testability (Moq proxy creation).
/// </summary>
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");
}
/// <summary>
/// Loads existing session ID from disk or creates a new one if none exists
/// </summary>
/// <returns>Session ID (GUID string)</returns>
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);
}
}
}
/// <summary>
/// Deletes the persisted session ID file
/// This should be called when enrollment is complete
/// </summary>
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
}
}
}
/// <summary>
/// Checks if a persisted session ID exists
/// </summary>
/// <returns>True if session file exists</returns>
public bool SessionExists()
{
lock (_lockObject)
{
return File.Exists(_sessionFilePath);
}
}
/// <summary>
/// Loads the persisted sequence counter. Returns 0 if no file exists or content is invalid.
/// </summary>
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;
}
}
/// <summary>
/// Persists the current sequence counter to disk.
/// Called periodically and before graceful shutdown (WhiteGlove).
/// </summary>
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
}
}
/// <summary>
/// Deletes the persisted sequence file.
/// Called alongside DeleteSession when enrollment completes.
/// </summary>
public void DeleteSequence()
{
lock (_lockObject)
{
try { if (File.Exists(_sequenceFilePath)) File.Delete(_sequenceFilePath); }
catch { }
}
}
/// <summary>
/// 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.
/// </summary>
public virtual void SaveWhiteGloveComplete()
{
lock (_lockObject)
{
try { File.WriteAllText(_whiteGloveMarkerPath, "1"); }
catch { }
}
}
/// <summary>
/// Returns true if a WhiteGlove Part 1 marker exists, indicating this boot is Part 2.
/// </summary>
public bool IsWhiteGloveResume()
{
lock (_lockObject)
{
return File.Exists(_whiteGloveMarkerPath);
}
}
/// <summary>
/// Persists the session creation timestamp (UTC). Called once when a new session is created.
/// </summary>
public void SaveSessionCreatedAt(DateTime utcTimestamp)
{
lock (_lockObject)
{
try { File.WriteAllText(_sessionCreatedPath, utcTimestamp.ToString("O")); }
catch { }
}
}
/// <summary>
/// Loads the persisted session creation timestamp. Returns null if file is missing or invalid.
/// </summary>
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;
}
}
/// <summary>
/// Resets the session creation timestamp to now (UTC).
/// Used on WhiteGlove Part 2 resume to give the user enrollment phase a fresh timer.
/// </summary>
public void ResetSessionCreatedAt()
{
SaveSessionCreatedAt(DateTime.UtcNow);
}
/// <summary>
/// Removes the WhiteGlove marker after the whiteglove_resumed event has been emitted.
/// </summary>
public void ClearWhiteGloveComplete()
{
lock (_lockObject)
{
try { if (File.Exists(_whiteGloveMarkerPath)) File.Delete(_whiteGloveMarkerPath); }
catch { }
}
}
}
}
@@ -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
{
@@ -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
{
@@ -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
{
@@ -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
{
@@ -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
{
@@ -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<EnrollmentEvent> 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
@@ -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
{
@@ -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
{
/// <summary>
/// Manages offline event storage (spool) for resilient uploads
/// </summary>
public class EventSpool : IDisposable
{
private readonly string _spoolDirectory;
private readonly object _lockObject = new object();
private readonly FileSystemWatcher _fileWatcher;
/// <summary>
/// Event raised when new events are added to the spool
/// </summary>
public event EventHandler EventsAvailable;
/// <summary>
/// Protected constructor for testability (Moq proxy creation).
/// </summary>
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;
}
/// <summary>
/// Starts watching for new events
/// </summary>
public void StartWatching()
{
_fileWatcher.EnableRaisingEvents = true;
}
/// <summary>
/// Stops watching for new events
/// </summary>
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);
}
/// <summary>
/// Adds an event to the spool
/// </summary>
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);
}
}
/// <summary>
/// Gets a batch of events from the spool (oldest first, sorted by sequence)
/// </summary>
public virtual List<EnrollmentEvent> GetBatch(int maxBatchSize)
{
lock (_lockObject)
{
var events = new List<EnrollmentEvent>();
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<EnrollmentEvent>(json);
if (evt != null)
{
events.Add(evt);
}
}
catch
{
// Skip corrupted files
}
}
return events;
}
}
/// <summary>
/// Extracts the sequence number from event filename
/// Filename format: event_{timestamp}_{sequence}.json
/// </summary>
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;
}
/// <summary>
/// Removes events from the spool after successful upload
/// </summary>
public virtual void RemoveEvents(List<EnrollmentEvent> 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
}
}
}
}
/// <summary>
/// Gets the count of events in the spool
/// </summary>
public virtual int GetCount()
{
lock (_lockObject)
{
return Directory.GetFiles(_spoolDirectory, "event_*.json").Length;
}
}
/// <summary>
/// Clears all events from the spool
/// </summary>
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
}
}
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
}
}
}
@@ -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
{
/// <summary>
/// Manages event upload lifecycle: debouncing, batched upload, auth-failure circuit breaker,
/// and admin signal detection (kill, block, override) from upload responses.
/// </summary>
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<EnrollmentEvent> _emitEvent;
private readonly Action<string, string, Dictionary<string, object>> _emitShutdownEvent;
private readonly Func<bool> _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<EnrollmentEvent> emitEvent,
Action<string, string, Dictionary<string, object>> emitShutdownEvent,
Func<bool> 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
);
}
/// <summary>
/// Starts the upload and fallback timers. Call after spool watcher is started.
/// </summary>
public void Start()
{
_uploadTimer.Change(
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(5)
);
_logger.Info("EventUploadOrchestrator started (debounce + fallback timers)");
}
/// <summary>
/// Wires the server-action dispatcher. Called by <see cref="MonitoringService"/> after the
/// orchestrator is constructed, once all other services exist. Optional — if null, actions
/// in ingest responses are simply ignored.
/// </summary>
public void SetServerActionDispatcher(ServerActionDispatcher dispatcher)
{
_serverActionDispatcher = dispatcher;
}
/// <summary>
/// Stops all upload timers. Called during terminal event processing to prevent
/// further automatic uploads while the completion handler drives uploads manually.
/// </summary>
public void StopTimers()
{
_uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
}
/// <summary>
/// Called when new events are available in the spool (FileSystemWatcher detected new files).
/// Uses debouncing to batch events before uploading.
/// </summary>
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<ServerAction> synthesized = null;
if (response.DeviceKillSignal)
{
synthesized = new List<ServerAction>
{
new ServerAction
{
Type = ServerActionTypes.TerminateSession,
Reason = "DeviceKillSignal from administrator",
QueuedAt = DateTime.UtcNow,
Params = new Dictionary<string, string>
{
// 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<ServerAction>
{
new ServerAction
{
Type = ServerActionTypes.TerminateSession,
Reason = $"Admin marked session as {response.AdminAction}",
QueuedAt = DateTime.UtcNow,
Params = new Dictionary<string, string>
{
{ "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<ServerAction>();
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();
}
}
/// <summary>
/// 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.
/// </summary>
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<string, object>
{
{ "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<string, object>
{
{ "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();
}
}
}