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