From 03b27e137754143c6f52ea4f3ab31bc5cc587610 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 20 Apr 2026 16:48:29 +0200 Subject: [PATCH] refactor(v2core): M4.3.2 signal adapter (shell-core tracker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §2.1a / §2.2 — ShellCoreTracker verbindet 3 Events mit 3 DecisionSignalKinds. New: - ShellCoreTrackerAdapter - FinalizingSetupPhaseTriggered → EspPhaseChanged (phase=FinalizingSetup, reason propagated) - WhiteGloveCompleted → WhiteGloveShellCoreSuccess - EspFailureDetected → EspTerminalFailure (failureType in payload, "unknown" fallback) - Per-kind Dedup-Flags (independent fire-once pro Signal-Kind) NOTE: ModernDeploymentTracker ist NICHT Signal-relevant (emittiert EnrollmentEvents via onEventCollected callback → Events-Tabelle-Pipeline). Wird in M4.4 Orchestrator direkt an TelemetryTransport als Kind=Event gebridged, nicht als SignalAdapter. Event 509 (WhiteGlove Start) war in Legacy Scoring-Factor (+15); per M3-Erkenntnis wurde das Weight in V2 entfernt → keine Signal-Emission nötig. Tests (+7, 102 total V2.Core.Tests): - Finalizing → EspPhaseChanged mit korrekter EnrollmentPhase.FinalizingSetup - WhiteGloveCompleted → WhiteGloveShellCoreSuccess - EspFailure → EspTerminalFailure mit failureType - Empty failureType → "unknown" fallback - Per-kind independent dedup (3 Events → 3 Posts, Duplicates gefiltert) - All-3-in-one-session flow - Null-args throw Testsuite: 1216 → 1223. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ShellCoreTrackerAdapterTests.cs | 147 ++++++++++++++++++ .../SignalAdapters/ShellCoreTrackerAdapter.cs | 139 +++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ShellCoreTrackerAdapterTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ShellCoreTrackerAdapter.cs diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ShellCoreTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ShellCoreTrackerAdapterTests.cs new file mode 100644 index 00000000..03ddab71 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ShellCoreTrackerAdapterTests.cs @@ -0,0 +1,147 @@ +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.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + public sealed class ShellCoreTrackerAdapterTests + { + 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 ShellCoreTracker 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 ShellCoreTracker( + sessionId: "S1", + tenantId: "T1", + onEventCollected: _ => { }, + logger: Logger, + helloTracker: null); + } + + public void Dispose() + { + Tracker.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void FinalizingPhase_maps_to_EspPhaseChanged_with_Finalizing_payload() + { + using var f = new Fixture(); + using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerFinalizingFromTest("shell-core-62404"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.EspPhaseChanged, posted.Kind); + Assert.Equal(Fixed, posted.OccurredAtUtc); + Assert.Equal("ShellCoreTracker", posted.SourceOrigin); + Assert.Equal( + EnrollmentPhase.FinalizingSetup.ToString(), + posted.Payload![SignalPayloadKeys.EspPhase]); + Assert.Equal("shell-core-62404", posted.Payload["reason"]); + } + + [Fact] + public void WhiteGloveCompleted_maps_to_WhiteGloveShellCoreSuccess() + { + using var f = new Fixture(); + using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerWhiteGloveCompletedFromTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.WhiteGloveShellCoreSuccess, posted.Kind); + Assert.Equal("ShellCoreTracker", posted.SourceOrigin); + Assert.Equal(EvidenceKind.Derived, posted.Evidence.Kind); + Assert.Contains("sealing success", posted.Evidence.Summary); + } + + [Fact] + public void EspFailure_maps_to_EspTerminalFailure_with_failureType_in_payload() + { + using var f = new Fixture(); + using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspFailureFromTest("AccountSetup_Timeout"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.EspTerminalFailure, posted.Kind); + Assert.Equal("AccountSetup_Timeout", posted.Payload!["failureType"]); + Assert.Equal("AccountSetup_Timeout", posted.Evidence.DerivationInputs!["failureType"]); + } + + [Fact] + public void EspFailure_empty_type_falls_back_to_unknown() + { + using var f = new Fixture(); + using var adapter = new ShellCoreTrackerAdapter(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 ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerFinalizingFromTest("r1"); + adapter.TriggerFinalizingFromTest("r2"); // dedup + adapter.TriggerWhiteGloveCompletedFromTest(); + adapter.TriggerWhiteGloveCompletedFromTest(); // dedup + adapter.TriggerEspFailureFromTest("t1"); + adapter.TriggerEspFailureFromTest("t2"); // dedup + + Assert.Equal(3, f.Ingress.Posted.Count); + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.EspPhaseChanged); + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.WhiteGloveShellCoreSuccess); + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.EspTerminalFailure); + } + + [Fact] + public void All_three_events_can_fire_in_one_session() + { + using var f = new Fixture(); + using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerFinalizingFromTest("phase-enter"); + adapter.TriggerWhiteGloveCompletedFromTest(); + adapter.TriggerEspFailureFromTest("Timeout"); + + Assert.Equal(3, f.Ingress.Posted.Count); + Assert.Equal(DecisionSignalKind.EspPhaseChanged, f.Ingress.Posted[0].Kind); + Assert.Equal(DecisionSignalKind.WhiteGloveShellCoreSuccess, f.Ingress.Posted[1].Kind); + Assert.Equal(DecisionSignalKind.EspTerminalFailure, f.Ingress.Posted[2].Kind); + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws(() => new ShellCoreTrackerAdapter(null!, f.Ingress, f.Clock)); + Assert.Throws(() => new ShellCoreTrackerAdapter(f.Tracker, null!, f.Clock)); + Assert.Throws(() => new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ShellCoreTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ShellCoreTrackerAdapter.cs new file mode 100644 index 00000000..36107626 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ShellCoreTrackerAdapter.cs @@ -0,0 +1,139 @@ +#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; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters +{ + /// + /// Adapter for — translates its 3 events into Decision-Signals. + /// Plan §2.1a / §2.2. + /// + /// Event mapping (Plan §2.2 DecisionSignalKind): + /// + /// FinalizingSetupPhaseTriggered → (phase=Finalizing) + /// WhiteGloveCompleted → + /// EspFailureDetected → + /// + /// + /// + /// Jeder Signaltyp ist fire-once (ShellCoreTracker selbst hat Dedup-Guards; der Adapter + /// führt zusätzlich pro-Kind-Flag-Dedup, damit doppelte Subscribe-Calls oder externe + /// Re-Entry-Szenarien nicht zweimal posten). + /// + /// + internal sealed class ShellCoreTrackerAdapter : IDisposable + { + private readonly ShellCoreTracker _tracker; + private readonly ISignalIngressSink _ingress; + private readonly IClock _clock; + + private bool _finalizingPosted; + private bool _whiteGloveSuccessPosted; + private bool _espFailurePosted; + + public ShellCoreTrackerAdapter( + ShellCoreTracker 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.FinalizingSetupPhaseTriggered += OnFinalizing; + _tracker.WhiteGloveCompleted += OnWhiteGloveCompleted; + _tracker.EspFailureDetected += OnEspFailure; + } + + public void Dispose() + { + _tracker.FinalizingSetupPhaseTriggered -= OnFinalizing; + _tracker.WhiteGloveCompleted -= OnWhiteGloveCompleted; + _tracker.EspFailureDetected -= OnEspFailure; + } + + private void OnFinalizing(object sender, string reason) => EmitFinalizing(reason); + private void OnWhiteGloveCompleted(object sender, EventArgs e) => EmitWhiteGloveSuccess(); + private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType); + + internal void TriggerFinalizingFromTest(string reason) => EmitFinalizing(reason); + internal void TriggerWhiteGloveCompletedFromTest() => EmitWhiteGloveSuccess(); + internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType); + + private void EmitFinalizing(string reason) + { + if (_finalizingPosted) return; + _finalizingPosted = true; + + _ingress.Post( + kind: DecisionSignalKind.EspPhaseChanged, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ShellCoreTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "shell-core-tracker-v1", + summary: "ESP Finalizing phase triggered (Shell-Core 62404/62407)", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["eventSource"] = "Microsoft-Windows-Shell-Core", + ["phaseReason"] = reason ?? string.Empty, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + [SignalPayloadKeys.EspPhase] = EnrollmentPhase.FinalizingSetup.ToString(), + ["reason"] = reason ?? string.Empty, + }); + } + + private void EmitWhiteGloveSuccess() + { + if (_whiteGloveSuccessPosted) return; + _whiteGloveSuccessPosted = true; + + _ingress.Post( + kind: DecisionSignalKind.WhiteGloveShellCoreSuccess, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ShellCoreTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "shell-core-tracker-v1", + summary: "WhiteGlove sealing success observed (Shell-Core)", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["eventSource"] = "Microsoft-Windows-Shell-Core", + ["detection"] = "WhiteGloveCompleted event", + })); + } + + 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: "ShellCoreTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "shell-core-tracker-v1", + summary: $"ESP terminal failure detected (type={safeFailureType})", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["eventSource"] = "Microsoft-Windows-Shell-Core", + ["failureType"] = safeFailureType, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["failureType"] = safeFailureType, + }); + } + } +}