mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
refactor(agent-v2): delete test-only sub-tracker adapters (ARCH-F7)
Wave 4 / ARCH-F7. HelloTrackerAdapter, ShellCoreTrackerAdapter and ProvisioningStatusTrackerAdapter had zero production instantiations — production runs solely through EspAndHelloTrackerAdapter (wired in EspAndHelloHost), which already exposes every Trigger*FromTest seam the sub-adapter tests exercised. Tests on the sub-adapters proved nothing about production behavior. - Delete the 3 sub-adapter sources + their test files. - Port the meaningful coverage to EspAndHelloTrackerAdapterTests: HelloOutcome verbatim-propagation theory + empty-failureType fallback. - Rewrite ShellCoreToEngineEspExitingTests test 1 to assert the tracker's EspExited contract directly (HandleBackfillRecord -> EspExited + source timestamp); drop the redundant adapter-driven reducer test (coordinator test 5 already covers the full production chain). - Drop the duplicate ProvisioningStatusTrackerAdapter test in EspFailureDetectedHResultTests (coordinator test above it is identical). - Refresh stale ShellCoreTrackerAdapter references in ShellCoreTracker + DefaultComponentFactory doc comments. V2.Core.Tests: 1293/1293 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+22
-80
@@ -16,17 +16,16 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan §EspExiting Adapter — wire-through verification on TWO topologies:
|
||||
/// <list type="number">
|
||||
/// <item>Single-tracker wiring: <c>ShellCoreTracker.EspExited</c> →
|
||||
/// <c>ShellCoreTrackerAdapter</c> → ingress (test-only scenario).</item>
|
||||
/// <item>Production wiring: <c>ShellCoreTracker.EspExited</c> → inner re-raise on
|
||||
/// <c>EspAndHelloTracker.EspExited</c> → <c>EspAndHelloTrackerAdapter</c> → ingress.
|
||||
/// This is the path the live V2-Host uses; without the coordinator forward the
|
||||
/// new EspExiting signal would never reach the engine in production.</item>
|
||||
/// </list>
|
||||
/// Both paths end with a reducer step that arms the <c>HelloSafety</c> deadline when
|
||||
/// AccountSetup is already entered.
|
||||
/// Plan §EspExiting Adapter — wire-through verification of the production EspExiting path:
|
||||
/// <c>ShellCoreTracker.HandleBackfillRecord</c> → <c>EspExited</c> event → inner re-raise on
|
||||
/// <c>EspAndHelloTracker.EspExited</c> → <c>EspAndHelloTrackerAdapter</c> → ingress → engine
|
||||
/// reducer. This is the path the live V2-Host uses; without the coordinator forward the
|
||||
/// EspExiting signal would never reach the engine in production.
|
||||
/// <para>
|
||||
/// The first test isolates the tracker's <c>EspExited</c> contract (source-event timestamp
|
||||
/// preserved through backfill); the coordinator tests drive the full production chain through
|
||||
/// to the <c>HelloSafety</c> deadline when AccountSetup is already entered.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ShellCoreToEngineEspExitingTests
|
||||
{
|
||||
@@ -35,12 +34,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
"CommercialOOBE_ESPProgress_Page_Exiting"; // matches EspExitingPattern in ShellCoreTracker
|
||||
|
||||
[Fact]
|
||||
public void Tracker_backfill_record_drives_adapter_to_post_EspExiting_signal()
|
||||
public void Tracker_backfill_record_raises_EspExited_with_source_timestamp()
|
||||
{
|
||||
// Tracker-level contract: HandleBackfillRecord raises EspExited carrying the source
|
||||
// event time (not Clock.UtcNow). The EspExited→EspExiting signal mapping and SourceOrigin
|
||||
// are covered by the coordinator tests below; here we isolate the backfill→event link
|
||||
// that the production coordinator depends on.
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var clock = new VirtualClock(ClockNow);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var trackerPost = new InformationalEventPost(new FakeSignalIngressSink(), clock);
|
||||
using var tracker = new ShellCoreTracker(
|
||||
sessionId: "S1",
|
||||
@@ -48,77 +50,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
post: trackerPost,
|
||||
logger: logger,
|
||||
helloTracker: null);
|
||||
using var adapter = new ShellCoreTrackerAdapter(tracker, ingress, clock);
|
||||
|
||||
// Source time IS in the past relative to the clock — verify the adapter forwards
|
||||
// the historical timestamp from the EventArgs rather than collapsing to Clock.UtcNow.
|
||||
EspExitedEventArgs captured = null;
|
||||
tracker.EspExited += (_, args) => captured = args;
|
||||
|
||||
// Source time IS in the past relative to the clock — verify the tracker carries the
|
||||
// historical timestamp on the event rather than collapsing to Clock.UtcNow.
|
||||
var sourceTime = ClockNow.AddMinutes(-2);
|
||||
tracker.HandleBackfillRecord(EspExitingDescription, sourceTime);
|
||||
|
||||
// One EspExiting on the ingress with the source timestamp preserved.
|
||||
Assert.Contains(ingress.Posted, p => p.Kind == DecisionSignalKind.EspExiting);
|
||||
var espExiting = ingress.Posted.FirstOrDefault(p => p.Kind == DecisionSignalKind.EspExiting)!;
|
||||
Assert.Equal("ShellCoreTracker", espExiting.SourceOrigin);
|
||||
Assert.Equal(sourceTime, espExiting.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracker_backfill_then_reducer_step_arms_HelloSafety_when_AccountSetup_observed()
|
||||
{
|
||||
// End-to-end: real tracker event → adapter signal → engine reducer →
|
||||
// HelloSafety deadline. Builds the seed state with AccountSetup pre-entered so the
|
||||
// reducer's ShouldTransitionToAwaitingHello guard unlocks on the EspExiting forward.
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var clock = new VirtualClock(ClockNow);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var trackerPost = new InformationalEventPost(new FakeSignalIngressSink(), clock);
|
||||
using var tracker = new ShellCoreTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: trackerPost,
|
||||
logger: logger,
|
||||
helloTracker: null);
|
||||
using var adapter = new ShellCoreTrackerAdapter(tracker, ingress, clock);
|
||||
|
||||
var sourceTime = ClockNow.AddMinutes(-1);
|
||||
tracker.HandleBackfillRecord(EspExitingDescription, sourceTime);
|
||||
|
||||
var espExiting = ingress.Posted.FirstOrDefault(p => p.Kind == DecisionSignalKind.EspExiting);
|
||||
Assert.NotNull(espExiting);
|
||||
|
||||
// Build a Classic state already at AccountSetup AND with the strong post-AccountSetup
|
||||
// gate satisfied (session 330f73f3 fix) so the EspExiting guard unlocks. Pre-fix this
|
||||
// worked with only AccountSetupEnteredUtc; the new gate requires AccountSetupProvisioningSucceededUtc.
|
||||
var engine = new DecisionEngine();
|
||||
var seed = DecisionState.CreateInitial("S1", "T1", agentBootUtc: sourceTime.AddMinutes(-5))
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.EspAccountSetup)
|
||||
.WithStepIndex(2)
|
||||
.WithLastAppliedSignalOrdinal(1);
|
||||
seed.AccountSetupEnteredUtc = new SignalFact<DateTime>(sourceTime.AddMinutes(-2), 1);
|
||||
seed.AccountSetupProvisioningSucceededUtc = new SignalFact<DateTime>(sourceTime.AddMinutes(-1).AddSeconds(-30), 1);
|
||||
var state = seed.Build();
|
||||
|
||||
// Re-wrap the adapter-posted signal with the ordinal the engine expects.
|
||||
var enrichedSignal = new DecisionSignal(
|
||||
sessionSignalOrdinal: 2,
|
||||
sessionTraceOrdinal: 2,
|
||||
kind: espExiting!.Kind,
|
||||
kindSchemaVersion: espExiting.KindSchemaVersion,
|
||||
occurredAtUtc: espExiting.OccurredAtUtc,
|
||||
sourceOrigin: espExiting.SourceOrigin,
|
||||
evidence: espExiting.Evidence,
|
||||
payload: espExiting.Payload);
|
||||
|
||||
var step = engine.Reduce(state, enrichedSignal);
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
var helloSafety = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
Assert.Equal(sourceTime.AddSeconds(300), helloSafety.DueAtUtc);
|
||||
Assert.Contains(step.Effects,
|
||||
e => e.Kind == DecisionEffectKind.ScheduleDeadline
|
||||
&& e.Deadline?.Name == DeadlineNames.HelloSafety);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(sourceTime, captured!.OccurredAtUtc);
|
||||
}
|
||||
|
||||
// -------------------------------------------- Coordinator-pfad (production wiring) --
|
||||
|
||||
+28
@@ -57,6 +57,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal("unknown", posted.Payload![SignalPayloadKeys.HelloOutcome]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("completed")]
|
||||
[InlineData("skipped")]
|
||||
[InlineData("timeout")]
|
||||
[InlineData("not_configured")]
|
||||
[InlineData("wizard_not_started")]
|
||||
public void HelloOutcome_propagates_verbatim_to_payload(string outcome)
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerHelloFromTest(outcome);
|
||||
|
||||
Assert.Equal(outcome, f.Ingress.Posted[0].Payload![SignalPayloadKeys.HelloOutcome]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinalizingEvent_emits_EspPhaseChanged_with_FinalizingSetup_payload()
|
||||
{
|
||||
@@ -97,6 +113,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Contains("merged", posted.Evidence.DerivationInputs!["subSource"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspFailureEvent_empty_type_falls_back_to_unknown()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock);
|
||||
|
||||
// Empty failureType is normalized to "unknown" by EspFailureDetectedEventArgs.
|
||||
adapter.TriggerEspFailureFromTest("");
|
||||
|
||||
Assert.Equal("unknown", f.Ingress.Posted[0].Payload!["failureType"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupCompleteEvent_emits_DeviceSetupProvisioningComplete_with_snapshot_fallback()
|
||||
{
|
||||
|
||||
-35
@@ -104,40 +104,5 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
}
|
||||
finally { coordinator.Dispose(); }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ProvisioningStatusTrackerAdapter
|
||||
|
||||
[Fact]
|
||||
public void Provisioning_adapter_forwards_full_args_to_signal_payload()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var clock = new VirtualClock(Fixed);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var tracker = new ProvisioningStatusTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: new InformationalEventPost(new FakeSignalIngressSink(), clock),
|
||||
logger: logger);
|
||||
|
||||
try
|
||||
{
|
||||
using var adapter = new ProvisioningStatusTrackerAdapter(tracker, ingress, clock);
|
||||
|
||||
adapter.TriggerEspFailureFromTest(new EspFailureDetectedEventArgs(
|
||||
failureType: "Provisioning_DeviceSetup_Apps_Failed",
|
||||
errorCode: "0x87d1041c",
|
||||
failedSubcategory: "Apps",
|
||||
category: "DeviceSetup"));
|
||||
|
||||
var posted = Assert.Single(ingress.Posted);
|
||||
Assert.Equal(DecisionSignalKind.EspTerminalFailure, posted.Kind);
|
||||
Assert.Equal("Provisioning_DeviceSetup_Apps_Failed", posted.Payload!["failureType"]);
|
||||
Assert.Equal("0x87d1041c", posted.Payload!["errorCode"]);
|
||||
Assert.Equal("Apps", posted.Payload!["failedSubcategory"]);
|
||||
Assert.Equal("DeviceSetup", posted.Payload!["category"]);
|
||||
}
|
||||
finally { tracker.Dispose(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
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 Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
public sealed class HelloTrackerAdapterTests
|
||||
{
|
||||
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 HelloTracker Tracker { get; }
|
||||
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
|
||||
public VirtualClock Clock { get; } = new VirtualClock(Fixed);
|
||||
|
||||
public Fixture()
|
||||
{
|
||||
Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
|
||||
// Separate throwaway ingress for tracker emissions so the adapter's DecisionSignal
|
||||
// assertions on Ingress.Posted stay unpolluted by InformationalEvent pass-through.
|
||||
var trackerPost = new InformationalEventPost(new FakeSignalIngressSink(), Clock);
|
||||
Tracker = new HelloTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: trackerPost,
|
||||
logger: Logger);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
Tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriggerFromTest_emits_HelloResolved_with_outcome_in_payload()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var adapter = new HelloTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerFromTest("completed");
|
||||
|
||||
var posted = Assert.Single(f.Ingress.Posted);
|
||||
Assert.Equal(DecisionSignalKind.HelloResolved, posted.Kind);
|
||||
Assert.Equal(Fixed, posted.OccurredAtUtc);
|
||||
Assert.Equal("HelloTracker", posted.SourceOrigin);
|
||||
Assert.Equal("completed", posted.Payload![SignalPayloadKeys.HelloOutcome]);
|
||||
Assert.Contains("completed", posted.Evidence.Summary);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("completed")]
|
||||
[InlineData("skipped")]
|
||||
[InlineData("timeout")]
|
||||
[InlineData("not_configured")]
|
||||
[InlineData("wizard_not_started")]
|
||||
public void Outcome_propagates_verbatim_to_payload(string outcome)
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var adapter = new HelloTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerFromTest(outcome);
|
||||
|
||||
Assert.Equal(outcome, f.Ingress.Posted[0].Payload![SignalPayloadKeys.HelloOutcome]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_or_empty_outcome_falls_back_to_unknown()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var adapter = new HelloTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerFromTest(null!);
|
||||
|
||||
Assert.Equal("unknown", f.Ingress.Posted[0].Payload![SignalPayloadKeys.HelloOutcome]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_trigger_is_deduplicated()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var adapter = new HelloTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerFromTest("completed");
|
||||
adapter.TriggerFromTest("timeout"); // even with different outcome, we're fire-once
|
||||
|
||||
Assert.Single(f.Ingress.Posted);
|
||||
Assert.Equal("completed", f.Ingress.Posted[0].Payload![SignalPayloadKeys.HelloOutcome]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_null_args_throw()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
Assert.Throws<ArgumentNullException>(() => new HelloTrackerAdapter(null!, f.Ingress, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new HelloTrackerAdapter(f.Tracker, null!, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new HelloTrackerAdapter(f.Tracker, f.Ingress, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
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);
|
||||
var trackerPost = new InformationalEventPost(new FakeSignalIngressSink(), Clock);
|
||||
Tracker = new ProvisioningStatusTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: trackerPost,
|
||||
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!));
|
||||
}
|
||||
}
|
||||
}
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
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);
|
||||
var trackerPost = new InformationalEventPost(new FakeSignalIngressSink(), Clock);
|
||||
Tracker = new ShellCoreTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: trackerPost,
|
||||
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<ArgumentNullException>(() => new ShellCoreTrackerAdapter(null!, f.Ingress, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new ShellCoreTrackerAdapter(f.Tracker, null!, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, null!));
|
||||
}
|
||||
|
||||
// ---- EspExited → DecisionSignalKind.EspExiting (Plan §EspExiting Adapter) ----
|
||||
|
||||
[Fact]
|
||||
public void EspExited_maps_to_EspExiting_signal()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var sourceTime = new DateTime(2026, 5, 12, 17, 45, 30, DateTimeKind.Utc);
|
||||
|
||||
adapter.TriggerEspExitingFromTest(sourceTime);
|
||||
|
||||
var posted = Assert.Single(f.Ingress.Posted);
|
||||
Assert.Equal(DecisionSignalKind.EspExiting, posted.Kind);
|
||||
Assert.Equal("ShellCoreTracker", posted.SourceOrigin);
|
||||
Assert.Equal(EvidenceKind.Derived, posted.Evidence.Kind);
|
||||
Assert.Contains("ESP exiting", posted.Evidence.Summary);
|
||||
Assert.Equal("Microsoft-Windows-Shell-Core", posted.Evidence.DerivationInputs!["eventSource"]);
|
||||
Assert.Equal("62407", posted.Evidence.DerivationInputs["eventId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspExited_posts_every_occurrence_no_dedup()
|
||||
{
|
||||
// Shell-Core 62407 fires at every ESP phase transition (Device→Account, Account→End).
|
||||
// The adapter must NOT dedup — the reducer (ShouldTransitionToAwaitingHello)
|
||||
// distinguishes the genuine post-AccountSetup exit from intermediate ones.
|
||||
using var f = new Fixture();
|
||||
using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerEspExitingFromTest(new DateTime(2026, 5, 12, 17, 20, 0, DateTimeKind.Utc));
|
||||
adapter.TriggerEspExitingFromTest(new DateTime(2026, 5, 12, 17, 45, 0, DateTimeKind.Utc));
|
||||
adapter.TriggerEspExitingFromTest(new DateTime(2026, 5, 12, 18, 10, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal(3, f.Ingress.Posted.Count);
|
||||
Assert.All(f.Ingress.Posted, p => Assert.Equal(DecisionSignalKind.EspExiting, p.Kind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspExited_preserves_source_event_timestamp_not_clock_now()
|
||||
{
|
||||
// OccurredAtUtc must come from the source event (live = log time, backfill =
|
||||
// record.TimeCreated), not from the adapter's clock — otherwise the engine's
|
||||
// EffectiveDeadlineBase would floor HelloSafety at "now" on a backfilled run.
|
||||
using var f = new Fixture();
|
||||
using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var pastTime = new DateTime(2026, 4, 1, 8, 15, 22, DateTimeKind.Utc); // before f.Clock = 2026-04-20
|
||||
|
||||
adapter.TriggerEspExitingFromTest(pastTime);
|
||||
|
||||
var posted = Assert.Single(f.Ingress.Posted);
|
||||
Assert.Equal(pastTime, posted.OccurredAtUtc);
|
||||
Assert.NotEqual(f.Clock.UtcNow, posted.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_four_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");
|
||||
adapter.TriggerEspExitingFromTest(Fixed);
|
||||
|
||||
Assert.Equal(4, 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);
|
||||
Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.EspExiting);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -299,10 +299,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{detectedFailureType}'", ex); }
|
||||
}
|
||||
|
||||
// Fire EspExited AFTER event emission. Subscribers (ShellCoreTrackerAdapter) post
|
||||
// a DecisionSignalKind.EspExiting so the engine can arm HelloSafety on the genuine
|
||||
// post-AccountSetup exit. Engine-side guard (ShouldTransitionToAwaitingHello)
|
||||
// distinguishes intermediate exits from the real one.
|
||||
// Fire EspExited AFTER event emission. The coordinator (EspAndHelloTracker) re-raises
|
||||
// this and EspAndHelloTrackerAdapter posts a DecisionSignalKind.EspExiting so the
|
||||
// engine can arm HelloSafety on the genuine post-AccountSetup exit. Engine-side guard
|
||||
// (ShouldTransitionToAwaitingHello) distinguishes intermediate exits from the real one.
|
||||
if (eventType == Constants.EventTypes.EspExiting)
|
||||
{
|
||||
try { EspExited?.Invoke(this, new EspExitedEventArgs(timestamp)); }
|
||||
|
||||
@@ -25,9 +25,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <item><b>EspAndHelloHost</b> — <see cref="EspAndHelloTracker"/> coordinator
|
||||
/// (internally aggregates HelloTracker + ShellCoreTracker + ProvisioningStatusTracker
|
||||
/// + ModernDeploymentTracker) wired via <see cref="EspAndHelloTrackerAdapter"/>.
|
||||
/// This is the single production entry for the ESP+Hello signal surface — avoids
|
||||
/// double emission that would happen if the sub-tracker adapters were also wired
|
||||
/// in parallel (§4.x M4.3 tech-debt note about adapter duplication).</item>
|
||||
/// This is the single production entry for the ESP+Hello signal surface: the sub-trackers
|
||||
/// are private to the coordinator and only their re-raised events are adapted here, so
|
||||
/// there is exactly one DecisionSignal per source event (no double emission).</item>
|
||||
/// <item><b>DesktopArrivalHost</b> — <see cref="DesktopArrivalDetector"/> + <see cref="DesktopArrivalDetectorAdapter"/>.</item>
|
||||
/// <item><b>AadJoinHost</b> — <see cref="AadJoinWatcher"/> + <see cref="AadJoinWatcherAdapter"/>.</item>
|
||||
/// <item><b>ImeLogHost</b> — <see cref="ImeLogTracker"/> + <see cref="ImeProcessWatcher"/>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
#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="HelloTracker"/> → <see cref="DecisionSignalKind.HelloResolved"/>.
|
||||
/// Plan §2.1a / §2.2.
|
||||
/// <para>
|
||||
/// Fires genau einmal, wenn der Tracker Hello-Abschluss feststellt. Payload enthält den
|
||||
/// <c>HelloOutcome</c>-String (<c>completed | skipped | timeout | not_configured | wizard_not_started</c>),
|
||||
/// der vom Reducer für die <c>SignalFact<string> HelloOutcome</c>-Fakt-Update verwendet wird.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class HelloTrackerAdapter : IDisposable
|
||||
{
|
||||
private readonly HelloTracker _tracker;
|
||||
private readonly ISignalIngressSink _ingress;
|
||||
private readonly IClock _clock;
|
||||
private bool _fired;
|
||||
|
||||
public HelloTrackerAdapter(
|
||||
HelloTracker 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.HelloCompleted += OnHelloCompleted;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tracker.HelloCompleted -= OnHelloCompleted;
|
||||
}
|
||||
|
||||
private void OnHelloCompleted(object sender, EventArgs e) => EmitInternal(_tracker.HelloOutcome);
|
||||
|
||||
internal void TriggerFromTest(string helloOutcome) => EmitInternal(helloOutcome);
|
||||
|
||||
private void EmitInternal(string? helloOutcome)
|
||||
{
|
||||
if (_fired) return;
|
||||
_fired = true;
|
||||
|
||||
var outcome = string.IsNullOrEmpty(helloOutcome) ? "unknown" : helloOutcome!;
|
||||
|
||||
_ingress.Post(
|
||||
kind: DecisionSignalKind.HelloResolved,
|
||||
occurredAtUtc: _clock.UtcNow,
|
||||
sourceOrigin: "HelloTracker",
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Derived,
|
||||
identifier: "hello-tracker-v1",
|
||||
summary: $"Hello resolved (outcome={outcome})",
|
||||
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[SignalPayloadKeys.HelloOutcome] = outcome,
|
||||
}),
|
||||
payload: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[SignalPayloadKeys.HelloOutcome] = outcome,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
-173
@@ -1,173 +0,0 @@
|
||||
#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"/> → 3 DecisionSignalKinds.
|
||||
/// Plan §2.1a / §2.2.
|
||||
/// <para>
|
||||
/// Event mapping:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DeviceSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/></item>
|
||||
/// <item><c>AccountSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/> (session 330f73f3 fix)</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 _accountSetupCompletePosted;
|
||||
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.AccountSetupProvisioningComplete += OnAccountSetupComplete;
|
||||
_tracker.EspFailureDetected += OnEspFailure;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tracker.DeviceSetupProvisioningComplete -= OnDeviceSetupComplete;
|
||||
_tracker.AccountSetupProvisioningComplete -= OnAccountSetupComplete;
|
||||
_tracker.EspFailureDetected -= OnEspFailure;
|
||||
}
|
||||
|
||||
private void OnDeviceSetupComplete(object sender, EventArgs e) => EmitDeviceSetupComplete();
|
||||
private void OnAccountSetupComplete(object sender, EventArgs e) => EmitAccountSetupComplete();
|
||||
private void OnEspFailure(object sender, EspFailureDetectedEventArgs args) => EmitEspFailure(args);
|
||||
|
||||
internal void TriggerDeviceSetupCompleteFromTest() => EmitDeviceSetupComplete();
|
||||
internal void TriggerAccountSetupCompleteFromTest() => EmitAccountSetupComplete();
|
||||
internal void TriggerEspFailureFromTest(string failureType)
|
||||
=> EmitEspFailure(new EspFailureDetectedEventArgs(failureType));
|
||||
internal void TriggerEspFailureFromTest(EspFailureDetectedEventArgs args) => EmitEspFailure(args);
|
||||
|
||||
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 EmitAccountSetupComplete()
|
||||
{
|
||||
if (_accountSetupCompletePosted) return;
|
||||
_accountSetupCompletePosted = true;
|
||||
|
||||
var snapshot = _tracker.GetProvisioningCategorySnapshot();
|
||||
var accountSetupResolved = snapshot.TryGetValue("AccountSetupCategory.Status", out var asState) && asState.HasValue
|
||||
? asState.Value.ToString().ToLowerInvariant()
|
||||
: "unknown";
|
||||
|
||||
_ingress.Post(
|
||||
kind: DecisionSignalKind.AccountSetupProvisioningComplete,
|
||||
occurredAtUtc: _clock.UtcNow,
|
||||
sourceOrigin: "ProvisioningStatusTracker",
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Derived,
|
||||
identifier: "provisioning-status-tracker-v1",
|
||||
summary: "AccountSetupCategory provisioning completed",
|
||||
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\AccountSetupCategory.Status",
|
||||
["accountSetupResolved"] = accountSetupResolved,
|
||||
}),
|
||||
payload: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["accountSetupResolved"] = accountSetupResolved,
|
||||
});
|
||||
}
|
||||
|
||||
private void EmitEspFailure(EspFailureDetectedEventArgs args)
|
||||
{
|
||||
if (_espFailurePosted) return;
|
||||
_espFailurePosted = true;
|
||||
|
||||
var safeFailureType = args?.FailureType ?? "unknown";
|
||||
var errorCode = args?.ErrorCode;
|
||||
var failedSubcategory = args?.FailedSubcategory;
|
||||
var category = args?.Category;
|
||||
|
||||
var derivationInputs = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\*.Status",
|
||||
["failureType"] = safeFailureType,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
derivationInputs["errorCode"] = errorCode!;
|
||||
if (!string.IsNullOrEmpty(failedSubcategory))
|
||||
derivationInputs["failedSubcategory"] = failedSubcategory!;
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
derivationInputs["category"] = category!;
|
||||
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["failureType"] = safeFailureType,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
payload["errorCode"] = errorCode!;
|
||||
if (!string.IsNullOrEmpty(failedSubcategory))
|
||||
payload["failedSubcategory"] = failedSubcategory!;
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
payload["category"] = category!;
|
||||
|
||||
_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}, errorCode={errorCode ?? "n/a"})",
|
||||
derivationInputs: derivationInputs),
|
||||
payload: payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
#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
|
||||
{
|
||||
/// <summary>
|
||||
/// Adapter for <see cref="ShellCoreTracker"/> — translates its 4 events into Decision-Signals.
|
||||
/// Plan §2.1a / §2.2.
|
||||
/// <para>
|
||||
/// Event mapping (Plan §2.2 DecisionSignalKind):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>FinalizingSetupPhaseTriggered</c> → <see cref="DecisionSignalKind.EspPhaseChanged"/> (phase=Finalizing)</item>
|
||||
/// <item><c>WhiteGloveCompleted</c> → <see cref="DecisionSignalKind.WhiteGloveShellCoreSuccess"/></item>
|
||||
/// <item><c>EspFailureDetected</c> → <see cref="DecisionSignalKind.EspTerminalFailure"/></item>
|
||||
/// <item><c>EspExited</c> → <see cref="DecisionSignalKind.EspExiting"/></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Finalizing / WhiteGloveSuccess / EspFailure are fire-once (ShellCoreTracker has Dedup-Guards;
|
||||
/// the adapter adds per-kind flags so duplicate Subscribe-Calls or external re-entry can't
|
||||
/// double-post). <c>EspExiting</c> is intentionally NOT deduped at the adapter — Shell-Core
|
||||
/// 62407 fires at every ESP phase transition (Device→Account, Account→End), and the
|
||||
/// reducer (HandleEspExitingV1 + ShouldTransitionToAwaitingHello) decides which occurrence
|
||||
/// is the genuine post-AccountSetup exit that arms HelloSafety.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
_tracker.EspExited += OnEspExited;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tracker.FinalizingSetupPhaseTriggered -= OnFinalizing;
|
||||
_tracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
|
||||
_tracker.EspFailureDetected -= OnEspFailure;
|
||||
_tracker.EspExited -= OnEspExited;
|
||||
}
|
||||
|
||||
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);
|
||||
private void OnEspExited(object sender, EspExitedEventArgs args) => EmitEspExiting(args.OccurredAtUtc);
|
||||
|
||||
internal void TriggerFinalizingFromTest(string reason) => EmitFinalizing(reason);
|
||||
internal void TriggerWhiteGloveCompletedFromTest() => EmitWhiteGloveSuccess();
|
||||
internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType);
|
||||
internal void TriggerEspExitingFromTest(DateTime occurredAtUtc) => EmitEspExiting(occurredAtUtc);
|
||||
|
||||
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<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["eventSource"] = "Microsoft-Windows-Shell-Core",
|
||||
["phaseReason"] = reason ?? string.Empty,
|
||||
}),
|
||||
payload: new Dictionary<string, string>(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<string, string>(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<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["eventSource"] = "Microsoft-Windows-Shell-Core",
|
||||
["failureType"] = safeFailureType,
|
||||
}),
|
||||
payload: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["failureType"] = safeFailureType,
|
||||
});
|
||||
}
|
||||
|
||||
// OccurredAtUtc comes from the tracker (live = log time, backfill = record.TimeCreated)
|
||||
// rather than _clock.UtcNow so HandleEspExitingV1 can floor HelloSafety at the actual
|
||||
// ESP-exit moment via EffectiveDeadlineBase, not at wall-clock-now on a backfilled run.
|
||||
private void EmitEspExiting(DateTime occurredAtUtc)
|
||||
{
|
||||
_ingress.Post(
|
||||
kind: DecisionSignalKind.EspExiting,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "ShellCoreTracker",
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Derived,
|
||||
identifier: "shell-core-tracker-v1",
|
||||
summary: "ESP exiting (Shell-Core 62407 OOBE_ESP*Exiting)",
|
||||
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["eventSource"] = "Microsoft-Windows-Shell-Core",
|
||||
["eventId"] = "62407",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
1036
|
||||
1039
|
||||
|
||||
Reference in New Issue
Block a user