diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ImeRegistryAppStateObserverTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ImeRegistryAppStateObserverTests.cs index 6e7788f3..1b560769 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ImeRegistryAppStateObserverTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ImeRegistryAppStateObserverTests.cs @@ -59,9 +59,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals public void ClassifyEnforcementState_bands(int state, string expected) => Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyEnforcementState(state)); + // IME AppInstallStatus values (StatusServiceReports\..\Status), stable IME 1.50 .. 1.106. + // 1001/1003 sit inside the 1000 band but are NOT terminal — IME maps every InProgress + // enforcement state to 1001 (verified in the decompiled 1.106 StatusServiceHelpers). [Theory] [InlineData(1000, "installed")] - [InlineData(1999, "installed")] + [InlineData(1002, "installed")] + [InlineData(1004, "installed")] + [InlineData(1001, "installing")] + [InlineData(1003, "installing")] + [InlineData(1005, null)] + [InlineData(1999, null)] [InlineData(3000, "failed")] [InlineData(2000, "notApplicable")] [InlineData(2999, "notApplicable")] @@ -72,6 +80,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals public void ClassifyStatusServiceStatus_bands(int status, string? expected) => Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyStatusServiceStatus(status)); + [Fact] + public void TryParseRegistryDateTime_reads_both_ime_shapes_as_utc() + { + // ExecutionDeadlineTime: DateTime.ToString(InvariantCulture), UTC without a kind marker. + var deadline = ImeRegistryAppStateObserver.TryParseRegistryDateTime("09/23/2026 18:00:00"); + Assert.Equal(new DateTime(2026, 9, 23, 18, 0, 0, DateTimeKind.Utc), deadline); + Assert.Equal(DateTimeKind.Utc, deadline!.Value.Kind); + + // DeferUntilTime: round-trip "o" with trailing Z. + var until = ImeRegistryAppStateObserver.TryParseRegistryDateTime("2026-09-23T10:30:00.0000000Z"); + Assert.Equal(new DateTime(2026, 9, 23, 10, 30, 0, DateTimeKind.Utc), until); + Assert.Equal(DateTimeKind.Utc, until!.Value.Kind); + + Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime(null)); + Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime("")); + Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime("not a date")); + Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime(42)); + } + + [Fact] + public void TryReadBool_parses_registry_strings_only() + { + Assert.True(ImeRegistryAppStateObserver.TryReadBool("True")); + Assert.False(ImeRegistryAppStateObserver.TryReadBool("false")); + Assert.Null(ImeRegistryAppStateObserver.TryReadBool("yes")); + Assert.Null(ImeRegistryAppStateObserver.TryReadBool(1)); + Assert.Null(ImeRegistryAppStateObserver.TryReadBool(null)); + } + [Fact] public void TerminalOutcome_prefers_enforcement_state_then_status_service() { @@ -88,6 +125,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 3000))); Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 2500))); Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry())); + // Installing (1001) / InstallingPendingReboot (1003) share the 1000 band but are not terminal. + Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 1001))); + Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 2009, statusServiceStatus: 1003))); + } + + [Fact] + public void ChangedFields_reports_wait_state_triggers_only() + { + var prev = Entry(enforcementState: 2009); + var next = Entry(enforcementState: 2009, + installDeadlineUtc: T0.AddHours(2), + deferralsUsed: 1, deferralMaxDeferrals: 3, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: false); + + Assert.Equal(new[] { "installDeadline", "deferralsUsed", "deferUntil" }, + ImeRegistryAppStateObserver.ChangedFields(prev, next)); + + // MaxDeferrals / AutoDeferred alone are data, not a transition. + var sameCounters = Entry(enforcementState: 2009, + installDeadlineUtc: T0.AddHours(2), + deferralsUsed: 1, deferralMaxDeferrals: 5, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: true); + Assert.Empty(ImeRegistryAppStateObserver.ChangedFields(next, sameCounters)); + + // A cleared deadline is a transition too (IME clears it when the next download starts). + Assert.Equal(new[] { "installDeadline" }, + ImeRegistryAppStateObserver.ChangedFields( + Entry(installDeadlineUtc: T0.AddHours(2)), Entry())); + } + + [Fact] + public void DescribeWaitState_texts() + { + Assert.Equal(string.Empty, ImeRegistryAppStateObserver.DescribeWaitState(Entry(enforcementState: 2009))); + Assert.Equal(", downloaded, install deadline 2026-08-18 10:00 UTC", + ImeRegistryAppStateObserver.DescribeWaitState(Entry(installDeadlineUtc: T0.AddHours(2)))); + Assert.Equal(", deferred by user 1/3 until 2026-08-18 08:30 UTC", + ImeRegistryAppStateObserver.DescribeWaitState(Entry( + deferralsUsed: 1, deferralMaxDeferrals: 3, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: false))); + Assert.Equal(", auto-deferred 2/3", + ImeRegistryAppStateObserver.DescribeWaitState(Entry( + deferralsUsed: 2, deferralMaxDeferrals: 3, deferralAutoDeferred: true))); + // Zero deferrals used is not a deferral; a missing max falls back to the bare count. + Assert.Equal(string.Empty, ImeRegistryAppStateObserver.DescribeWaitState(Entry(deferralsUsed: 0, deferralMaxDeferrals: 3))); + Assert.Equal(", deferred by user 1", ImeRegistryAppStateObserver.DescribeWaitState(Entry(deferralsUsed: 1))); } [Fact] @@ -184,6 +264,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals Assert.Equal("success", evt.Payload["enforcementClass"]); } + [Fact] + public void Wait_state_changes_emit_state_events_with_data_and_text() + { + var (sink, clock, observer) = CreateObserver(trackerApps: null); + var current = Snapshot((App1, 2009, null)); + + using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current)) + { + observer.Tick("baseline"); + + // Download finished, IME parks the app until its install deadline (1.106 ExecutionDeadlineTime). + var withDeadline = Snapshot((App1, 2009, null)); + withDeadline.Entries.Values.Single().InstallDeadlineUtc = T0.AddHours(2); + current = withDeadline; + observer.Tick("registry_change"); + + // A required app hits a running process and the user defers (1.105 app-in-use deferral). + var deferred = Snapshot((App1, 2014, null)); + var entry = deferred.Entries.Values.Single(); + entry.InstallDeadlineUtc = T0.AddHours(2); + entry.DeferralsUsed = 1; + entry.DeferralMaxDeferrals = 3; + entry.DeferUntilUtc = T0.AddMinutes(30); + entry.DeferralAutoDeferred = false; + current = deferred; + observer.Tick("registry_change"); + } + + var events = Events(sink, SharedEventTypes.RegistryAppState); + Assert.Equal(2, events.Count); + + var deadlineEvent = events[0]; + Assert.Equal("installDeadline", deadlineEvent.Payload!["changedFields"]); + Assert.Equal("2026-08-18T10:00:00.0000000Z", deadlineEvent.Payload["installDeadlineUtc"]); + Assert.DoesNotContain("deferralsUsed", deadlineEvent.Payload.Keys); + Assert.Contains("-> inProgress, downloaded, install deadline 2026-08-18 10:00 UTC", + deadlineEvent.Payload[SignalPayloadKeys.Message]); + + var deferralEvent = events[1]; + Assert.Equal("enforcementState,deferralsUsed,deferUntil", deferralEvent.Payload!["changedFields"]); + Assert.Equal("2014", deferralEvent.Payload["enforcementState"]); + Assert.Equal("1", deferralEvent.Payload["deferralsUsed"]); + Assert.Equal("3", deferralEvent.Payload["deferralMaxDeferrals"]); + Assert.Equal("2026-08-18T08:30:00.0000000Z", deferralEvent.Payload["deferUntilUtc"]); + Assert.Equal("false", deferralEvent.Payload["deferralAutoDeferred"]); + Assert.Contains("deferred by user 1/3 until 2026-08-18 08:30 UTC", deferralEvent.Payload[SignalPayloadKeys.Message]); + } + [Fact] public void NonEmpty_baseline_emits_one_summary_with_counts() { @@ -331,7 +459,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals private static AppRegistryEntry Entry( int? enforcementState = null, long? errorCode = null, int? exitCode = null, - int? statusServiceStatus = null, bool espTracked = false, string? espPhase = null) + int? statusServiceStatus = null, bool espTracked = false, string? espPhase = null, + DateTime? installDeadlineUtc = null, int? deferralsUsed = null, int? deferralMaxDeferrals = null, + DateTime? deferUntilUtc = null, bool? deferralAutoDeferred = null) { var entry = new AppRegistryEntry($"{ImeRegistrySnapshot.DeviceContext}|{App1}", ImeRegistrySnapshot.DeviceContext, App1); entry.EnforcementState = enforcementState; @@ -340,6 +470,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals entry.StatusServiceStatus = statusServiceStatus; entry.EspTracked = espTracked; entry.EspPhase = espPhase; + entry.InstallDeadlineUtc = installDeadlineUtc; + entry.DeferralsUsed = deferralsUsed; + entry.DeferralMaxDeferrals = deferralMaxDeferrals; + entry.DeferUntilUtc = deferUntilUtc; + entry.DeferralAutoDeferred = deferralAutoDeferred; return entry; } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs index 3c643c3d..c752154c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs @@ -24,10 +24,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals /// EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev> — which apps IME /// registered for ESP tracking and in which phase. /// SideCarPolicies\StatusServiceReports\<userGuid>\<appId> — the exact - /// AppInstallStatusReport the ESP page renders (Status 1000=Installed .. 3000=Failed). + /// AppInstallStatusReport the ESP page renders (Status 1000=Installed, 1001=Installing, + /// 1003=InstallingPendingReboot, 2000=NotApplicable, 3000=Failed). + /// Win32Apps\OperationalState\<userGuid>\<appGuid> (bare GUIDs) — IME's + /// per-app operational state: the install deadline a downloaded app is waiting for + /// (ExecutionDeadlineTime, IME 1.106+) and the app-in-use deferral counters + /// (DeferralsUsed, DeferUntilTime, ..., IME 1.105+). /// /// The observer is snapshot-and-diff driven (RegistryWatcher gives key-scope edges only, - /// coalesced — a per-write parse is impossible by design): every tick re-reads the three + /// coalesced — a per-write parse is impossible by design): every tick re-reads the four /// surfaces and emits registry_app_state on real field changes. Pre-existing state at /// agent start is the silent baseline — Win32Apps keys survive re-enrollments, so replaying /// them as fresh events would be the registry twin of the historic-IME-replay bug. @@ -162,6 +167,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals ReadWin32Apps(root, snapshot); ReadEspTracking(root, snapshot); ReadStatusServiceReports(root, snapshot); + ReadOperationalState(root, snapshot); return snapshot; } @@ -250,6 +256,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals } } + // Only the two wait states are read; the download/execution bookkeeping next to them + // (DownloadStatus, DownloadStartTime, ExecutionStatus, ...) duplicates the log narrative + // and would double the event volume without adding a state the timeline lacks. + private static void ReadOperationalState(RegistryKey root, ImeRegistrySnapshot snapshot) + { + using var operational = root.OpenSubKey(@"Win32Apps\OperationalState"); + if (operational == null) return; + + foreach (var userName in operational.GetSubKeyNames()) + { + if (!LooksLikeGuid(userName)) continue; + using var userKey = operational.OpenSubKey(userName); + if (userKey == null) continue; + + foreach (var appKeyName in userKey.GetSubKeyNames()) + { + var appId = ExtractAppId(appKeyName); + if (appId == null) continue; + + using var appKey = userKey.OpenSubKey(appKeyName); + if (appKey == null) continue; + + var deadline = TryParseRegistryDateTime(appKey.GetValue("ExecutionDeadlineTime")); + var deferralsUsed = TryReadInt(appKey.GetValue("DeferralsUsed")); + if (deadline == null && deferralsUsed == null) continue; // no wait state — no entry of its own + + var entry = snapshot.GetOrAdd(userName, appId); + entry.InstallDeadlineUtc = deadline; + entry.DeferralsUsed = deferralsUsed; + entry.DeferralMaxDeferrals = TryReadInt(appKey.GetValue("DeferralMaxDeferrals")); + entry.DeferUntilUtc = TryParseRegistryDateTime(appKey.GetValue("DeferUntilTime")); + entry.DeferralAutoDeferred = TryReadBool(appKey.GetValue("DeferralAutoDeferred")); + } + } + } + // ── pure helpers (unit-tested directly) ───────────────────────────────── internal static bool LooksLikeGuid(string name) => @@ -301,6 +343,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals } } + internal static bool? TryReadBool(object? value) => + value is string s && bool.TryParse(s, out var parsed) ? parsed : (bool?)null; + + /// + /// OperationalState timestamps are UTC but stored in two InvariantCulture shapes: + /// ExecutionDeadlineTime as MM/dd/yyyy HH:mm:ss without a kind marker, + /// DeferUntilTime round-trip ("o", trailing Z). Both come back as UTC here. + /// + internal static DateTime? TryParseRegistryDateTime(object? value) + { + if (!(value is string s) || string.IsNullOrWhiteSpace(s)) return null; + return DateTime.TryParse(s, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) + ? parsed + : (DateTime?)null; + } + /// StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104). internal static string ClassifyEnforcementState(int state) { @@ -312,15 +371,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals return "unknown"; } - /// AppInstallStatus (StatusService/ESP page): 1000er = installed, 3000 = failed. + /// + /// AppInstallStatus as IME writes it to StatusServiceReports (unchanged IME 1.50 .. 1.106): + /// 1000 Installed, 1001 Installing, 1002 InstalledButDependenciesNotPresent, + /// 1003 InstallingPendingReboot, 1004 InstalledPendingReboot, 2000 NotApplicable, 3000 Failed. + /// The 1000 band is NOT uniformly terminal — IME maps every InProgress enforcement state to + /// 1001 — so the mapping is explicit and an unknown value is not judged. + /// internal static string? ClassifyStatusServiceStatus(int status) { - if (status >= 1000 && status < 2000) return "installed"; - if (status == 3000) return "failed"; + switch (status) + { + case 1000: + case 1002: + case 1004: + return "installed"; + case 1001: + case 1003: + return "installing"; + case 3000: + return "failed"; + } if (status >= 2000 && status < 3000) return "notApplicable"; return null; } + /// + /// Human-readable suffix for the two IME wait states (install deadline, app-in-use + /// deferral); empty when neither applies. Culture-invariant on purpose — the agent runs + /// under whatever culture the SYSTEM account carries. + /// + internal static string DescribeWaitState(AppRegistryEntry entry) + { + var parts = new List(2); + if (entry.InstallDeadlineUtc is DateTime deadline) + parts.Add("downloaded, install deadline " + FormatUtcMinute(deadline)); + + if (entry.DeferralsUsed is int used && used > 0) + { + var kind = entry.DeferralAutoDeferred == true ? "auto-deferred" : "deferred by user"; + var count = entry.DeferralMaxDeferrals is int max + ? used.ToString(CultureInfo.InvariantCulture) + "/" + max.ToString(CultureInfo.InvariantCulture) + : used.ToString(CultureInfo.InvariantCulture); + parts.Add(entry.DeferUntilUtc is DateTime until + ? kind + " " + count + " until " + FormatUtcMinute(until) + : kind + " " + count); + } + + return parts.Count == 0 ? string.Empty : ", " + string.Join(", ", parts); + } + + private static string FormatUtcMinute(DateTime utc) => + utc.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + " UTC"; + internal static List DiffSnapshots(ImeRegistrySnapshot previous, ImeRegistrySnapshot next) { var changes = new List(); @@ -343,6 +446,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals if (prev?.StatusServiceStatus != next.StatusServiceStatus) changed.Add("statusServiceStatus"); if ((prev?.EspTracked ?? false) != next.EspTracked) changed.Add("espTracked"); if (!string.Equals(prev?.EspPhase, next.EspPhase, StringComparison.OrdinalIgnoreCase)) changed.Add("espPhase"); + // Wait states: the deadline and each recorded deferral are transitions worth an event; + // MaxDeferrals / AutoDeferred only travel along as data. + if (prev?.InstallDeadlineUtc != next.InstallDeadlineUtc) changed.Add("installDeadline"); + if (prev?.DeferralsUsed != next.DeferralsUsed) changed.Add("deferralsUsed"); + if (prev?.DeferUntilUtc != next.DeferUntilUtc) changed.Add("deferUntil"); return changed; } @@ -445,6 +553,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals } if (e.EspTracked) data["espTracked"] = "true"; if (!string.IsNullOrEmpty(e.EspPhase)) data["espPhase"] = e.EspPhase!; + if (e.InstallDeadlineUtc is DateTime deadline) data["installDeadlineUtc"] = deadline.ToString("o", CultureInfo.InvariantCulture); + if (e.DeferralsUsed is int used) data["deferralsUsed"] = used.ToString(CultureInfo.InvariantCulture); + if (e.DeferralMaxDeferrals is int max) data["deferralMaxDeferrals"] = max.ToString(CultureInfo.InvariantCulture); + if (e.DeferUntilUtc is DateTime until) data["deferUntilUtc"] = until.ToString("o", CultureInfo.InvariantCulture); + if (e.DeferralAutoDeferred is bool auto) data["deferralAutoDeferred"] = auto ? "true" : "false"; var summary = data.TryGetValue("enforcementClass", out var encls) ? encls : data.TryGetValue("statusServiceClass", out var sscls) ? sscls @@ -453,7 +566,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals _post.Emit( eventType: SharedEventTypes.RegistryAppState, source: SourceLabel, - message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}", + message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}{DescribeWaitState(e)}", severity: EventSeverity.Info, data: data, occurredAtUtc: nowUtc); @@ -625,6 +738,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals public int? StatusServiceStatus { get; set; } public bool EspTracked { get; set; } public string? EspPhase { get; set; } + + // Win32Apps\OperationalState — the two IME wait states (all UTC). + public DateTime? InstallDeadlineUtc { get; set; } + public int? DeferralsUsed { get; set; } + public int? DeferralMaxDeferrals { get; set; } + public DateTime? DeferUntilUtc { get; set; } + public bool? DeferralAutoDeferred { get; set; } } internal sealed class AppRegistryChange