v2 single-rail 5.8: DeviceInfoCollector + new DeviceInfoHost (B27, fixes Parity Issue #2)

V1 Parity Issue #2: DeviceInfoCollector existed in V2.Core but was not
wired into any host, leaving the Web "Device Details" section empty on
every V2 session. This PR migrates the collector to Signal-Post and
adds a new DeviceInfoHost that schedules CollectAll() on a background
thread at orchestrator startup.

Collector (3 partials — .cs, .HardwareSpec.cs, .NetworkAndSecurity.cs):
  * Constructor Action<EnrollmentEvent> emitEvent -> InformationalEventPost
    post. 14 emit-sites flow through the shared EmitDeviceInfoEvent
    helper plus one direct enrollment_type_detected emit:
      os_info, boot_time, network_adapters, dns_configuration,
      proxy_configuration, autopilot_profile, enrollment_type_detected,
      esp_config_detected, secureboot_status, bitlocker_status,
      tpm_status, aad_join_status, network_interface_info,
      hardware_spec, wifi_signal_info.

New DeviceInfoHost (nested in DefaultComponentFactory):
  * Kernel host (not remote-config-gated) — Device Details are essential
    for every enrollment session, not optional telemetry.
  * Constructs InformationalEventPost from (ingress, clock).
  * Start() schedules CollectAll() on Task.Run — WMI queries can take
    several seconds and must not block orchestrator.Start. Exceptions
    are logged at Warning but never crash the host.
  * Stop() is a no-op; the background task completes on its own.

Follow-up (tracked in plan §5.8 TODO): Legacy EnrollmentTracker also
called CollectAtEnrollmentStart on first DeviceSetup phase detection
(re-fetches AAD join / autopilot profile / ESP config / TPM once MDM
enrollment has populated them) and CollectAtEnd at termination
(re-fetches BitLocker + active NIC). Hooking those into the V2 signal
timeline requires a reducer-driven subscription that does not exist
yet. This PR solves the dominant parity case; the re-collections are
deferred.

Tests: 538 V2.Core + 108 DecisionCore = 646 green. No DeviceInfoCollector
unit tests exist today (the collector is deeply tied to live WMI +
registry); the behavior change is verified structurally via the factory
registration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-23 15:08:52 +02:00
parent 818fa53728
commit be9070a68f
3 changed files with 86 additions and 5 deletions
@@ -283,7 +283,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
EmitDeviceInfoEvent("autopilot_profile", "Autopilot profile configuration", data);
// Emit dedicated enrollment_type_detected event for easy filtering
_emitEvent(new EnrollmentEvent
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
@@ -751,7 +751,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
private void EmitDeviceInfoEvent(string eventType, string message, Dictionary<string, object> data)
{
_emitEvent(new EnrollmentEvent
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
@@ -7,6 +7,7 @@ using System.Net.NetworkInformation;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
@@ -25,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
private const string RegKeyWindowsCurrentVersion = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion";
@@ -33,12 +34,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
public DeviceInfoCollector(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
InformationalEventPost post,
AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -126,6 +126,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
hosts.Add(new AadJoinHost(logger, ingress, clock));
// Single-rail refactor (plan §5.8) — DeviceInfoCollector existed in V2.Core but had
// no host so the Device-Details UI block was empty in V2 sessions (V1-Parity Issue #2).
// Kernel host: always on, not remote-config-gated; fires CollectAll on Start on a
// background thread so the agent's critical path is not blocked by WMI queries.
hosts.Add(new DeviceInfoHost(
sessionId: sessionId,
tenantId: tenantId,
ingress: ingress,
clock: clock,
logger: logger));
// Dev / test — if --replay-log-dir is set, the tracker reads from the replay folder
// with SimulationMode ON + the configured SpeedFactor instead of tailing the live
// IME log folder. Production agents leave ReplayLogDir empty.
@@ -379,6 +390,75 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
/// <summary>
/// Single-rail refactor (plan §5.8) — wraps <see cref="Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector"/>
/// to deliver the V1 "Device Details" event surface (OS / hardware / TPM / BitLocker /
/// AAD-Join / autopilot profile / ESP config / network / hardware spec, 14 event types).
/// <para>
/// Kernel host (not remote-config-gated). Fires <see cref="DeviceInfoCollector.CollectAll"/>
/// on <see cref="Start"/> on a ThreadPool task so the orchestrator's critical path is not
/// blocked by the underlying WMI / registry / networking probes. Exceptions from the task
/// are swallowed and logged; a failure in any one sub-emit must not kill the agent.
/// </para>
/// <para>
/// <b>Out of scope today</b>: the Legacy EnrollmentTracker also called
/// <c>CollectAtEnrollmentStart</c> on first DeviceSetup phase detection (re-fetches AAD
/// join / autopilot profile / ESP config / TPM once MDM enrollment has populated them)
/// and <c>CollectAtEnd</c> at termination (re-fetches BitLocker + active NIC). Hooking
/// those into the V2 signal timeline requires a reducer-driven subscription that does
/// not exist yet. The basic <see cref="Start"/> path solves Parity Issue #2 for the
/// dominant case; the phase-transition re-collections are a follow-up (plan §5.8 TODO).
/// </para>
/// </summary>
private sealed class DeviceInfoHost : ICollectorHost
{
public string Name => "DeviceInfoCollector";
private readonly Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector _collector;
private readonly AgentLogger _logger;
private int _disposed;
public DeviceInfoHost(
string sessionId,
string tenantId,
ISignalIngressSink ingress,
IClock clock,
AgentLogger logger)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_logger = logger;
var post = new InformationalEventPost(ingress, clock);
_collector = new Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector(
sessionId, tenantId, post, logger);
}
public void Start()
{
// Fire-and-forget — WMI queries can take several seconds and must not block the
// orchestrator's Start path. The collector emits its 13+ events into the ingress
// pipe as each sub-collector completes.
System.Threading.Tasks.Task.Run(() =>
{
try { _collector.CollectAll(); }
catch (Exception ex) { _logger.Warning($"DeviceInfoHost: CollectAll threw: {ex.Message}"); }
});
_logger.Info("DeviceInfoHost: CollectAll scheduled on background thread.");
}
public void Stop()
{
// No background worker to stop; the scheduled Task either completed or is still
// running and will exit once it finishes emitting. Stop is a no-op by design.
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
// DeviceInfoCollector does not implement IDisposable — purely event-emitting.
}
}
private sealed class AadJoinHost : ICollectorHost
{
public string Name => "AadJoinWatcher";