diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/RealmJoinInfoParseTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/RealmJoinInfoParseTests.cs new file mode 100644 index 00000000..8ec36c53 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/RealmJoinInfoParseTests.cs @@ -0,0 +1,67 @@ +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals +{ + /// + /// Parser tests for . The RJ binary's + /// FileVersion string resource carries a full SemVer where the prerelease tag IS the + /// release channel: "4.21.6-canary+476277.d320cac0" (canary/beta) vs "4.21.6" (stable + /// release — confirmed by the RJ developer that release builds carry no tag). + /// + public sealed class RealmJoinInfoParseTests + { + [Theory] + [InlineData("4.21.6-canary+476277.d320cac0", "4.21.6", "canary")] + [InlineData("4.21.6-beta+123456.abcdef00", "4.21.6", "beta")] + [InlineData("4.21.6-canary", "4.21.6", "canary")] + public void Tagged_version_yields_channel_from_prerelease_tag(string raw, string expectedVersion, string expectedChannel) + { + var info = RealmJoinInfo.ParseVersionAndChannel(raw); + + Assert.Equal(expectedVersion, info.ProductVersion); + Assert.Equal(expectedChannel, info.ReleaseChannel); + } + + [Theory] + [InlineData("4.21.6")] + [InlineData("4.21.6+476277.d320cac0")] + [InlineData(" 4.21.6 ")] + public void Untagged_version_yields_stable_release_channel(string raw) + { + var info = RealmJoinInfo.ParseVersionAndChannel(raw); + + Assert.Equal("4.21.6", info.ProductVersion); + Assert.Equal(RealmJoinInfo.ReleaseChannelStable, info.ReleaseChannel); + } + + [Fact] + public void Build_metadata_is_dropped_from_both_fields() + { + var info = RealmJoinInfo.ParseVersionAndChannel("4.21.6-canary+476277.d320cac0"); + + Assert.DoesNotContain("+", info.ProductVersion); + Assert.DoesNotContain("476277", info.ReleaseChannel); + } + + [Fact] + public void Trailing_dash_without_tag_falls_back_to_stable_channel() + { + // Defensive: a malformed "4.21.6-" must not produce an empty channel string. + var info = RealmJoinInfo.ParseVersionAndChannel("4.21.6-"); + + Assert.Equal("4.21.6", info.ProductVersion); + Assert.Equal(RealmJoinInfo.ReleaseChannelStable, info.ReleaseChannel); + } + + [Fact] + public void Empty_after_metadata_strip_yields_null_fields() + { + // Defensive: a string that is ONLY build metadata carries no version information. + var info = RealmJoinInfo.ParseVersionAndChannel("+476277.d320cac0"); + + Assert.Null(info.ProductVersion); + Assert.Null(info.ReleaseChannel); + } + } +} 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 2f25a91f..3c2c9ace 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs @@ -85,6 +85,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Contains("version=3.5.21.0", info.Payload[SignalPayloadKeys.Message]); } + [Fact] + public void TriggerDetectedFromTest_includes_ReleaseChannel_when_provided() + { + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerDetectedFromTest(phase: 100, productVersion: "4.21.6", releaseChannel: "canary"); + + var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinDetected); + Assert.Equal("4.21.6", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.ProductVersion]); + Assert.Equal("canary", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel]); + + var info = f.Ingress.Posted.Single(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.RealmJoinDetected); + Assert.Equal("canary", info.Payload!["releaseChannel"]); + Assert.Contains("version=4.21.6", info.Payload[SignalPayloadKeys.Message]); + Assert.Contains("channel=canary", info.Payload[SignalPayloadKeys.Message]); + } + [Fact] public void TriggerDetectedFromTest_omits_ProductVersion_when_null_or_empty() { @@ -95,6 +117,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinDetected); Assert.False(decision.Payload!.ContainsKey(DecisionEngine.RealmJoinPayloadKeys.ProductVersion)); + Assert.False(decision.Payload.ContainsKey(DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel)); var info = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent @@ -102,7 +125,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) && et == SharedEventTypes.RealmJoinDetected); Assert.False(info.Payload!.ContainsKey("productVersion")); + Assert.False(info.Payload.ContainsKey("releaseChannel")); Assert.DoesNotContain("version=", info.Payload[SignalPayloadKeys.Message]); + Assert.DoesNotContain("channel=", info.Payload[SignalPayloadKeys.Message]); } [Fact] 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 23c7e4e6..5bd5350f 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 @@ -103,25 +103,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals public const string RealmJoinExePathX86 = @"C:\Program Files (x86)\RealmJoin\RealmJoin.exe"; /// - /// Best-effort read of RealmJoin.exe's ProductVersion via + /// 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. + /// + public const string ReleaseChannelStable = "release"; + + /// + /// Best-effort read of RealmJoin.exe's version + release channel via /// . Wrapped in a defensive try/catch so a - /// missing/locked/inaccessible binary returns null instead of throwing — + /// missing/locked/inaccessible binary returns an empty result instead of throwing — /// version is observability-only and must never block the detection signal. Probes the /// canonical 64-bit path first, then the (x86) fallback. /// - public static string? TryReadRealmJoinProductVersion() + public static RealmJoinVersionInfo TryReadRealmJoinVersionInfo() { - return TryReadVersion(RealmJoinExePath) ?? TryReadVersion(RealmJoinExePathX86); + return TryReadVersionInfo(RealmJoinExePath) ?? TryReadVersionInfo(RealmJoinExePathX86) ?? RealmJoinVersionInfo.Empty; } - private static string? TryReadVersion(string path) + private static RealmJoinVersionInfo? TryReadVersionInfo(string path) { try { if (!File.Exists(path)) return null; var info = FileVersionInfo.GetVersionInfo(path); - var version = info.ProductVersion; - return string.IsNullOrWhiteSpace(version) ? info.FileVersion : version; + // RJ inverts the usual .NET SDK resource layout: the FileVersion STRING entry + // carries the full SemVer including the channel tag and build metadata + // ("4.21.6-canary+476277.d320cac0") while the ProductVersion string is the + // channel-less "4.21.6". Prefer FileVersion, fall back to ProductVersion. + var raw = !string.IsNullOrWhiteSpace(info.FileVersion) ? info.FileVersion : info.ProductVersion; + if (string.IsNullOrWhiteSpace(raw)) return null; + return ParseVersionAndChannel(raw!); } catch { @@ -129,6 +140,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals } } + /// + /// Split a SemVer-shaped version string (version[-prerelease][+buildmetadata]) + /// into the bare version and the RJ release channel. The prerelease tag IS the channel + /// ("beta", "canary"); absence of a tag means . + /// Build metadata is dropped. + /// + internal static RealmJoinVersionInfo ParseVersionAndChannel(string raw) + { + var value = raw.Trim(); + var plus = value.IndexOf('+'); + if (plus >= 0) value = value.Substring(0, plus); + + var dash = value.IndexOf('-'); + if (dash < 0) + { + return new RealmJoinVersionInfo( + productVersion: value.Length == 0 ? null : value, + releaseChannel: value.Length == 0 ? null : ReleaseChannelStable); + } + + var version = value.Substring(0, dash).Trim(); + var channel = value.Substring(dash + 1).Trim(); + return new RealmJoinVersionInfo( + productVersion: version.Length == 0 ? null : version, + releaseChannel: channel.Length == 0 ? ReleaseChannelStable : channel); + } + /// /// Read the DeploymentPhase DWORD from the Parameters key. Returns null when the /// key/value does not exist or is unreadable. @@ -225,6 +263,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals } } + /// + /// Version + release channel of the installed RealmJoin.exe, parsed from its + /// file-version resource by . + /// Both fields are null when the binary is missing or unreadable. + /// + internal readonly struct RealmJoinVersionInfo + { + public static readonly RealmJoinVersionInfo Empty = new RealmJoinVersionInfo(null, null); + + public RealmJoinVersionInfo(string? productVersion, string? releaseChannel) + { + ProductVersion = productVersion; + ReleaseChannel = releaseChannel; + } + + /// Bare version without prerelease tag or build metadata, e.g. 4.21.6. + public string? ProductVersion { get; } + + /// + /// SemVer prerelease tag ("beta", "canary") or + /// when the version string carries no tag. + /// + public string? ReleaseChannel { get; } + } + /// /// Read-only snapshot of a single RealmJoin package sub-key. Built by /// ; consumed by RealmJoinWatcher to 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 17f4c330..fce339c8 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 @@ -13,16 +13,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals { public int DeploymentPhase { get; } /// - /// ProductVersion read from C:\Program Files\RealmJoin\RealmJoin.exe - /// (or the x86 fallback). null when the binary is missing, locked, or read - /// fails — version is observability-only and never blocks detection. + /// Bare version parsed from C:\Program Files\RealmJoin\RealmJoin.exe's + /// file-version resource (or the x86 fallback), e.g. 4.21.6. null when + /// the binary is missing, locked, or read fails — version is observability-only and + /// never blocks detection. /// public string? ProductVersion { get; } - public RealmJoinDetectedEventArgs(int deploymentPhase, string? productVersion = null) + /// + /// RJ release channel parsed from the version string's SemVer prerelease tag + /// ("beta", "canary"); when untagged; + /// null when the version was unreadable. + /// + public string? ReleaseChannel { get; } + + public RealmJoinDetectedEventArgs(int deploymentPhase, string? productVersion = null, string? releaseChannel = null) { DeploymentPhase = deploymentPhase; ProductVersion = productVersion; + ReleaseChannel = releaseChannel; } } @@ -546,12 +555,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals if (fireDetected) { var initial = phase ?? 0; - // Read RJ ProductVersion 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 productVersion = RealmJoinInfo.TryReadRealmJoinProductVersion(); - _logger.Info($"RealmJoinWatcher: RJ detected (phase={initial}, productVersion={productVersion ?? ""})"); - try { RealmJoinDetected?.Invoke(this, new RealmJoinDetectedEventArgs(initial, productVersion)); } + // 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(); + _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); } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs index 1a01e377..a8819bac 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs @@ -51,8 +51,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters } // Test seams - internal void TriggerDetectedFromTest(int phase, string? productVersion = null) => - OnDetected(this, new RealmJoinDetectedEventArgs(phase, productVersion)); + internal void TriggerDetectedFromTest(int phase, string? productVersion = null, string? releaseChannel = null) => + OnDetected(this, new RealmJoinDetectedEventArgs(phase, productVersion, releaseChannel)); internal void TriggerResolvedFromTest(int phase) => OnResolved(this, new RealmJoinResolvedEventArgs(phase)); 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)); @@ -68,6 +68,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters { payload[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = e.ProductVersion!; } + if (!string.IsNullOrEmpty(e.ReleaseChannel)) + { + payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = e.ReleaseChannel!; + } _ingress.Post( kind: DecisionSignalKind.RealmJoinDetected, @@ -75,7 +79,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters sourceOrigin: SourceLabel, evidence: BuildEvidence( "realmjoin-detected-v1", - $"RealmJoin Parameters key observed (phase={e.DeploymentPhase}, productVersion={e.ProductVersion ?? ""})"), + $"RealmJoin Parameters key observed (phase={e.DeploymentPhase}, productVersion={e.ProductVersion ?? ""}, releaseChannel={e.ReleaseChannel ?? ""})"), payload: payload); var data = new Dictionary(StringComparer.Ordinal) @@ -87,11 +91,16 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters { data["productVersion"] = e.ProductVersion!; } + if (!string.IsNullOrEmpty(e.ReleaseChannel)) + { + data["releaseChannel"] = e.ReleaseChannel!; + } var versionTail = string.IsNullOrEmpty(e.ProductVersion) ? string.Empty : $", version={e.ProductVersion}"; + var channelTail = string.IsNullOrEmpty(e.ReleaseChannel) ? string.Empty : $", channel={e.ReleaseChannel}"; _post.Emit( eventType: SharedConstants.EventTypes.RealmJoinDetected, source: SourceLabel, - message: $"RealmJoin deployment detected (phase={e.DeploymentPhase}{versionTail})", + message: $"RealmJoin deployment detected (phase={e.DeploymentPhase}{versionTail}{channelTail})", severity: EventSeverity.Info, immediateUpload: true, data: data, diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs index 5d107afb..dbd4a01c 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs @@ -92,6 +92,35 @@ namespace AutopilotMonitor.DecisionCore.Tests e.Deadline.Name == DeadlineNames.RealmJoinTimeout); } + [Fact] + public void RealmJoinDetected_records_ProductVersion_and_ReleaseChannel_facts() + { + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + + var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100", + [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.6", + [DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "canary", + })); + + Assert.Equal("4.21.6", step.NewState.RealmJoinFacts.ProductVersion!.Value); + Assert.Equal("canary", step.NewState.RealmJoinFacts.ReleaseChannel!.Value); + + // Set-once: a replayed Detected signal with different values must not overwrite. + var replay = engine.Reduce(step.NewState, MakeSignal(6, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(6), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100", + [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "9.9.9", + [DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "beta", + })); + Assert.Equal("4.21.6", replay.NewState.RealmJoinFacts.ProductVersion!.Value); + Assert.Equal("canary", replay.NewState.RealmJoinFacts.ReleaseChannel!.Value); + } + [Fact] public void RealmJoinGate_closed_defers_finalizing_when_DesktopArrived_arrives_with_hello_already_resolved() { diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs index 26ea8bda..e177453f 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs @@ -147,6 +147,10 @@ namespace AutopilotMonitor.DecisionCore.Engine { data["realmjoinProductVersion"] = postState.RealmJoinFacts.ProductVersion.Value; } + if (postState.RealmJoinFacts.ReleaseChannel != null) + { + data["realmjoinReleaseChannel"] = postState.RealmJoinFacts.ReleaseChannel.Value; + } data["realmjoinPackages"] = BuildRealmJoinPackageSummary(postState.RealmJoinFacts.Packages); } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs index dc32a255..92bb70aa 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs @@ -31,8 +31,11 @@ namespace AutopilotMonitor.DecisionCore.Engine public const string Scope = "scope"; // "machine" | "user" public const string Success = "success"; // "true" | "false" public const string LastExitCode = "lastExitCode"; - // Set on RealmJoinDetected by the agent adapter from RealmJoin.exe ProductVersion. + // Set on RealmJoinDetected by the agent adapter from RealmJoin.exe's + // file-version resource: bare version + release channel ("release"/"beta"/"canary" + // — the SemVer prerelease tag, absent tag == stable release). public const string ProductVersion = "productVersion"; + public const string ReleaseChannel = "releaseChannel"; } /// @@ -80,6 +83,7 @@ namespace AutopilotMonitor.DecisionCore.Engine var phase = TryReadPhase(signal); var productVersion = TryReadString(signal, RealmJoinPayloadKeys.ProductVersion); + var releaseChannel = TryReadString(signal, RealmJoinPayloadKeys.ReleaseChannel); var updatedFacts = state.RealmJoinFacts.WithDetected(signal.OccurredAtUtc, signal.SessionSignalOrdinal); if (phase.HasValue) { @@ -89,6 +93,10 @@ namespace AutopilotMonitor.DecisionCore.Engine { updatedFacts = updatedFacts.WithProductVersion(productVersion!, signal.SessionSignalOrdinal); } + if (!string.IsNullOrEmpty(releaseChannel)) + { + updatedFacts = updatedFacts.WithReleaseChannel(releaseChannel!, signal.SessionSignalOrdinal); + } builder.RealmJoinFacts = updatedFacts; var effects = Array.Empty(); diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs index f29d5c1b..26515ca8 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs @@ -33,6 +33,7 @@ namespace AutopilotMonitor.DecisionCore.State outcome: null, selfDeployingDeferredCompletion: null, productVersion: null, + releaseChannel: null, packages: Array.Empty()); public RealmJoinFacts( @@ -42,6 +43,7 @@ namespace AutopilotMonitor.DecisionCore.State SignalFact? outcome, SignalFact? selfDeployingDeferredCompletion, SignalFact? productVersion, + SignalFact? releaseChannel, IReadOnlyList packages) { DetectedUtc = detectedUtc; @@ -50,6 +52,7 @@ namespace AutopilotMonitor.DecisionCore.State Outcome = outcome; SelfDeployingDeferredCompletion = selfDeployingDeferredCompletion; ProductVersion = productVersion; + ReleaseChannel = releaseChannel; Packages = packages ?? Array.Empty(); } @@ -74,12 +77,19 @@ namespace AutopilotMonitor.DecisionCore.State public SignalFact? SelfDeployingDeferredCompletion { get; } /// - /// ProductVersion read from C:\Program Files\RealmJoin\RealmJoin.exe at - /// detection time. Observability-only — never gates a decision. Null when the binary - /// was missing or unreadable. + /// Bare version parsed from C:\Program Files\RealmJoin\RealmJoin.exe's + /// file-version resource at detection time. Observability-only — never gates a + /// decision. Null when the binary was missing or unreadable. /// public SignalFact? ProductVersion { get; } + /// + /// RJ release channel parsed from the version string's SemVer prerelease tag at + /// detection time: "release" (untagged stable), "beta" or "canary". + /// Observability-only — never gates a decision. Null when the version was unreadable. + /// + public SignalFact? ReleaseChannel { get; } + /// Tracked per-package install rows (machine + user scope combined). public IReadOnlyList Packages { get; } @@ -93,6 +103,7 @@ namespace AutopilotMonitor.DecisionCore.State outcome: Outcome, selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, productVersion: ProductVersion, + releaseChannel: ReleaseChannel, packages: Packages); } @@ -112,6 +123,27 @@ namespace AutopilotMonitor.DecisionCore.State outcome: Outcome, selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, productVersion: new SignalFact(productVersion, sourceSignalOrdinal), + releaseChannel: ReleaseChannel, + packages: Packages); + } + + /// + /// Set-once observability fact carrying the RJ release channel parsed from the + /// RealmJoin.exe version string. No-op when + /// is empty or already set. Pure metadata — never participates in a gate decision. + /// + public RealmJoinFacts WithReleaseChannel(string releaseChannel, long sourceSignalOrdinal) + { + if (ReleaseChannel != null) return this; + if (string.IsNullOrEmpty(releaseChannel)) return this; + return new RealmJoinFacts( + detectedUtc: DetectedUtc, + resolvedUtc: ResolvedUtc, + lastDeploymentPhase: LastDeploymentPhase, + outcome: Outcome, + selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + productVersion: ProductVersion, + releaseChannel: new SignalFact(releaseChannel, sourceSignalOrdinal), packages: Packages); } @@ -125,6 +157,7 @@ namespace AutopilotMonitor.DecisionCore.State outcome: new SignalFact(OutcomeResolved, sourceSignalOrdinal), selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, productVersion: ProductVersion, + releaseChannel: ReleaseChannel, packages: Packages); } @@ -138,6 +171,7 @@ namespace AutopilotMonitor.DecisionCore.State outcome: Outcome, selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, productVersion: ProductVersion, + releaseChannel: ReleaseChannel, packages: Packages); } @@ -151,6 +185,7 @@ namespace AutopilotMonitor.DecisionCore.State outcome: new SignalFact(OutcomeTimeout, sourceSignalOrdinal), selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, productVersion: ProductVersion, + releaseChannel: ReleaseChannel, packages: Packages); } @@ -164,6 +199,7 @@ namespace AutopilotMonitor.DecisionCore.State outcome: Outcome, selfDeployingDeferredCompletion: new SignalFact(true, sourceSignalOrdinal), productVersion: ProductVersion, + releaseChannel: ReleaseChannel, packages: Packages); } @@ -183,6 +219,7 @@ namespace AutopilotMonitor.DecisionCore.State outcome: Outcome, selfDeployingDeferredCompletion: null, productVersion: ProductVersion, + releaseChannel: ReleaseChannel, packages: Packages); } @@ -237,6 +274,7 @@ namespace AutopilotMonitor.DecisionCore.State outcome: Outcome, selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, productVersion: ProductVersion, + releaseChannel: ReleaseChannel, packages: copy); } @@ -305,6 +343,7 @@ namespace AutopilotMonitor.DecisionCore.State outcome: Outcome, selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, productVersion: ProductVersion, + releaseChannel: ReleaseChannel, packages: copy); } }