From 3c1fca8089112cd4e1764a5db1954cb9efafe3f9 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 20 Apr 2026 16:50:21 +0200 Subject: [PATCH] refactor(v2core): M4.3.3 signal adapter (provisioning-status tracker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §2.1a / §2.2 — ProvisioningStatusTracker mit 2 Events auf 2 DecisionSignalKinds. New: - ProvisioningStatusTrackerAdapter - DeviceSetupProvisioningComplete → DeviceSetupProvisioningComplete (liest GetProvisioningCategorySnapshot() — deviceSetupResolved in Payload) - EspFailureDetected → EspTerminalFailure (SourceOrigin unterscheidet von ShellCoreTracker — wichtig für Multi-Source-Attribution im Journal) Dual-Source-EspTerminalFailure-Handling (ShellCore + ProvisioningStatus): Duplicate Signals sind erwartbar und OK — der Reducer akzeptiert die erste, weitere landen als Dead-End-Transitions im Journal. Adapter-Dedup ist tracker-lokal (pro Instanz). Tests (+5, 107 total V2.Core.Tests): - DeviceSetupComplete → DeviceSetupProvisioningComplete Signal - EspFailure → EspTerminalFailure mit distinct SourceOrigin - Empty failureType → "unknown" - Per-kind dedup - Null-args throw Testsuite: 1223 → 1228. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ProvisioningStatusTrackerAdapterTests.cs | 113 +++++++++++++++++ .../ProvisioningStatusTrackerAdapter.cs | 117 ++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ProvisioningStatusTrackerAdapterTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ProvisioningStatusTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ProvisioningStatusTrackerAdapterTests.cs new file mode 100644 index 00000000..2ea60500 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ProvisioningStatusTrackerAdapterTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Signals; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + public sealed class ProvisioningStatusTrackerAdapterTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public ProvisioningStatusTracker Tracker { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Tracker = new ProvisioningStatusTracker( + sessionId: "S1", + tenantId: "T1", + onEventCollected: _ => { }, + logger: Logger); + } + + public void Dispose() + { + Tracker.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void DeviceSetupComplete_maps_to_DeviceSetupProvisioningComplete_signal() + { + using var f = new Fixture(); + using var adapter = new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerDeviceSetupCompleteFromTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.DeviceSetupProvisioningComplete, posted.Kind); + Assert.Equal(Fixed, posted.OccurredAtUtc); + Assert.Equal("ProvisioningStatusTracker", posted.SourceOrigin); + // Snapshot without real registry data → "unknown" + Assert.Equal("unknown", posted.Payload!["deviceSetupResolved"]); + } + + [Fact] + public void EspFailure_maps_to_EspTerminalFailure_with_distinct_sourceOrigin_from_ShellCore() + { + using var f = new Fixture(); + using var adapter = new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspFailureFromTest("DevicePreparation_Failed"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.EspTerminalFailure, posted.Kind); + // SourceOrigin distinguishes ShellCore vs. ProvisioningStatus — important for + // multi-source failure attribution in the journal. + Assert.Equal("ProvisioningStatusTracker", posted.SourceOrigin); + Assert.Equal("DevicePreparation_Failed", posted.Payload!["failureType"]); + } + + [Fact] + public void EspFailure_empty_type_falls_back_to_unknown() + { + using var f = new Fixture(); + using var adapter = new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspFailureFromTest(""); + + Assert.Equal("unknown", f.Ingress.Posted[0].Payload!["failureType"]); + } + + [Fact] + public void Each_signal_kind_is_deduplicated_independently() + { + using var f = new Fixture(); + using var adapter = new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerDeviceSetupCompleteFromTest(); + adapter.TriggerDeviceSetupCompleteFromTest(); // dedup + adapter.TriggerEspFailureFromTest("t1"); + adapter.TriggerEspFailureFromTest("t2"); // dedup + + Assert.Equal(2, f.Ingress.Posted.Count); + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.DeviceSetupProvisioningComplete); + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.EspTerminalFailure); + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws( + () => new ProvisioningStatusTrackerAdapter(null!, f.Ingress, f.Clock)); + Assert.Throws( + () => new ProvisioningStatusTrackerAdapter(f.Tracker, null!, f.Clock)); + Assert.Throws( + () => new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs new file mode 100644 index 00000000..3430d14b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs @@ -0,0 +1,117 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters +{ + /// + /// Adapter for → 2 DecisionSignalKinds. + /// Plan §2.1a / §2.2. + /// + /// Event mapping: + /// + /// DeviceSetupProvisioningComplete → + /// EspFailureDetected → + /// + /// + /// + /// Duplicate EspTerminalFailure-Signals aus ShellCoreTracker + ProvisioningStatusTracker + /// sind erwartbar (zwei Detection-Quellen für dasselbe Outcome). Der Reducer handled das + /// idempotent — erste Failure gewinnt und setzt Stage auf Failed; weitere sind dann Dead-End. + /// Adapter führt nur lokale per-kind Dedup (innerhalb einer Tracker-Instanz). + /// + /// + internal sealed class ProvisioningStatusTrackerAdapter : IDisposable + { + private readonly ProvisioningStatusTracker _tracker; + private readonly ISignalIngressSink _ingress; + private readonly IClock _clock; + + private bool _deviceSetupCompletePosted; + private bool _espFailurePosted; + + public ProvisioningStatusTrackerAdapter( + ProvisioningStatusTracker tracker, + ISignalIngressSink ingress, + IClock clock) + { + _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker)); + _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + + _tracker.DeviceSetupProvisioningComplete += OnDeviceSetupComplete; + _tracker.EspFailureDetected += OnEspFailure; + } + + public void Dispose() + { + _tracker.DeviceSetupProvisioningComplete -= OnDeviceSetupComplete; + _tracker.EspFailureDetected -= OnEspFailure; + } + + private void OnDeviceSetupComplete(object sender, EventArgs e) => EmitDeviceSetupComplete(); + private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType); + + internal void TriggerDeviceSetupCompleteFromTest() => EmitDeviceSetupComplete(); + internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType); + + private void EmitDeviceSetupComplete() + { + if (_deviceSetupCompletePosted) return; + _deviceSetupCompletePosted = true; + + var snapshot = _tracker.GetProvisioningCategorySnapshot(); + var deviceSetupResolved = snapshot.TryGetValue("DeviceSetup", out var dsState) && dsState.HasValue + ? dsState.Value.ToString().ToLowerInvariant() + : "unknown"; + + _ingress.Post( + kind: DecisionSignalKind.DeviceSetupProvisioningComplete, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ProvisioningStatusTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "provisioning-status-tracker-v1", + summary: "DeviceSetupCategory provisioning completed", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\DeviceSetupCategory.Status", + ["deviceSetupResolved"] = deviceSetupResolved, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["deviceSetupResolved"] = deviceSetupResolved, + }); + } + + private void EmitEspFailure(string failureType) + { + if (_espFailurePosted) return; + _espFailurePosted = true; + + var safeFailureType = string.IsNullOrEmpty(failureType) ? "unknown" : failureType!; + + _ingress.Post( + kind: DecisionSignalKind.EspTerminalFailure, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ProvisioningStatusTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "provisioning-status-tracker-v1", + summary: $"ESP terminal failure from provisioning registry (type={safeFailureType})", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\*.Status", + ["failureType"] = safeFailureType, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["failureType"] = safeFailureType, + }); + } + } +}