refactor(v2core): M4.3.3 signal adapter (provisioning-status tracker)

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 16:50:21 +02:00
parent 03b27e1377
commit 3c1fca8089
2 changed files with 230 additions and 0 deletions
@@ -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<ArgumentNullException>(
() => new ProvisioningStatusTrackerAdapter(null!, f.Ingress, f.Clock));
Assert.Throws<ArgumentNullException>(
() => new ProvisioningStatusTrackerAdapter(f.Tracker, null!, f.Clock));
Assert.Throws<ArgumentNullException>(
() => new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, null!));
}
}
}
@@ -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
{
/// <summary>
/// Adapter for <see cref="ProvisioningStatusTracker"/> → 2 DecisionSignalKinds.
/// Plan §2.1a / §2.2.
/// <para>
/// Event mapping:
/// <list type="bullet">
/// <item><c>DeviceSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/></item>
/// <item><c>EspFailureDetected</c> → <see cref="DecisionSignalKind.EspTerminalFailure"/></item>
/// </list>
/// </para>
/// <para>
/// 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).
/// </para>
/// </summary>
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<string, string>(StringComparer.Ordinal)
{
["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\DeviceSetupCategory.Status",
["deviceSetupResolved"] = deviceSetupResolved,
}),
payload: new Dictionary<string, string>(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<string, string>(StringComparer.Ordinal)
{
["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\*.Status",
["failureType"] = safeFailureType,
}),
payload: new Dictionary<string, string>(StringComparer.Ordinal)
{
["failureType"] = safeFailureType,
});
}
}
}