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; }
+ }
+}