diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs index a8496864..b6359ae9 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs @@ -72,9 +72,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime private CancellationTokenSource _cts; private bool _allAppsCompletedFired; - // 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. + // 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; private bool _stateDirty; // Standard GUID capture pattern used as {GUID} placeholder in patterns @@ -148,9 +148,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime _matchLogPath = matchLogPath; _packageStates = new AppPackageStateList(logger); - // M4.5.a: state-persistence removed; stateDirectory retained in signature for - // backwards-compat with existing callers/tests but intentionally unused. - _ = stateDirectory; + // State persistence setup + if (!string.IsNullOrEmpty(stateDirectory)) + { + _statePersistence = new ImeTrackerStatePersistence(stateDirectory, logger); + } if (!string.IsNullOrEmpty(_matchLogPath)) { @@ -282,27 +284,164 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime _pollingTask = null; } - #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. + #region State Persistence + /// + /// Loads persisted state from disk. Called on Start() to restore tracker state + /// after an agent restart, so parsing continues exactly where it left off. + /// private void LoadState() { - // no-op — fresh start on restart + if (_statePersistence == null) return; + + var state = _statePersistence.Load(); + if (state == null) return; + + // Restore phase tracking + _currentPhaseOrder = state.CurrentPhaseOrder; + _lastEspPhaseDetected = state.LastEspPhaseDetected; + _allAppsCompletedFired = state.AllAppsCompletedFired; + _logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase; + + // Restore seen app IDs + _seenAppIds.Clear(); + if (state.SeenAppIds != null) + { + foreach (var id in state.SeenAppIds) + _seenAppIds.Add(id); + } + + // Restore ignore list + if (state.IgnoreList != null) + { + foreach (var id in state.IgnoreList) + _packageStates.AddToIgnoreList(id); + } + + // Restore current package ID + _packageStates.CurrentPackageId = state.CurrentPackageId; + + // Restore package states + if (state.Packages != null) + { + _packageStates.Clear(); + foreach (var p in state.Packages) + { + var pkg = AppPackageState.Restore( + p.Id, p.ListPos, p.Name, + (AppRunAs)p.RunAs, (AppIntent)p.Intent, (AppTargeted)p.Targeted, + p.DependsOn != null ? new HashSet(p.DependsOn) : new HashSet(), + (AppInstallationState)p.InstallationState, p.DownloadingOrInstallingSeen, + p.ProgressPercent, p.BytesDownloaded, p.BytesTotal, + errorPatternId: p.ErrorPatternId, errorDetail: p.ErrorDetail, errorCode: p.ErrorCode, + exitCode: p.ExitCode, hresultFromWin32: p.HResultFromWin32, + doFileSize: p.DoFileSize, doTotalBytesDownloaded: p.DoTotalBytesDownloaded, + doBytesFromPeers: p.DoBytesFromPeers, doPercentPeerCaching: p.DoPercentPeerCaching, + doBytesFromLanPeers: p.DoBytesFromLanPeers, doBytesFromGroupPeers: p.DoBytesFromGroupPeers, + doBytesFromInternetPeers: p.DoBytesFromInternetPeers, doDownloadMode: p.DoDownloadMode, + doDownloadDuration: p.DoDownloadDuration, doBytesFromHttp: p.DoBytesFromHttp, + hasDoTelemetry: p.HasDoTelemetry, + appVersion: p.AppVersion, appType: p.AppType, + attemptNumber: p.AttemptNumber, detectionResult: p.DetectionResult); + _packageStates.Add(pkg); + } + } + + // Restore file positions + if (state.FilePositions != null) + { + foreach (var kvp in state.FilePositions) + { + var fullPath = Path.Combine(_logFolder, kvp.Key); + _positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize); + } + } + + // Re-activate patterns based on restored phase state + ActivatePatterns(_logPhaseIsCurrentPhase, force: true); + + _logger.Info($"ImeLogTracker: state restored - phase: {_lastEspPhaseDetected ?? "(none)"} (order: {_currentPhaseOrder}), " + + $"ignore list: {_packageStates.IgnoreList.Count}, packages: {_packageStates.Count}, " + + $"file positions: {state.FilePositions?.Count ?? 0}"); } + /// + /// Persists current tracker state to disk as JSON. + /// Called after each polling cycle when state has changed. + /// private void SaveState() { - // no-op — nothing to persist + if (_statePersistence == null) return; + + // Build state DTO + var state = new ImeTrackerStateData + { + CurrentPhaseOrder = _currentPhaseOrder, + LastEspPhaseDetected = _lastEspPhaseDetected, + AllAppsCompletedFired = _allAppsCompletedFired, + LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase, + SeenAppIds = _seenAppIds.ToList(), + IgnoreList = _packageStates.IgnoreList.ToList(), + CurrentPackageId = _packageStates.CurrentPackageId, + Packages = _packageStates.Select(p => new PackageStateData + { + Id = p.Id, + ListPos = p.ListPos, + Name = p.Name, + RunAs = (int)p.RunAs, + Intent = (int)p.Intent, + Targeted = (int)p.Targeted, + DependsOn = p.DependsOn?.ToList() ?? new List(), + InstallationState = (int)p.InstallationState, + DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen, + ProgressPercent = p.ProgressPercent, + BytesDownloaded = p.BytesDownloaded, + BytesTotal = p.BytesTotal, + ErrorPatternId = p.ErrorPatternId, + ErrorDetail = p.ErrorDetail, + ErrorCode = p.ErrorCode, + ExitCode = p.ExitCode, + HResultFromWin32 = p.HResultFromWin32, + DoFileSize = p.DoFileSize, + DoTotalBytesDownloaded = p.DoTotalBytesDownloaded, + DoBytesFromPeers = p.DoBytesFromPeers, + DoPercentPeerCaching = p.DoPercentPeerCaching, + DoBytesFromLanPeers = p.DoBytesFromLanPeers, + DoBytesFromGroupPeers = p.DoBytesFromGroupPeers, + DoBytesFromInternetPeers = p.DoBytesFromInternetPeers, + DoDownloadMode = p.DoDownloadMode, + DoDownloadDuration = p.DoDownloadDuration, + DoBytesFromHttp = p.DoBytesFromHttp, + HasDoTelemetry = p.HasDoTelemetry, + AppVersion = p.AppVersion, + AppType = p.AppType, + AttemptNumber = p.AttemptNumber, + DetectionResult = p.DetectionResult + }).ToList(), + FilePositions = new Dictionary() + }; + + // Store file positions by filename only (log folder is known) + foreach (var kvp in _positionTracker.GetAllPositions()) + { + var fileName = Path.GetFileName(kvp.Key); + state.FilePositions[fileName] = new FilePositionData + { + Position = kvp.Value.Position, + LastKnownSize = kvp.Value.LastKnownSize + }; + } + + _statePersistence.Save(state); } - /// Kept as public no-op for API compatibility; callers treat it as a hint. + /// + /// Deletes persisted state file. Called on enrollment complete to ensure + /// a fresh state on the next enrollment cycle. + /// public void DeleteState() { - // no-op + _statePersistence?.Delete(); } #endregion diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs new file mode 100644 index 00000000..795d69cb --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Handles persistence of ImeLogTracker state to disk (JSON serialization). + /// Extracted from ImeLogTracker to separate I/O concerns from log parsing. + /// + public class ImeTrackerStatePersistence + { + private readonly string _stateDirectory; + private readonly string _stateFilePath; + private readonly AgentLogger _logger; + + public ImeTrackerStatePersistence(string stateDirectory, AgentLogger logger) + { + _stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory); + _stateFilePath = Path.Combine(_stateDirectory, "ime-tracker-state.json"); + _logger = logger; + } + + public string StateFilePath => _stateFilePath; + + /// + /// Loads persisted state from disk. Returns null if no state file exists or on error. + /// + public ImeTrackerStateData Load() + { + if (!File.Exists(_stateFilePath)) + { + _logger.Info("ImeLogTracker: no persisted state found (fresh enrollment)"); + return null; + } + + try + { + var json = File.ReadAllText(_stateFilePath); + var state = JsonConvert.DeserializeObject(json); + if (state == null) + { + _logger.Warning("ImeLogTracker: persisted state file was empty or invalid"); + return null; + } + return state; + } + catch (Exception ex) + { + _logger.Warning($"ImeLogTracker: failed to load persisted state, starting fresh: {ex.Message}"); + return null; + } + } + + /// + /// Persists tracker state to disk as JSON (atomic write via temp file). + /// + public void Save(ImeTrackerStateData state) + { + try + { + Directory.CreateDirectory(_stateDirectory); + var tempPath = _stateFilePath + ".tmp"; + File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented)); + File.Copy(tempPath, _stateFilePath, overwrite: true); + try { File.Delete(tempPath); } catch { } + } + catch (Exception ex) + { + _logger.Debug($"ImeLogTracker: failed to save state: {ex.Message}"); + } + } + + /// + /// Deletes persisted state file. + /// + public void Delete() + { + try + { + if (File.Exists(_stateFilePath)) + { + File.Delete(_stateFilePath); + _logger.Info("ImeLogTracker: persisted state deleted"); + } + } + catch (Exception ex) + { + _logger.Debug($"ImeLogTracker: failed to delete state file: {ex.Message}"); + } + } + } + + // ---- DTOs for state serialization ---- + + public class ImeTrackerStateData + { + public int CurrentPhaseOrder { get; set; } + public string LastEspPhaseDetected { get; set; } + public bool AllAppsCompletedFired { get; set; } + public bool LogPhaseIsCurrentPhase { get; set; } + public List SeenAppIds { get; set; } + public List IgnoreList { get; set; } + public string CurrentPackageId { get; set; } + public List Packages { get; set; } + public Dictionary FilePositions { get; set; } + } + + public class PackageStateData + { + public string Id { get; set; } + public int ListPos { get; set; } + public string Name { get; set; } + public int RunAs { get; set; } + public int Intent { get; set; } + public int Targeted { get; set; } + public List DependsOn { get; set; } + public int InstallationState { get; set; } + public bool DownloadingOrInstallingSeen { get; set; } + public int? ProgressPercent { get; set; } + public long BytesDownloaded { get; set; } + public long BytesTotal { get; set; } + + // Error context + public string ErrorPatternId { get; set; } + public string ErrorDetail { get; set; } + public string ErrorCode { get; set; } + + // Installer result codes + public string ExitCode { get; set; } + public string HResultFromWin32 { get; set; } + + // Delivery Optimization telemetry + public long DoFileSize { get; set; } + public long DoTotalBytesDownloaded { get; set; } + public long DoBytesFromPeers { get; set; } + public int DoPercentPeerCaching { get; set; } + public long DoBytesFromLanPeers { get; set; } + public long DoBytesFromGroupPeers { get; set; } + public long DoBytesFromInternetPeers { get; set; } + public int DoDownloadMode { get; set; } = -1; + public string DoDownloadDuration { get; set; } + public long DoBytesFromHttp { get; set; } + public bool HasDoTelemetry { get; set; } + + // App metadata (captured from IME logs for App Dashboard reporting) + public string AppVersion { get; set; } + public string AppType { get; set; } + public int AttemptNumber { get; set; } + public string DetectionResult { get; set; } + } + + public class FilePositionData + { + public long Position { get; set; } + public long LastKnownSize { get; set; } + } +}