mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(agent): RealmJoin self-update is observed and reported as realmjoin_autoupdate_detected — realmjoin_detected read the version once from RealmJoin.exe, but the tray AppUpdater often replaces the MSI-shipped binary on its first start minutes after detection (gktatooine 946ccbd6: 4.21.4 -> 4.21.18, whole deployment ran on the new build while the timeline and RealmJoinFacts kept 4.21.4); the updater's only registry write is HKLM\...\Uninstall\RealmJoin (DisplayVersion/InstallDate), so RealmJoinWatcher arms a RegNotifyChangeKeyValue watcher there once Detected fired (appearance-watch on the Uninstall parent until the tray creates the entry), re-reads the binary version after a 2 s debounce (DisplayVersion fallback while the file is locked, channel from baseline) and emits the Info event {previousVersion,newVersion,releaseChannel} plus the typed RealmJoinAutoUpdateDetected signal, whose reducer overrides RealmJoinFacts.ProductVersion/ReleaseChannel (WithVersionOverride) and touches neither gate, deadline nor LastActivityUtc; unreadable baseline at detection is seeded silently; portal device card prefers the updated version ("auto-updated from X"); event registered in Constants/resource-catalog/shared-manifests with replay fixture and watcher/adapter/reducer tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+133
@@ -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]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -102,6 +102,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <summary>Defensive fallback for legacy 32-bit installs.</summary>
|
||||
public const string RealmJoinExePathX86 = @"C:\Program Files (x86)\RealmJoin\RealmJoin.exe";
|
||||
|
||||
/// <summary>
|
||||
/// Always-existing parent of the RJ Add/Remove-Programs entry — the appearance-watch
|
||||
/// anchor when <see cref="UninstallRealmJoinPath"/> does not exist yet (the tray creates
|
||||
/// the entry on its first start, after the service key is already present).
|
||||
/// </summary>
|
||||
public const string UninstallRootPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
|
||||
/// <summary>Sub-key name of the RJ entry under <see cref="UninstallRootPath"/>.</summary>
|
||||
public const string UninstallRealmJoinKeyName = "RealmJoin";
|
||||
|
||||
/// <summary>
|
||||
/// RJ's Add/Remove-Programs entry. The in-app updater (<c>RealmJoin.Core.AppUpdater</c>)
|
||||
/// rewrites this key (<c>DisplayVersion</c>, <c>InstallDate</c>) in the same second it
|
||||
/// swaps <c>RealmJoin.exe</c> — the only registry write that reflects an RJ self-update.
|
||||
/// Neither <c>SOFTWARE\RealmJoin</c> nor <c>Services\realmjoin\Parameters</c> carry a version.
|
||||
/// </summary>
|
||||
public const string UninstallRealmJoinPath = UninstallRootPath + @"\" + UninstallRealmJoinKeyName;
|
||||
|
||||
/// <summary>Bare version string under <see cref="UninstallRealmJoinPath"/>, e.g. <c>4.21.18</c> (no channel tag).</summary>
|
||||
public const string DisplayVersionValueName = "DisplayVersion";
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort read of <c>DisplayVersion</c> under <see cref="UninstallRealmJoinPath"/>.
|
||||
/// Fallback for the auto-update watcher when <c>RealmJoin.exe</c> is momentarily locked by
|
||||
/// the updater; carries no release channel. <c>null</c> when absent or unreadable.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
|
||||
+173
-5
@@ -35,6 +35,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RJ replaced its own binary after detection (tray <c>AppUpdater</c> "New version found …,
|
||||
/// update required" on first start). <see cref="PreviousVersion"/> is the version reported
|
||||
/// on <see cref="RealmJoinDetectedEventArgs"/> (or the last auto-update), <see cref="NewVersion"/>
|
||||
/// the version now installed. Observability-only — the deployment lifecycle is unaffected.
|
||||
/// </summary>
|
||||
public sealed class RealmJoinAutoUpdateDetectedEventArgs : EventArgs
|
||||
{
|
||||
public string PreviousVersion { get; }
|
||||
public string NewVersion { get; }
|
||||
/// <summary>Channel of the new build; <c>null</c> when only the registry fallback was readable.</summary>
|
||||
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<string> 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();
|
||||
|
||||
/// <summary>
|
||||
/// Debounce for the Uninstall-key watcher. Longer than <see cref="DebounceMilliseconds"/>:
|
||||
/// the updater rewrites several values and swaps <c>RealmJoin.exe</c> in the same window,
|
||||
/// so the version re-read should land after the binary is back in place.
|
||||
/// </summary>
|
||||
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<RealmJoinPhaseChangedEventArgs>? RealmJoinPhaseChanged;
|
||||
public event EventHandler<RealmJoinPackageEventArgs>? RealmJoinPackageStarted;
|
||||
public event EventHandler<RealmJoinPackageEventArgs>? RealmJoinPackageCompleted;
|
||||
public event EventHandler<RealmJoinAutoUpdateDetectedEventArgs>? 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 ?? "<unknown>"}, releaseChannel={versionInfo.ReleaseChannel ?? "<unknown>"})");
|
||||
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) ---------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Arm the RJ self-update observer. The tray's <c>AppUpdater</c> rewrites
|
||||
/// <see cref="RealmJoinInfo.UninstallRealmJoinPath"/> whenever it swaps the binary — on a
|
||||
/// first start with a stale MSI payload this happens minutes after Detected, so the
|
||||
/// version reported on <see cref="RealmJoinDetected"/> 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-read the installed RJ version and fire <see cref="RealmJoinAutoUpdateDetected"/>
|
||||
/// when it differs from the baseline. Primary source is the binary's file-version
|
||||
/// resource (carries the channel); the Uninstall key's <c>DisplayVersion</c> is the
|
||||
/// fallback while the updater still holds the file. Unreadable ⇒ no event, baseline kept.
|
||||
/// </summary>
|
||||
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 ?? "<unknown>"})");
|
||||
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.
|
||||
|
||||
@@ -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<string, string>(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 ?? "<unknown>"})",
|
||||
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["registryKey"] = UninstallRegistryKeyHint,
|
||||
}),
|
||||
payload: payload);
|
||||
|
||||
var data = new Dictionary<string, string>(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);
|
||||
|
||||
@@ -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<string, string>
|
||||
{
|
||||
[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<string, string>
|
||||
{
|
||||
[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<string, string>
|
||||
{
|
||||
[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<string, string>
|
||||
{
|
||||
[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<string, string>
|
||||
{
|
||||
[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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -387,6 +390,47 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// the <c>realmjoin_first_deployment_incomplete</c> Warning, cancels the hard-timeout
|
||||
/// deadline and releases the completion gate — for us RJ is finished at that point.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// RJ self-update observed after detection. Overrides the set-once version facts so
|
||||
/// <see cref="RealmJoinFacts.ProductVersion"/> / <see cref="RealmJoinFacts.ReleaseChannel"/>
|
||||
/// 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 <c>productVersion</c> dead-ends.
|
||||
/// </summary>
|
||||
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<DecisionEffect>());
|
||||
}
|
||||
|
||||
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<DecisionEffect>());
|
||||
}
|
||||
|
||||
private DecisionStep HandleRealmJoinPhaseChangedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var currentPhase = TryReadPhase(signal);
|
||||
|
||||
@@ -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) —
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,29 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-update override of the set-once version facts (<see cref="WithProductVersion"/> /
|
||||
/// <see cref="WithReleaseChannel"/> deliberately refuse replays of Detected; a self-update
|
||||
/// is the one legitimate later write). Empty <paramref name="productVersion"/> is a no-op;
|
||||
/// a <c>null</c> <paramref name="releaseChannel"/> keeps the existing channel fact.
|
||||
/// </summary>
|
||||
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<string>(productVersion, sourceSignalOrdinal),
|
||||
releaseChannel: string.IsNullOrEmpty(releaseChannel)
|
||||
? ReleaseChannel
|
||||
: new SignalFact<string>(releaseChannel!, sourceSignalOrdinal),
|
||||
packages: Packages,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
public RealmJoinFacts WithResolved(DateTime utc, int phase, long sourceSignalOrdinal)
|
||||
{
|
||||
if (ResolvedUtc != null) return this; // set-once
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ImeVersionData>("ime_agent_version");
|
||||
const realmJoinInfo = getEventData<RealmJoinInfoData>("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<RealmJoinInfoData>("realmjoin_detected");
|
||||
const realmJoinAutoUpdate = getEventData<RealmJoinAutoUpdateData>("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<BitLockerStatusData>("bitlocker_status");
|
||||
const secureBootStatus = getEventData("secureboot_status");
|
||||
const tpmStatus = getEventData<TpmStatusData>("tpm_status");
|
||||
@@ -478,9 +489,10 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
<DetailRow
|
||||
label="RealmJoin Agent Version"
|
||||
value={
|
||||
realmJoinInfo.releaseChannel && realmJoinInfo.releaseChannel !== "release"
|
||||
(realmJoinInfo.releaseChannel && realmJoinInfo.releaseChannel !== "release"
|
||||
? `${realmJoinInfo.productVersion} (${realmJoinInfo.releaseChannel})`
|
||||
: realmJoinInfo.productVersion
|
||||
: realmJoinInfo.productVersion!) +
|
||||
(realmJoinInitialVersion ? ` — auto-updated from ${realmJoinInitialVersion}` : "")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}}
|
||||
Reference in New Issue
Block a user