From 3c8ef02191475c5e30829ec5ff88fa2a97ca6f1d Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 30 Aug 2026 20:02:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(agent):=20RealmJoin=20self-update=20is=20o?= =?UTF-8?q?bserved=20and=20reported=20as=20realmjoin=5Fautoupdate=5Fdetect?= =?UTF-8?q?ed=20=E2=80=94=20realmjoin=5Fdetected=20read=20the=20version=20?= =?UTF-8?q?once=20from=20RealmJoin.exe,=20but=20the=20tray=20AppUpdater=20?= =?UTF-8?q?often=20replaces=20the=20MSI-shipped=20binary=20on=20its=20firs?= =?UTF-8?q?t=20start=20minutes=20after=20detection=20(gktatooine=20946ccbd?= =?UTF-8?q?6:=204.21.4=20->=204.21.18,=20whole=20deployment=20ran=20on=20t?= =?UTF-8?q?he=20new=20build=20while=20the=20timeline=20and=20RealmJoinFact?= =?UTF-8?q?s=20kept=204.21.4);=20the=20updater's=20only=20registry=20write?= =?UTF-8?q?=20is=20HKLM\...\Uninstall\RealmJoin=20(DisplayVersion/InstallD?= =?UTF-8?q?ate),=20so=20RealmJoinWatcher=20arms=20a=20RegNotifyChangeKeyVa?= =?UTF-8?q?lue=20watcher=20there=20once=20Detected=20fired=20(appearance-w?= =?UTF-8?q?atch=20on=20the=20Uninstall=20parent=20until=20the=20tray=20cre?= =?UTF-8?q?ates=20the=20entry),=20re-reads=20the=20binary=20version=20afte?= =?UTF-8?q?r=20a=202=20s=20debounce=20(DisplayVersion=20fallback=20while?= =?UTF-8?q?=20the=20file=20is=20locked,=20channel=20from=20baseline)=20and?= =?UTF-8?q?=20emits=20the=20Info=20event=20{previousVersion,newVersion,rel?= =?UTF-8?q?easeChannel}=20plus=20the=20typed=20RealmJoinAutoUpdateDetected?= =?UTF-8?q?=20signal,=20whose=20reducer=20overrides=20RealmJoinFacts.Produ?= =?UTF-8?q?ctVersion/ReleaseChannel=20(WithVersionOverride)=20and=20touche?= =?UTF-8?q?s=20neither=20gate,=20deadline=20nor=20LastActivityUtc;=20unrea?= =?UTF-8?q?dable=20baseline=20at=20detection=20is=20seeded=20silently;=20p?= =?UTF-8?q?ortal=20device=20card=20prefers=20the=20updated=20version=20("a?= =?UTF-8?q?uto-updated=20from=20X");=20event=20registered=20in=20Constants?= =?UTF-8?q?/resource-catalog/shared-manifests=20with=20replay=20fixture=20?= =?UTF-8?q?and=20watcher/adapter/reducer=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/agent/decision-engine.md | 16 +- docs/log.md | 4 + .../RealmJoinWatcherAdapterTests.cs | 133 +++++++++++++ .../Enrollment/SystemSignals/RealmJoinInfo.cs | 43 +++++ .../SystemSignals/RealmJoinWatcher.cs | 178 +++++++++++++++++- .../SignalAdapters/RealmJoinWatcherAdapter.cs | 55 ++++++ .../RealmJoinGateTests.cs | 68 +++++++ .../src/resource-catalog.ts | 1 + .../Engine/DecisionEngine.RealmJoin.cs | 44 +++++ .../Engine/DecisionEngine.cs | 1 + .../Signals/DecisionSignalKind.cs | 6 + .../State/RealmJoinFacts.cs | 23 +++ .../AutopilotMonitor.Shared/Constants.cs | 5 + .../sessions/components/DeviceDetailsCard.tsx | 18 +- .../utils/shared-manifests.generated.ts | 1 + .../utils/shared-manifests.json | 1 + .../realmjoin-autoupdate-detected-v1.json | 7 + 17 files changed, 595 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/signal-kinds/realmjoin-autoupdate-detected-v1.json diff --git a/docs/agent/decision-engine.md b/docs/agent/decision-engine.md index 09cdc18c..caa5f49a 100644 --- a/docs/agent/decision-engine.md +++ b/docs/agent/decision-engine.md @@ -8,7 +8,7 @@ tags: - decision-engine - reducer - completion -timestamp: 2026-07-11T00:00:00+02:00 +timestamp: 2026-08-30T00:00:00+02:00 --- # V2 Agent — Decision Engine (DecisionCore) @@ -175,6 +175,20 @@ advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`: scheduler contract) dead-ends as `realmjoin_timeout_stale_superseded_by_rearm`. The idle case (detected, no deployment activity ever) times out at 60 min unchanged, and phase 200/210 activity never extends. + **Self-update observation** (gktatooine session 946ccbd6): the version on + `RealmJoinDetected` is read once from `RealmJoin.exe`, but the tray's `AppUpdater` + often replaces the MSI-shipped binary on its first start — minutes after detection, + before the deployment begins. `RealmJoinWatcher` therefore arms a + `RegNotifyChangeKeyValue` watcher on `HKLM\...\Uninstall\RealmJoin` (the only + registry write of the updater; appearance-watch on the `Uninstall` parent when the + entry does not exist yet) once Detected fired, re-reads the binary version after a + 2 s debounce (registry `DisplayVersion` as fallback while the file is locked) and + posts the typed `RealmJoinAutoUpdateDetected` signal plus the + `realmjoin_autoupdate_detected` timeline event when the version differs from the + baseline. The reducer overrides `RealmJoinFacts.ProductVersion` / `ReleaseChannel` + (`WithVersionOverride` — the one legitimate later write past the set-once helpers); + it never touches the gate, the deadline, or `LastActivityUtc`. An unreadable + baseline at detection is seeded silently by the first successful re-read. A closed gate also outranks the 30-minute `AdvisoryCompletion` backstop: that handler re-arms (trigger suffix `:CompletionGateHolding`) instead of failing while the RealmJoin gate is closed *and* its own deadline is still armed, because the gate diff --git a/docs/log.md b/docs/log.md index ccdaa337..3357a885 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-30 (13) + +* **Update**: `agent/decision-engine.md` — RealmJoin self-update observation. `realmjoin_detected` reported the MSI-shipped build (4.21.4) although the tray auto-updated to 4.21.18 nine minutes later and ran the whole deployment on it (gktatooine session 946ccbd6). `RealmJoinWatcher` now watches `HKLM\...\Uninstall\RealmJoin` (the updater's only registry write) after detection, re-reads the binary version on change and emits `realmjoin_autoupdate_detected` (`previousVersion`, `newVersion`, `releaseChannel`); the typed `RealmJoinAutoUpdateDetected` signal overrides `RealmJoinFacts.ProductVersion`/`ReleaseChannel` and nothing else. Portal device card shows the updated version with an "auto-updated from" note. + ## 2026-08-30 (12) * **Update**: `web/portal-navigation-prefetch.md` — hosting citation now points at `deploy-web.yml`: the portal is built in CI (setup-node 22, `npm ci`, `next build`) and uploaded with the pinned SWA CLI; the Azure/static-web-apps-deploy action, its Oryx in-container build and the per-PR preview environments are gone. All non-GitHub-owned actions are SHA-pinned; the release and rules auto-commit steps use first-party `gh`/`git`. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs index ef4ba854..725f3d13 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs @@ -600,5 +600,138 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.DoesNotContain(ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPackageCompleted); } + + // ---- auto-update (Uninstall\RealmJoin) ----------------------------------------------- + + private sealed class VersionSource + { + public RealmJoinVersionInfo Info = RealmJoinVersionInfo.Empty; + public string? DisplayVersion; + public RealmJoinVersionInfo Read() => Info; + public string? ReadDisplayVersion() => DisplayVersion; + } + + private sealed class AutoUpdateHarness : IDisposable + { + private readonly TempDirectory _tmp = new TempDirectory(); + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VersionSource Versions { get; } = new VersionSource(); + public RealmJoinWatcher Watcher { get; } + public RealmJoinWatcherAdapter Adapter { get; } + + public AutoUpdateHarness() + { + var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info); + var registry = new FakeRealmJoinRegistry(); + Watcher = new RealmJoinWatcher( + logger, + enumeratePackageIds: registry.Enumerate, + tryReadPackage: registry.TryRead, + keyExists: registry.KeyExists, + readVersionInfo: Versions.Read, + readUninstallDisplayVersion: Versions.ReadDisplayVersion); + Adapter = new RealmJoinWatcherAdapter(Watcher, Ingress, new VirtualClock(Fixed)); + } + + public void Dispose() + { + Adapter.Dispose(); + Watcher.Dispose(); + _tmp.Dispose(); + } + } + + [Fact] + public void AutoUpdate_fires_once_with_previous_and_new_version_when_binary_version_changes_after_detection() + { + // gktatooine session 946ccbd6: realmjoin_detected read 4.21.4 from the MSI-shipped + // binary; nine minutes later the tray AppUpdater swapped it for 4.21.18 and rewrote + // Uninstall\RealmJoin. The Detected read is the baseline; the re-read after the + // Uninstall-key change must surface exactly one auto-update event. + using var h = new AutoUpdateHarness(); + h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "canary"); + h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0); + Assert.Equal("4.21.4", h.Watcher.BaselineVersionForTest); + + h.Versions.Info = new RealmJoinVersionInfo("4.21.18", "canary"); + h.Watcher.TriggerCheckAutoUpdateFromTest(); + + var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected); + Assert.Equal("4.21.4", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousVersion]); + Assert.Equal("4.21.18", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ProductVersion]); + Assert.Equal("canary", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel]); + + var info = Assert.Single(h.Ingress.Posted, p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.RealmJoinAutoUpdateDetected); + Assert.Equal("4.21.4", info.Payload!["previousVersion"]); + Assert.Equal("4.21.18", info.Payload["newVersion"]); + Assert.Equal("canary", info.Payload["releaseChannel"]); + Assert.Contains("4.21.4 -> 4.21.18", info.Payload[SignalPayloadKeys.Message]); + + // Same version again (updater rewrote more values) — no duplicate. + h.Watcher.TriggerCheckAutoUpdateFromTest(); + Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected); + Assert.Equal("4.21.18", h.Watcher.BaselineVersionForTest); + } + + [Fact] + public void AutoUpdate_stays_silent_when_version_is_unchanged_or_unreadable() + { + using var h = new AutoUpdateHarness(); + h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "release"); + h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0); + + // Unchanged (e.g. the tray creating the Uninstall entry on first start). + h.Watcher.TriggerCheckAutoUpdateFromTest(); + // Unreadable (binary locked mid-swap, registry value not there yet) — baseline kept. + h.Versions.Info = RealmJoinVersionInfo.Empty; + h.Versions.DisplayVersion = null; + h.Watcher.TriggerCheckAutoUpdateFromTest(); + + Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected); + Assert.Equal("4.21.4", h.Watcher.BaselineVersionForTest); + } + + [Fact] + public void AutoUpdate_falls_back_to_Uninstall_DisplayVersion_and_keeps_baseline_channel() + { + using var h = new AutoUpdateHarness(); + h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "canary"); + h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0); + + h.Versions.Info = RealmJoinVersionInfo.Empty; // RealmJoin.exe still held by the updater + h.Versions.DisplayVersion = "4.21.18"; // registry already rewritten + h.Watcher.TriggerCheckAutoUpdateFromTest(); + + var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected); + Assert.Equal("4.21.18", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.ProductVersion]); + // DisplayVersion carries no channel tag — the Detected channel is the best evidence. + Assert.Equal("canary", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel]); + } + + [Fact] + public void AutoUpdate_seeds_baseline_silently_when_Detected_could_not_read_the_binary() + { + using var h = new AutoUpdateHarness(); + h.Versions.Info = RealmJoinVersionInfo.Empty; + h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0); + Assert.Null(h.Watcher.BaselineVersionForTest); + + // First successful read has nothing to compare against — no phantom update. + h.Versions.Info = new RealmJoinVersionInfo("4.21.18", "canary"); + h.Watcher.TriggerCheckAutoUpdateFromTest(); + Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected); + Assert.Equal("4.21.18", h.Watcher.BaselineVersionForTest); + + // A later genuine change is still reported against the seeded baseline. + h.Versions.Info = new RealmJoinVersionInfo("4.21.19", "canary"); + h.Watcher.TriggerCheckAutoUpdateFromTest(); + var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected); + Assert.Equal("4.21.18", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousVersion]); + } + } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs index 5bd5350f..9d536f55 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs @@ -102,6 +102,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals /// Defensive fallback for legacy 32-bit installs. public const string RealmJoinExePathX86 = @"C:\Program Files (x86)\RealmJoin\RealmJoin.exe"; + /// + /// Always-existing parent of the RJ Add/Remove-Programs entry — the appearance-watch + /// anchor when does not exist yet (the tray creates + /// the entry on its first start, after the service key is already present). + /// + public const string UninstallRootPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; + + /// Sub-key name of the RJ entry under . + public const string UninstallRealmJoinKeyName = "RealmJoin"; + + /// + /// RJ's Add/Remove-Programs entry. The in-app updater (RealmJoin.Core.AppUpdater) + /// rewrites this key (DisplayVersion, InstallDate) in the same second it + /// swaps RealmJoin.exe — the only registry write that reflects an RJ self-update. + /// Neither SOFTWARE\RealmJoin nor Services\realmjoin\Parameters carry a version. + /// + public const string UninstallRealmJoinPath = UninstallRootPath + @"\" + UninstallRealmJoinKeyName; + + /// Bare version string under , e.g. 4.21.18 (no channel tag). + public const string DisplayVersionValueName = "DisplayVersion"; + + /// + /// Best-effort read of DisplayVersion under . + /// Fallback for the auto-update watcher when RealmJoin.exe is momentarily locked by + /// the updater; carries no release channel. null when absent or unreadable. + /// + public static string? TryReadUninstallDisplayVersion() + { + try + { + using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64)) + using (var key = baseKey.OpenSubKey(UninstallRealmJoinPath, writable: false)) + { + var raw = key?.GetValue(DisplayVersionValueName) as string; + return string.IsNullOrWhiteSpace(raw) ? null : raw!.Trim(); + } + } + catch + { + return null; + } + } + /// /// Release-channel name reported when the version string carries no SemVer prerelease /// tag. Per the RJ developer, only beta/canary builds are tagged — untagged == stable. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs index fce339c8..1771e3ad 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs @@ -35,6 +35,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals } } + /// + /// RJ replaced its own binary after detection (tray AppUpdater "New version found …, + /// update required" on first start). is the version reported + /// on (or the last auto-update), + /// the version now installed. Observability-only — the deployment lifecycle is unaffected. + /// + public sealed class RealmJoinAutoUpdateDetectedEventArgs : EventArgs + { + public string PreviousVersion { get; } + public string NewVersion { get; } + /// Channel of the new build; null when only the registry fallback was readable. + public string? ReleaseChannel { get; } + + public RealmJoinAutoUpdateDetectedEventArgs(string previousVersion, string newVersion, string? releaseChannel) + { + PreviousVersion = previousVersion; + NewVersion = newVersion; + ReleaseChannel = releaseChannel; + } + } + public sealed class RealmJoinResolvedEventArgs : EventArgs { public int DeploymentPhase { get; } @@ -104,13 +125,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals internal delegate IReadOnlyList EnumeratePackageIdsDelegate(RegistryHive hive, string packagesPath); internal delegate bool TryReadPackageDelegate(RegistryHive hive, string packagesPath, string packageId, out RealmJoinPackageSnapshot snapshot); internal delegate bool KeyExistsDelegate(RegistryHive hive, string subPath); + internal delegate RealmJoinVersionInfo ReadVersionInfoDelegate(); + internal delegate string? ReadUninstallDisplayVersionDelegate(); + + /// + /// Debounce for the Uninstall-key watcher. Longer than : + /// the updater rewrites several values and swaps RealmJoin.exe in the same window, + /// so the version re-read should land after the binary is back in place. + /// + internal const int AutoUpdateDebounceMilliseconds = 2000; private readonly AgentLogger _logger; private readonly EnumeratePackageIdsDelegate _enumeratePackageIds; private readonly TryReadPackageDelegate _tryReadPackage; private readonly KeyExistsDelegate _keyExists; + private readonly ReadVersionInfoDelegate _readVersionInfo; + private readonly ReadUninstallDisplayVersionDelegate _readUninstallDisplayVersion; private readonly object _stateLock = new object(); + // Auto-update stage (armed once Detected fired, held until Stop). + private RegistryWatcher? _uninstallAppearanceWatcher; + private RegistryWatcher? _uninstallWatcher; + private Timer? _uninstallDebounce; + // Version baseline the next Uninstall-key change is compared against. Seeded from the + // Detected read; null when the binary was unreadable then — the first successful + // re-read seeds it silently instead of reporting a phantom update. + private string? _baselineVersion; + private string? _baselineChannel; + // Stage-1 parent-watchers (disposed on appearance). private RegistryWatcher? _servicesAppearanceWatcher; private RegistryWatcher? _machineSoftwareAppearanceWatcher; @@ -149,6 +191,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals public event EventHandler? RealmJoinPhaseChanged; public event EventHandler? RealmJoinPackageStarted; public event EventHandler? RealmJoinPackageCompleted; + public event EventHandler? RealmJoinAutoUpdateDetected; public RealmJoinWatcher(AgentLogger logger) : this(logger, RealmJoinInfo.EnumeratePackageIds, RealmJoinInfo.TryReadPackage, KeyExistsViaRegistry) @@ -159,12 +202,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals AgentLogger logger, EnumeratePackageIdsDelegate enumeratePackageIds, TryReadPackageDelegate tryReadPackage, - KeyExistsDelegate keyExists) + KeyExistsDelegate keyExists, + ReadVersionInfoDelegate? readVersionInfo = null, + ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _enumeratePackageIds = enumeratePackageIds ?? throw new ArgumentNullException(nameof(enumeratePackageIds)); _tryReadPackage = tryReadPackage ?? throw new ArgumentNullException(nameof(tryReadPackage)); _keyExists = keyExists ?? throw new ArgumentNullException(nameof(keyExists)); + _readVersionInfo = readVersionInfo ?? RealmJoinInfo.TryReadRealmJoinVersionInfo; + _readUninstallDisplayVersion = readUninstallDisplayVersion ?? RealmJoinInfo.TryReadUninstallDisplayVersion; } public void Start() @@ -224,14 +271,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals DisposeAndNull(ref _servicesAppearanceWatcher); DisposeAndNull(ref _machineSoftwareAppearanceWatcher); DisposeAndNull(ref _userSoftwareAppearanceWatcher); + DisposeAndNull(ref _uninstallAppearanceWatcher); _serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null; _machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null; _userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null; + _uninstallDebounce?.Dispose(); _uninstallDebounce = null; DisposeAndNull(ref _serviceRealmjoinWatcher); DisposeAndNull(ref _machineRealmJoinWatcher); DisposeAndNull(ref _userRealmJoinWatcher); + DisposeAndNull(ref _uninstallWatcher); } _logger.Info($"RealmJoinWatcher: stopped ({reason})"); } @@ -555,13 +605,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals if (fireDetected) { var initial = phase ?? 0; - // Read RJ version + release channel best-effort (fail-soft to null) ONLY when - // the Detected event actually fires — single read per session lifetime keeps - // any potential file-system cost to a single hit. - var versionInfo = RealmJoinInfo.TryReadRealmJoinVersionInfo(); + // Read RJ version + release channel best-effort (fail-soft to null) when the + // Detected event fires. The result seeds the auto-update baseline; later reads + // only happen when the Uninstall key changes (see CheckAutoUpdate). + var versionInfo = _readVersionInfo(); + lock (_stateLock) + { + _baselineVersion = versionInfo.ProductVersion; + _baselineChannel = versionInfo.ReleaseChannel; + } _logger.Info($"RealmJoinWatcher: RJ detected (phase={initial}, productVersion={versionInfo.ProductVersion ?? ""}, releaseChannel={versionInfo.ReleaseChannel ?? ""})"); try { RealmJoinDetected?.Invoke(this, new RealmJoinDetectedEventArgs(initial, versionInfo.ProductVersion, versionInfo.ReleaseChannel)); } catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinDetected handler threw", ex); } + + EnsureUninstallStage(); } if (firePhaseChange && prevPhase.HasValue && phase.HasValue) @@ -593,6 +650,115 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals } } + // ---- auto-update stage (Uninstall\RealmJoin) --------------------------------------- + + /// + /// Arm the RJ self-update observer. The tray's AppUpdater rewrites + /// whenever it swaps the binary — on a + /// first start with a stale MSI payload this happens minutes after Detected, so the + /// version reported on would otherwise be wrong for the + /// rest of the session. Same appearance-then-subtree pattern as the other stages; the + /// entry is created by the tray on first start, i.e. it may not exist at Detected time. + /// + private void EnsureUninstallStage() + { + if (_disposed) return; + if (_keyExists(RegistryHive.LocalMachine, RealmJoinInfo.UninstallRealmJoinPath)) + { + AttachUninstallWatcher(); + return; + } + ArmAppearanceParentWatcher( + role: "uninstall", + hive: RegistryHive.LocalMachine, + parentPath: RealmJoinInfo.UninstallRootPath, + targetSubKeyName: RealmJoinInfo.UninstallRealmJoinKeyName, + targetFullPath: RealmJoinInfo.UninstallRealmJoinPath, + slot: w => { lock (_stateLock) { _uninstallAppearanceWatcher = w; } }, + clearSlot: () => { lock (_stateLock) { _uninstallAppearanceWatcher = null; } }, + onAppeared: AttachUninstallWatcher); + } + + private void AttachUninstallWatcher() + { + lock (_stateLock) + { + if (_disposed || _uninstallWatcher != null) return; + try + { + _uninstallDebounce = new Timer(_ => CheckAutoUpdate(), null, Timeout.Infinite, Timeout.Infinite); + _uninstallWatcher = new RegistryWatcher( + RegistryHive.LocalMachine, + RealmJoinInfo.UninstallRealmJoinPath, + watchSubtree: false, + view: RegistryView.Registry64, + filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet, + trace: msg => _logger.Trace($"RealmJoinWatcher(uninstall): {msg}")); + _uninstallWatcher.Changed += (s, e) => _uninstallDebounce?.Change(AutoUpdateDebounceMilliseconds, Timeout.Infinite); + _uninstallWatcher.Error += (s, ex) => _logger.Warning($"RealmJoinWatcher(uninstall): {ex.Message}"); + _uninstallWatcher.Start(); + _logger.Info("RealmJoinWatcher: attached auto-update watcher on Uninstall\\RealmJoin"); + } + catch (Exception ex) + { + _logger.Warning($"RealmJoinWatcher: failed to attach Uninstall\\RealmJoin watcher: {ex.Message}"); + } + } + + // The key may already carry a newer version than the Detected read (update raced + // the appearance), so sweep once on attach — same as the other stages. + CheckAutoUpdate(); + } + + /// + /// Re-read the installed RJ version and fire + /// when it differs from the baseline. Primary source is the binary's file-version + /// resource (carries the channel); the Uninstall key's DisplayVersion is the + /// fallback while the updater still holds the file. Unreadable ⇒ no event, baseline kept. + /// + private void CheckAutoUpdate() + { + try + { + var info = _readVersionInfo(); + var newVersion = info.ProductVersion; + var newChannel = info.ReleaseChannel; + if (string.IsNullOrEmpty(newVersion)) + { + newVersion = _readUninstallDisplayVersion(); + newChannel = null; + if (string.IsNullOrEmpty(newVersion)) return; + } + + string? previousVersion; + string? channelForEvent; + lock (_stateLock) + { + previousVersion = _baselineVersion; + if (previousVersion == null) + { + // Detected could not read the binary — seed silently, nothing to compare. + _baselineVersion = newVersion; + _baselineChannel = newChannel; + return; + } + if (string.Equals(previousVersion, newVersion, StringComparison.OrdinalIgnoreCase)) return; + + channelForEvent = newChannel ?? _baselineChannel; + _baselineVersion = newVersion; + _baselineChannel = channelForEvent; + } + + _logger.Info($"RealmJoinWatcher: RJ auto-update detected ({previousVersion} -> {newVersion}, releaseChannel={channelForEvent ?? ""})"); + try { RealmJoinAutoUpdateDetected?.Invoke(this, new RealmJoinAutoUpdateDetectedEventArgs(previousVersion, newVersion!, channelForEvent)); } + catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinAutoUpdateDetected handler threw", ex); } + } + catch (Exception ex) + { + _logger.Warning($"RealmJoinWatcher: CheckAutoUpdate threw: {ex.Message}"); + } + } + private void CheckMachinePackages() { CheckPackages( @@ -766,6 +932,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals MaybeFirePackageEvents(RealmJoinPackageScope.User, snap, _hkuPackagesStarted, _hkuPackagesCompleted); internal void TriggerNotifyRealmJoinPresenceFromTest(int? phase) => NotifyRealmJoinPresence(phase); + internal void TriggerCheckAutoUpdateFromTest() => CheckAutoUpdate(); + internal string? BaselineVersionForTest { get { lock (_stateLock) { return _baselineVersion; } } } // Test seam — directly seed the dedup sets without touching the registry, so that a // subsequent MaybeFirePackageEvents observation can verify suppression behavior. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs index 69d3d109..0ea0e452 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs @@ -21,6 +21,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters { private const string SourceLabel = "RealmJoinWatcher"; private const string RegistryKeyHint = @"HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters"; + private const string UninstallRegistryKeyHint = @"HKLM\" + RealmJoinInfo.UninstallRealmJoinPath; private readonly RealmJoinWatcher _watcher; private readonly ISignalIngressSink _ingress; @@ -39,6 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _watcher.RealmJoinPhaseChanged += OnPhaseChanged; _watcher.RealmJoinPackageStarted += OnPackageStarted; _watcher.RealmJoinPackageCompleted += OnPackageCompleted; + _watcher.RealmJoinAutoUpdateDetected += OnAutoUpdateDetected; } public void Dispose() @@ -48,6 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _watcher.RealmJoinPhaseChanged -= OnPhaseChanged; _watcher.RealmJoinPackageStarted -= OnPackageStarted; _watcher.RealmJoinPackageCompleted -= OnPackageCompleted; + _watcher.RealmJoinAutoUpdateDetected -= OnAutoUpdateDetected; } // Test seams @@ -57,6 +60,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters internal void TriggerPhaseChangedFromTest(int prev, int curr) => OnPhaseChanged(this, new RealmJoinPhaseChangedEventArgs(prev, curr)); internal void TriggerPackageStartedFromTest(string scope, RealmJoinPackageSnapshot snap) => OnPackageStarted(this, new RealmJoinPackageEventArgs(scope, snap)); internal void TriggerPackageCompletedFromTest(string scope, RealmJoinPackageSnapshot snap) => OnPackageCompleted(this, new RealmJoinPackageEventArgs(scope, snap)); + internal void TriggerAutoUpdateDetectedFromTest(string previousVersion, string newVersion, string? releaseChannel) => + OnAutoUpdateDetected(this, new RealmJoinAutoUpdateDetectedEventArgs(previousVersion, newVersion, releaseChannel)); private void OnDetected(object? sender, RealmJoinDetectedEventArgs e) { @@ -177,6 +182,56 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters occurredAtUtc: _clock.UtcNow); } + private void OnAutoUpdateDetected(object? sender, RealmJoinAutoUpdateDetectedEventArgs e) + { + // Dual-emission: (1) the typed signal overrides the persisted version facts so + // final-status / replay carry the build that ran the deployment; (2) the timeline + // entry makes the self-update visible next to realmjoin_detected. + var payload = new Dictionary(StringComparer.Ordinal) + { + [DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = e.PreviousVersion, + [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = e.NewVersion, + }; + if (!string.IsNullOrEmpty(e.ReleaseChannel)) + { + payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = e.ReleaseChannel!; + } + + _ingress.Post( + kind: DecisionSignalKind.RealmJoinAutoUpdateDetected, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: SourceLabel, + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "realmjoin-autoupdate-detected-v1", + summary: $"RealmJoin self-update observed ({e.PreviousVersion} -> {e.NewVersion}, releaseChannel={e.ReleaseChannel ?? ""})", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["registryKey"] = UninstallRegistryKeyHint, + }), + payload: payload); + + var data = new Dictionary(StringComparer.Ordinal) + { + ["previousVersion"] = e.PreviousVersion, + ["newVersion"] = e.NewVersion, + ["registryKey"] = UninstallRegistryKeyHint, + }; + if (!string.IsNullOrEmpty(e.ReleaseChannel)) + { + data["releaseChannel"] = e.ReleaseChannel!; + } + var channelTail = string.IsNullOrEmpty(e.ReleaseChannel) ? string.Empty : $", channel={e.ReleaseChannel}"; + _post.Emit( + eventType: SharedConstants.EventTypes.RealmJoinAutoUpdateDetected, + source: SourceLabel, + message: $"RealmJoin auto-updated {e.PreviousVersion} -> {e.NewVersion}{channelTail}", + severity: EventSeverity.Info, + immediateUpload: true, + data: data, + occurredAtUtc: _clock.UtcNow); + } + private void OnPackageStarted(object? sender, RealmJoinPackageEventArgs e) { var displayName = TruncateDisplayName(e.DisplayName); diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs index f8a08f3d..73d0475e 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs @@ -837,5 +837,73 @@ namespace AutopilotMonitor.DecisionCore.Tests Assert.True(data.ContainsKey("realmjoinDetectedUtc")); Assert.True(data.ContainsKey("realmjoinResolvedUtc")); } + + [Fact] + public void RealmJoinAutoUpdateDetected_overrides_version_facts_without_touching_gate_or_deadline() + { + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + + var detected = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0", + [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.4", + [DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "canary", + })); + var deadlineBefore = Assert.Single(detected.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout); + + // Channel omitted (registry DisplayVersion fallback) — the Detected channel must survive. + var updated = engine.Reduce(detected.NewState, MakeSignal(6, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(14), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.4", + [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.18", + })); + + Assert.Null(updated.Transition.DeadEndReason); + Assert.Equal("4.21.18", updated.NewState.RealmJoinFacts.ProductVersion!.Value); + Assert.Equal("canary", updated.NewState.RealmJoinFacts.ReleaseChannel!.Value); + Assert.Equal(detected.NewState.Stage, updated.NewState.Stage); + Assert.False(DecisionEngine.RealmJoinGateOpen(updated.NewState)); + var deadlineAfter = Assert.Single(updated.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout); + Assert.Equal(deadlineBefore.DueAtUtc, deadlineAfter.DueAtUtc); + Assert.Null(updated.NewState.RealmJoinFacts.LastActivityUtc); + Assert.Empty(updated.Effects); + + // Channel supplied — overrides too. + var again = engine.Reduce(updated.NewState, MakeSignal(7, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(15), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.18", + [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.22.0", + [DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "release", + })); + Assert.Equal("4.22.0", again.NewState.RealmJoinFacts.ProductVersion!.Value); + Assert.Equal("release", again.NewState.RealmJoinFacts.ReleaseChannel!.Value); + } + + [Fact] + public void RealmJoinAutoUpdateDetected_without_version_dead_ends_and_keeps_facts() + { + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + var detected = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0", + [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.4", + })); + + var step = engine.Reduce(detected.NewState, MakeSignal(6, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(14), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.4", + })); + + Assert.Equal("realmjoin_autoupdate_missing_version", step.Transition.DeadEndReason); + Assert.Equal("4.21.4", step.NewState.RealmJoinFacts.ProductVersion!.Value); + } + } } diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index 73a67f99..a2ba772f 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -128,6 +128,7 @@ export const EVENT_TYPES_CATALOG = { 'realmjoin_first_deployment_incomplete', 'realmjoin_package_started', 'realmjoin_package_completed', + 'realmjoin_autoupdate_detected', ], deployment_events: [ 'modern_deployment_log', diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs index 864b0e6a..a3b627b9 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs @@ -55,6 +55,9 @@ namespace AutopilotMonitor.DecisionCore.Engine // — the SemVer prerelease tag, absent tag == stable release). public const string ProductVersion = "productVersion"; public const string ReleaseChannel = "releaseChannel"; + // Set on RealmJoinAutoUpdateDetected: the version the facts carried before the + // self-update (ProductVersion/ReleaseChannel then describe the NEW build). + public const string PreviousVersion = "previousVersion"; } /// @@ -387,6 +390,47 @@ namespace AutopilotMonitor.DecisionCore.Engine /// the realmjoin_first_deployment_incomplete Warning, cancels the hard-timeout /// deadline and releases the completion gate — for us RJ is finished at that point. /// + /// + /// RJ self-update observed after detection. Overrides the set-once version facts so + /// / + /// describe the build that actually ran the deployment. Pure bookkeeping: no gate, no + /// deadline, no activity credit (the update precedes the deployment, it is not + /// deployment progress). Missing productVersion dead-ends. + /// + private DecisionStep HandleRealmJoinAutoUpdateDetectedV1(DecisionState state, DecisionSignal signal) + { + var newVersion = TryReadString(signal, RealmJoinPayloadKeys.ProductVersion); + if (string.IsNullOrEmpty(newVersion)) + { + var deadEnd = BumpStepBookkeeping(state, signal); + var deadEndTransition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: deadEnd.StepIndex, + trigger: nameof(DecisionSignalKind.RealmJoinAutoUpdateDetected), + deadEndReason: "realmjoin_autoupdate_missing_version"); + return new DecisionStep(deadEnd, deadEndTransition, Array.Empty()); + } + + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + builder.RealmJoinFacts = state.RealmJoinFacts.WithVersionOverride( + newVersion!, + TryReadString(signal, RealmJoinPayloadKeys.ReleaseChannel), + signal.SessionSignalOrdinal); + + var bookkept = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.RealmJoinAutoUpdateDetected)); + return new DecisionStep(bookkept, transition, Array.Empty()); + } + private DecisionStep HandleRealmJoinPhaseChangedV1(DecisionState state, DecisionSignal signal) { var currentPhase = TryReadPhase(signal); diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs index 2a96bb78..cd4c3230 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -144,6 +144,7 @@ namespace AutopilotMonitor.DecisionCore.Engine (DecisionSignalKind.RealmJoinDetected, 1) => HandleRealmJoinDetectedV1(state, signal), (DecisionSignalKind.RealmJoinResolved, 1) => HandleRealmJoinResolvedV1(state, signal), (DecisionSignalKind.RealmJoinPhaseChanged, 1) => HandleRealmJoinPhaseChangedV1(state, signal), + (DecisionSignalKind.RealmJoinAutoUpdateDetected, 1) => HandleRealmJoinAutoUpdateDetectedV1(state, signal), (DecisionSignalKind.RealmJoinPackageStarted, 1) => HandleRealmJoinPackageStartedV1(state, signal), (DecisionSignalKind.RealmJoinPackageCompleted, 1) => HandleRealmJoinPackageCompletedV1(state, signal), // Note: RealmJoinTimeout is a deadline-only kind (no direct adapter signal) — diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs index a87ba5c1..6a224f75 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs @@ -125,5 +125,11 @@ namespace AutopilotMonitor.DecisionCore.Signals // the run as a secondary-user deployment, which writes 200/210 and NEVER 110 — the // gate would otherwise stay closed until the 60-min hard timeout. RealmJoinPhaseChanged, + // RJ replaced its own binary after detection (tray AppUpdater on first start: "New + // version found …, update required" → service restarts on the new build minutes after + // RealmJoinDetected captured the MSI-shipped version). Bookkeeping-only: overrides + // RealmJoinFacts.ProductVersion/ReleaseChannel so the persisted facts match what + // actually ran the deployment. Never touches the gate or the timeout deadline. + RealmJoinAutoUpdateDetected, } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs index 695908b8..0d6927d3 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs @@ -167,6 +167,29 @@ namespace AutopilotMonitor.DecisionCore.State lastActivityUtc: LastActivityUtc); } + /// + /// Auto-update override of the set-once version facts ( / + /// deliberately refuse replays of Detected; a self-update + /// is the one legitimate later write). Empty is a no-op; + /// a null keeps the existing channel fact. + /// + public RealmJoinFacts WithVersionOverride(string productVersion, string? releaseChannel, long sourceSignalOrdinal) + { + if (string.IsNullOrEmpty(productVersion)) return this; + return new RealmJoinFacts( + detectedUtc: DetectedUtc, + resolvedUtc: ResolvedUtc, + lastDeploymentPhase: LastDeploymentPhase, + outcome: Outcome, + selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + productVersion: new SignalFact(productVersion, sourceSignalOrdinal), + releaseChannel: string.IsNullOrEmpty(releaseChannel) + ? ReleaseChannel + : new SignalFact(releaseChannel!, sourceSignalOrdinal), + packages: Packages, + lastActivityUtc: LastActivityUtc); + } + public RealmJoinFacts WithResolved(DateTime utc, int phase, long sourceSignalOrdinal) { if (ResolvedUtc != null) return this; // set-once diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index a1a1974d..0d58643c 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -547,6 +547,11 @@ namespace AutopilotMonitor.Shared public const string RealmJoinFirstDeploymentIncomplete = "realmjoin_first_deployment_incomplete"; public const string RealmJoinPackageStarted = "realmjoin_package_started"; public const string RealmJoinPackageCompleted = "realmjoin_package_completed"; + // Info: RJ replaced its own binary after realmjoin_detected (tray AppUpdater on + // first start). Data: previousVersion, newVersion, releaseChannel. Source of truth + // for the version that ran the deployment — realmjoin_detected carries the + // MSI-shipped build. + public const string RealmJoinAutoUpdateDetected = "realmjoin_autoupdate_detected"; // ===================================================================== // Single-source consolidation (2026-05-29): event-type string literals diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx index 26f9eabe..92f34495 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx @@ -21,6 +21,7 @@ interface ProxyConfigData { [key: string]: unknown; proxyType?: string; type?: s interface AutopilotProfileData { [key: string]: unknown; PolicyDownloadDate?: string; AutopilotCreationDate?: string; autopilotModeLabel?: string; domainJoinMethodLabel?: string } interface ImeVersionData { [key: string]: unknown; version?: string; agentVersion?: string } interface RealmJoinInfoData { [key: string]: unknown; productVersion?: string; releaseChannel?: string } +interface RealmJoinAutoUpdateData { [key: string]: unknown; previousVersion?: string; newVersion?: string; releaseChannel?: string } interface TpmStatusData { [key: string]: unknown; specVersion?: string } interface BitLockerStatusData { [key: string]: unknown; volumes?: unknown[] } interface DeviceLocationData { [key: string]: unknown; country?: string; Country?: string; timezone?: string; Timezone?: string } @@ -189,7 +190,17 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session session?.isCloudPc === true || `${autopilotProfileMissingEvent?.isCloudPc}` === "true"; const aadJoinStatus = getEventData("aad_join_status"); const imeVersion = getEventData("ime_agent_version"); - const realmJoinInfo = getEventData("realmjoin_detected"); + // realmjoin_detected carries the MSI-shipped build; the tray often self-updates minutes later + // (realmjoin_autoupdate_detected). The latest auto-update is the version that ran the deployment. + const realmJoinDetected = getEventData("realmjoin_detected"); + const realmJoinAutoUpdate = getEventData("realmjoin_autoupdate_detected"); + const realmJoinInfo: RealmJoinInfoData | null = realmJoinAutoUpdate?.newVersion + ? { productVersion: realmJoinAutoUpdate.newVersion, releaseChannel: realmJoinAutoUpdate.releaseChannel ?? realmJoinDetected?.releaseChannel } + : realmJoinDetected; + const realmJoinInitialVersion = + realmJoinAutoUpdate?.newVersion && realmJoinDetected?.productVersion && realmJoinDetected.productVersion !== realmJoinAutoUpdate.newVersion + ? realmJoinDetected.productVersion + : null; const bitLockerStatus = getEventData("bitlocker_status"); const secureBootStatus = getEventData("secureboot_status"); const tpmStatus = getEventData("tpm_status"); @@ -478,9 +489,10 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session )} diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index 65475958..3e264171 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -623,6 +623,7 @@ export const SHARED_MANIFEST = { "prior_run_died_with_state", "provisioning_package_scan", "proxy_configuration", + "realmjoin_autoupdate_detected", "realmjoin_detected", "realmjoin_first_deployment_incomplete", "realmjoin_package_completed", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 477d3630..cb4a6148 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -619,6 +619,7 @@ "prior_run_died_with_state", "provisioning_package_scan", "proxy_configuration", + "realmjoin_autoupdate_detected", "realmjoin_detected", "realmjoin_first_deployment_incomplete", "realmjoin_package_completed", diff --git a/tests/fixtures/signal-kinds/realmjoin-autoupdate-detected-v1.json b/tests/fixtures/signal-kinds/realmjoin-autoupdate-detected-v1.json new file mode 100644 index 00000000..2e18329d --- /dev/null +++ b/tests/fixtures/signal-kinds/realmjoin-autoupdate-detected-v1.json @@ -0,0 +1,7 @@ +# RealmJoinAutoUpdateDetected v1 — RealmJoin self-update after detection. +# Posted by RealmJoinWatcherAdapter when the HKLM\...\Uninstall\RealmJoin key changes and the +# re-read RealmJoin.exe version differs from the one reported on RealmJoinDetected (tray +# AppUpdater on first start; gktatooine session 946ccbd6: 4.21.4 -> 4.21.18 nine minutes after +# detection). Reducer: HandleRealmJoinAutoUpdateDetectedV1 → overrides +# RealmJoinFacts.ProductVersion / ReleaseChannel. Bookkeeping-only: no gate, no deadline. +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"RealmJoinAutoUpdateDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-08-30T13:33:27.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-autoupdate-detected-v1","Summary":"RealmJoin self-update observed (4.21.4 -> 4.21.18, releaseChannel=canary)","DerivationInputs":{"registryKey":"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\RealmJoin"}},"Payload":{"previousVersion":"4.21.4","productVersion":"4.21.18","releaseChannel":"canary"}}