feat(agent/v2): realmjoin_phase_changed surfaces RJ enum names alongside raw DeploymentPhase ints

The realmjoin_phase_changed message read like "DeploymentPhase 100 -> 110" — the raw
ints are correct but require a lookup table to interpret. Map them to the RJ-internal
DeploymentPhase enum names (Blank / RunningFirstDeployment[Auto] / CompletedFirstDeployment /
RunningDeployment / CompletedDeployment / ManualDeployment / NestedFromShim) so the
timeline + log message read like "DeploymentPhase RunningFirstDeployment -> CompletedFirstDeployment"
without a lookup. The numeric ints stay in the payload (deploymentPhase / previousPhase)
so KQL filters and dashboards do not have to switch.

- New RealmJoinInfo.PhaseDisplayName(int) — single switch matching the published RJ enum.
  Unknown / future values fall back to the numeric string so they stay observable instead
  of being collapsed to "Unknown".
- RealmJoinWatcherAdapter.OnPhaseChanged: payload now carries the two new
  deploymentPhaseName / previousPhaseName keys; message rendered with names.

Tests: +1 (mapping known phase ints → enum names + message format),
       existing phase-changed test extended to assert both raw + named fields.
       11/11 adapter suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-22 13:28:32 +02:00
parent 27612db560
commit f6b2bedd69
3 changed files with 54 additions and 1 deletions
@@ -140,8 +140,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == SharedEventTypes.RealmJoinPhaseChanged);
// Raw ints stay for downstream filters / KQL.
Assert.Equal("200", info.Payload!["deploymentPhase"]);
Assert.Equal("100", info.Payload["previousPhase"]);
// RJ enum names alongside — UI / message readable without a lookup table.
Assert.Equal("RunningDeployment", info.Payload["deploymentPhaseName"]);
Assert.Equal("RunningFirstDeployment", info.Payload["previousPhaseName"]);
Assert.Contains("RunningFirstDeployment -> RunningDeployment", info.Payload[SignalPayloadKeys.Message]);
}
[Fact]
public void TriggerPhaseChangedFromTest_falls_back_to_numeric_when_phase_is_unknown_RJ_enum_value()
{
using var f = new Fixture();
using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock);
adapter.TriggerPhaseChangedFromTest(prev: 100, curr: 999);
var info = f.Ingress.Posted.Single(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == SharedEventTypes.RealmJoinPhaseChanged);
Assert.Equal("RunningFirstDeployment", info.Payload!["previousPhaseName"]);
// Future / unknown RJ phase values stay observable as their numeric form rather
// than being collapsed to "Unknown".
Assert.Equal("999", info.Payload["deploymentPhaseName"]);
}
[Fact]
@@ -66,6 +66,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// Resolved-phase value per the RJ enum (RealmJoin.Core.SoftwarePackaging.DeploymentPhase).
public const int PhaseCompletedFirstDeployment = 110;
/// <summary>
/// Map a raw <c>DeploymentPhase</c> DWORD to its RJ-internal enum name. Mirrors
/// <c>RealmJoin.Core.SoftwarePackaging.DeploymentPhase</c> as documented at feature
/// design time. Unknown values fall back to the numeric form so future RJ-introduced
/// phases stay observable instead of being collapsed to "Unknown".
/// </summary>
public static string PhaseDisplayName(int phase)
{
switch (phase)
{
case 0: return "Blank";
case 100: return "RunningFirstDeployment";
case 101: return "RunningFirstDeploymentAuto";
case 110: return "CompletedFirstDeployment";
case 200: return "RunningDeployment";
case 210: return "CompletedDeployment";
case 500: return "ManualDeployment";
case 600: return "NestedFromShim";
default: return phase.ToString(CultureInfo.InvariantCulture);
}
}
/// <summary>Canonical 64-bit install path for the RJ host executable.</summary>
public const string RealmJoinExePath = @"C:\Program Files\RealmJoin\RealmJoin.exe";
@@ -129,15 +129,22 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private void OnPhaseChanged(object? sender, RealmJoinPhaseChangedEventArgs e)
{
// Phase change is observability-only: no DecisionSignalKind, just a timeline event.
// Both raw int (deploymentPhase / previousPhase) and human-readable name
// (deploymentPhaseName / previousPhaseName) flow through — int stays for downstream
// filters / KQL, name fields make the message + UI rows readable without a lookup.
var currentName = RealmJoinInfo.PhaseDisplayName(e.CurrentPhase);
var previousName = RealmJoinInfo.PhaseDisplayName(e.PreviousPhase);
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["deploymentPhase"] = e.CurrentPhase.ToString(),
["deploymentPhaseName"] = currentName,
["previousPhase"] = e.PreviousPhase.ToString(),
["previousPhaseName"] = previousName,
};
_post.Emit(
eventType: SharedConstants.EventTypes.RealmJoinPhaseChanged,
source: SourceLabel,
message: $"RealmJoin DeploymentPhase {e.PreviousPhase} -> {e.CurrentPhase}",
message: $"RealmJoin DeploymentPhase {previousName} -> {currentName}",
severity: EventSeverity.Info,
immediateUpload: false,
data: data,