mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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:
-96
@@ -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") && enrollmentType != "v2" && espEverSeen && !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 && source == "esp_hello_composite") { ... hybridRebootGateBlocked = !hasImeCompletion && !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 && !_aadJoinedWithUser)
|
||||
/// </remarks>
|
||||
public static bool IsDeviceOnlyDeployment(int? autopilotMode, bool? skipUserStatusPage,
|
||||
bool aadJoinedWithUser)
|
||||
{
|
||||
bool isSelfDeploying = autopilotMode == 1;
|
||||
return isSelfDeploying || (skipUserStatusPage == true && !aadJoinedWithUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
-820
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-393
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-120
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-150
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
-63
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
-151
@@ -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; }
|
||||
}
|
||||
}
|
||||
-1813
File diff suppressed because it is too large
Load Diff
-381
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-768
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
-11
@@ -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;
|
||||
}
|
||||
}
|
||||
-11
@@ -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;
|
||||
}
|
||||
}
|
||||
-21
@@ -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));
|
||||
}
|
||||
}
|
||||
-30
@@ -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; }
|
||||
}
|
||||
}
|
||||
+16
-157
@@ -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
|
||||
|
||||
-160
@@ -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; }
|
||||
}
|
||||
}
|
||||
-154
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-2
@@ -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
-311
@@ -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
|
||||
{
|
||||
|
||||
-2
@@ -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
|
||||
{
|
||||
|
||||
-2
@@ -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
|
||||
{
|
||||
|
||||
-2
@@ -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
|
||||
{
|
||||
|
||||
-2
@@ -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
|
||||
{
|
||||
|
||||
+4
-10
@@ -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
|
||||
|
||||
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-381
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user