From fb31a2d6d12476a5d66a40f3af789dfadbd88e31 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 2 May 2026 10:56:10 +0200 Subject: [PATCH] feat(v2): close Hybrid User-Driven completion gaps Session e58bcfdb stuck 5h on Hybrid User-Driven enrollment because the foouser OOBE shell triggered desktop_arrived prematurely and no Hello/AAD-real-user signals ever followed. Agent (V2): - DesktopArrivalDetector excludes foouser/autopilot placeholders (UPN prefix + exact bare username; autopilotadmin etc. stay through) - AadJoinWatcherAdapter dual-emits placeholder + real-user join as informational events for Events-table consumers - DesktopArrivalDetector resets on placeholder->real-user transition via AadJoinHost.onRealUserJoined wiring - New single-shot HybridLoginPendingDetector (10m, gated on reboot_kill + isHybridJoin, cancels on real-user join) Backend: - New FailureSnapshotBuilder writes per-session diagnostic snapshot on 5h-timeout (last ESP phase, desktop arrival, Hello policy, AAD-join state, missing canonical signals); aad_user_join slot satisfied by either Part 1 or Part 2 join event - Sessions.FailureSnapshotJson column with Store/Map round-trip - UpdateSessionStatusAsync extended with optional snapshot param Web: - New collapsible Failure Snapshot block in SessionInfoCard Tests: V2.Core 920, Backend 862, DecisionCore 252, V1.Core 530 (regression-clean), Web vitest 208. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Monitoring/DesktopArrivalDetectorTests.cs | 178 +++++++++ .../HybridLoginPendingDetectorTests.cs | 253 +++++++++++++ .../AadJoinWatcherAdapterTests.cs | 202 +++++++++- .../SystemSignals/DesktopArrivalDetector.cs | 59 ++- .../HybridLoginPendingDetector.cs | 157 ++++++++ .../Orchestration/DefaultComponentFactory.cs | 22 +- .../Orchestration/Hosts/AadJoinHost.cs | 28 +- .../Orchestration/Hosts/DesktopArrivalHost.cs | 9 + .../SignalAdapters/AadJoinWatcherAdapter.cs | 103 +++++- .../Runtime/AgentRuntimeHost.cs | 17 + .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../FailureSnapshotBuilderTests.cs | 350 ++++++++++++++++++ .../TableStorage/TableSessionRepository.cs | 6 +- .../Services/FailureSnapshotBuilder.cs | 234 ++++++++++++ .../Services/MaintenanceService.cs | 22 +- .../Services/TableStorageService.Sessions.cs | 23 +- .../AutopilotMonitor.Shared/Constants.cs | 13 + .../DataAccess/ISessionRepository.cs | 3 +- .../Models/SessionApiModels.cs | 12 + .../components/FailureSnapshotBlock.tsx | 133 +++++++ .../components/SessionInfoCard.tsx | 2 + .../autopilot-monitor-web/types/session.ts | 8 + 23 files changed, 1798 insertions(+), 40 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/DesktopArrivalDetectorTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/HybridLoginPendingDetectorTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/HybridLoginPendingDetector.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/FailureSnapshotBuilderTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/FailureSnapshotBlock.tsx diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/DesktopArrivalDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/DesktopArrivalDetectorTests.cs new file mode 100644 index 00000000..6d94477e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/DesktopArrivalDetectorTests.cs @@ -0,0 +1,178 @@ +#nullable enable +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring +{ + /// + /// Unit tests for . Hybrid-User-Driven + /// session diagnosis (e58bcfdb-…, 2026-05-01) showed the detector treated the fooUser + /// OOBE shell as a real user desktop, firing DesktopArrived 6 s after agent start — + /// before the Hybrid reboot to the AD account ever happened. The exclusion now covers + /// the foouser@/autopilot@ Autopilot provisioning placeholders in both UPN and + /// DOMAIN\User shapes. + /// + public sealed class DesktopArrivalDetectorTests + { + // ---------------- Existing exclusions stay covered ---------------- + + [Theory] + [InlineData("SYSTEM")] + [InlineData("system")] + [InlineData("LOCAL SERVICE")] + [InlineData("NETWORK SERVICE")] + [InlineData("DefaultUser0")] + [InlineData("DefaultUser1")] + [InlineData("defaultuser0")] + [InlineData("DefaultUser42")] + public void System_and_default_users_are_excluded(string user) + { + Assert.True(DesktopArrivalDetector.IsExcludedUser(user)); + } + + [Theory] + [InlineData("NT AUTHORITY\\SYSTEM")] + [InlineData("WORKGROUP\\DefaultUser0")] + [InlineData("CONTOSO\\DefaultUser1")] + public void Domain_qualified_system_users_are_excluded(string user) + { + Assert.True(DesktopArrivalDetector.IsExcludedUser(user)); + } + + // ---------------- Real users are NOT excluded ---------------- + + [Theory] + [InlineData("alice")] + [InlineData("CONTOSO\\alice")] + [InlineData("alice@contoso.com")] + [InlineData("bob.smith@fabrikam.com")] + // Codex review 2026-05-01 (Finding 3): the bare-username matcher was tightened + // from prefix-match to exact-match, so the following real account shapes that + // *start with* "autopilot" or "foouser" must now stay through the gate. Reused + // by UserProfileResolver — incorrectly excluding these would resolve the wrong + // home directory. + [InlineData("CONTOSO\\autopilotadmin")] + [InlineData("CONTOSO\\autopilot.admin")] + [InlineData("FABRIKAM\\foouserservice")] + [InlineData("autopilotadmin")] + [InlineData("foouserservice")] + public void Real_users_are_not_excluded(string user) + { + Assert.False(DesktopArrivalDetector.IsExcludedUser(user)); + } + + // ---------------- New: Autopilot placeholder UPN form ---------------- + + [Theory] + [InlineData("foouser@fabrikam.onmicrosoft.com")] + [InlineData("FooUser@fabrikam.onmicrosoft.com")] + [InlineData("FOOUSER@example.com")] + [InlineData("autopilot@contoso.com")] + [InlineData("Autopilot@contoso.com")] + [InlineData("AUTOPILOT@contoso.com")] + public void Autopilot_placeholder_upn_is_excluded(string upn) + { + // Hybrid User-Driven OOBE shell runs explorer.exe under foouser@; + // matching this prevents premature DesktopArrived firing on the foo desktop. + // UPN form is delegated to AadJoinInfo.IsPlaceholderUserEmail (prefix-match + // on the local-part, so any tenant domain works). + Assert.True(DesktopArrivalDetector.IsExcludedUser(upn)); + } + + // ---------------- New: Domain-qualified placeholder (exact bare-name match) ---------------- + + [Theory] + [InlineData("AzureAD\\foouser")] + [InlineData("WORKGROUP\\autopilot")] + [InlineData("AzureAD\\FooUser")] // case-insensitive + [InlineData("AzureAD\\AUTOPILOT")] // case-insensitive + [InlineData("foouser")] // bare username (no domain prefix) + [InlineData("autopilot")] // bare username (no domain prefix) + public void Domain_qualified_placeholder_is_excluded(string user) + { + // WMI Win32_Process.GetOwner sometimes returns DOMAIN\foouser instead of the + // UPN form. The bare-username match is now EXACT (Finding 3) — no prefix. + Assert.True(DesktopArrivalDetector.IsExcludedUser(user)); + } + + // ---------------- Edge cases ---------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Null_or_empty_is_excluded(string? user) + { + Assert.True(DesktopArrivalDetector.IsExcludedUser(user!)); + } + + [Fact] + public void Real_user_email_with_foouser_substring_is_NOT_excluded() + { + // Defense against false positives — UPN match goes through + // AadJoinInfo.IsPlaceholderUserEmail which anchors the local-part at start. + // "realfoouser@…" and "not-autopilot@…" must NOT trigger. + Assert.False(DesktopArrivalDetector.IsExcludedUser("realfoouser@contoso.com")); + Assert.False(DesktopArrivalDetector.IsExcludedUser("not-autopilot@contoso.com")); + } + + [Fact] + public void Real_user_with_default_substring_anywhere_is_NOT_excluded() + { + Assert.False(DesktopArrivalDetector.IsExcludedUser("MyDefaultUser")); + // "DefaultUser" prefix-match: this WOULD trigger because the bare-username + // DefaultUser* code path is still prefix-based (Windows generates + // DefaultUser0/1/2/...). Documenting current behavior. + Assert.True(DesktopArrivalDetector.IsExcludedUser("DefaultUserBob")); + } + + // ============================================================================ + // ResetForRealUserSwitch (Pkt 5 — placeholder→real-user transition) + // ============================================================================ + + [Fact] + public void ResetForRealUserSwitch_does_not_throw_when_detector_is_stopped() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var detector = new DesktopArrivalDetector(logger); + + // Detector has not been Started — Reset must be safe regardless. The Hybrid + // reset path may fire before any explicit Start in test fixtures, and we + // never want host-wiring to crash the agent. + detector.ResetForRealUserSwitch(); + } + + [Fact] + public void ResetForRealUserSwitch_is_idempotent() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var detector = new DesktopArrivalDetector(logger); + + // Calling reset multiple times back-to-back must not leak timer instances or + // throw. The composition root currently invokes it once per real-user join, + // but the contract is still safe-to-repeat. + detector.ResetForRealUserSwitch(); + detector.ResetForRealUserSwitch(); + detector.ResetForRealUserSwitch(); + } + + [Fact] + public void ResetForRealUserSwitch_after_Stop_restarts_polling_path() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var detector = new DesktopArrivalDetector(logger); + + detector.Start(); + detector.Stop(); + + // After Stop the polling timer is null. Reset must reinstate it without + // requiring a fresh Start call (the Hybrid reboot transition shouldn't have + // to rebuild the host). + detector.ResetForRealUserSwitch(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/HybridLoginPendingDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/HybridLoginPendingDetectorTests.cs new file mode 100644 index 00000000..5ea0dfc1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/HybridLoginPendingDetectorTests.cs @@ -0,0 +1,253 @@ +#nullable enable +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 SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring +{ + /// + /// Tests for the single-shot Hybrid User-Driven login-pending detector. Single-shot + /// semantics, conditional emission, and cancel-on-real-user are the contracts that + /// matter — the detector exists explicitly to NOT add periodicity / timer sprawl. + /// + public sealed class HybridLoginPendingDetectorTests + { + private static readonly DateTime Fixed = new DateTime(2026, 5, 1, 14, 11, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public AadJoinWatcher Watcher { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + public InformationalEventPost Post { get; } + // Adapter wires the watcher's AadUserJoined into a real AadUserJoinedLate signal — + // tests need it so TriggerFromTest("...") fires the watcher event the detector + // is subscribed to. + public AadJoinWatcherAdapter Adapter { get; } + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Watcher = new AadJoinWatcher(Logger); + Post = new InformationalEventPost(Ingress, Clock); + Adapter = new AadJoinWatcherAdapter(Watcher, Ingress, Clock); + } + + public void Dispose() + { + Adapter.Dispose(); + Watcher.Dispose(); + Tmp.Dispose(); + } + } + + private static FakeSignalIngressSink.PostedSignal? FindHybridLoginPending(FakeSignalIngressSink ingress) => + ingress.Posted.FirstOrDefault(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.HybridLoginPending); + + // ============================================================================ + // Single-shot emission path + // ============================================================================ + + [Fact] + public void Arm_then_TriggerFromTest_emits_hybrid_login_pending_with_warning_severity() + { + using var f = new Fixture(); + using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger, TimeSpan.FromMinutes(10)); + + detector.Arm(); + detector.TriggerFromTest(); + + var info = FindHybridLoginPending(f.Ingress); + Assert.NotNull(info); + Assert.Equal("HybridLoginPendingDetector", info!.Payload![SignalPayloadKeys.Source]); + Assert.Equal("Warning", info.Payload[SignalPayloadKeys.Severity]); + Assert.Equal("true", info.Payload[SignalPayloadKeys.ImmediateUpload]); + Assert.Equal("10", info.Payload["delayMinutes"]); + Assert.Equal("timer_fired", info.Payload["reason"]); + Assert.Equal("true", info.Payload["isHybridJoin"]); + Assert.True(detector.HasFiredForTest); + } + + [Fact] + public void TriggerFromTest_without_Arm_emits_nothing() + { + // Codex review 2026-05-01: contract tightened — emission requires a deliberate + // Arm(). The earlier behavior (emit-regardless) had a misleading test name and + // a fragile contract; production was safe only because Arm() was the sole timer + // creator. Now the guard is explicit so any future code path that bypasses Arm() + // can't silently produce a hybrid_login_pending event. + using var f = new Fixture(); + using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger); + + detector.TriggerFromTest(); + + Assert.Null(FindHybridLoginPending(f.Ingress)); + Assert.False(detector.HasFiredForTest); + } + + [Fact] + public void Arm_is_idempotent() + { + using var f = new Fixture(); + using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger); + + detector.Arm(); + detector.Arm(); + detector.Arm(); + + // No hybrid_login_pending fires from arming alone — arming just starts a timer. + Assert.Null(FindHybridLoginPending(f.Ingress)); + Assert.True(detector.IsArmedForTest); + } + + [Fact] + public void Multiple_TriggerFromTest_calls_emit_only_once() + { + using var f = new Fixture(); + using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger); + + detector.Arm(); + detector.TriggerFromTest(); + detector.TriggerFromTest(); + detector.TriggerFromTest(); + + // Single-shot — exactly one informational event regardless of how many times + // the timer logic is re-entered. + var matches = f.Ingress.Posted.Where(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.HybridLoginPending).ToList(); + Assert.Single(matches); + } + + // ============================================================================ + // Cancel-on-real-user path + // ============================================================================ + + [Fact] + public void RealUserJoined_before_TriggerFromTest_cancels_emission() + { + using var f = new Fixture(); + using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger); + + detector.Arm(); + detector.TriggerRealUserJoinedFromTest(); + detector.TriggerFromTest(); + + Assert.True(detector.IsCancelledByRealUserForTest); + Assert.False(detector.HasFiredForTest); + Assert.Null(FindHybridLoginPending(f.Ingress)); + } + + [Fact] + public void RealUserJoined_before_Arm_short_circuits_Arm() + { + // The watcher could in principle see a real user before Arm() is called + // (composition-time race). The detector subscribes in its constructor so the + // _cancelledByRealUser flag is set early and Arm() respects it. + using var f = new Fixture(); + using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger); + + detector.TriggerRealUserJoinedFromTest(); + detector.Arm(); + detector.TriggerFromTest(); + + Assert.True(detector.IsCancelledByRealUserForTest); + Assert.False(detector.IsArmedForTest); + Assert.False(detector.HasFiredForTest); + Assert.Null(FindHybridLoginPending(f.Ingress)); + } + + [Fact] + public void Placeholder_user_does_not_cancel_emission() + { + // Placeholder is the EXPECTED state at the time the detector arms — only the + // appearance of a REAL AAD user (not foouser/autopilot) should cancel. + // The detector subscribes to AadUserJoined (the real-user event) only — the + // placeholder pathway never fires that event, so we don't need to simulate it + // here; just verifying the timer fires through unchanged. + using var f = new Fixture(); + using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger); + + detector.Arm(); + // No TriggerRealUserJoinedFromTest() — only a placeholder appeared in JoinInfo. + detector.TriggerFromTest(); + + Assert.False(detector.IsCancelledByRealUserForTest); + Assert.True(detector.HasFiredForTest); + Assert.NotNull(FindHybridLoginPending(f.Ingress)); + } + + // ============================================================================ + // Lifecycle / Dispose + // ============================================================================ + + [Fact] + public void Dispose_after_Arm_does_not_throw() + { + using var f = new Fixture(); + var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger); + detector.Arm(); + detector.Dispose(); + + // No emission expected — dispose ate the timer before it fired. + Assert.Null(FindHybridLoginPending(f.Ingress)); + } + + [Fact] + public void TriggerFromTest_after_Dispose_does_not_emit() + { + using var f = new Fixture(); + var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger); + detector.Arm(); + detector.Dispose(); + + detector.TriggerFromTest(); + + Assert.Null(FindHybridLoginPending(f.Ingress)); + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws(() => new HybridLoginPendingDetector(null!, f.Post, f.Logger)); + Assert.Throws(() => new HybridLoginPendingDetector(f.Watcher, null!, f.Logger)); + Assert.Throws(() => new HybridLoginPendingDetector(f.Watcher, f.Post, null!)); + } + + // ============================================================================ + // Custom delay + // ============================================================================ + + [Fact] + public void Custom_delay_is_reflected_in_payload() + { + using var f = new Fixture(); + using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger, TimeSpan.FromMinutes(15)); + + detector.Arm(); + detector.TriggerFromTest(); + + var info = FindHybridLoginPending(f.Ingress); + Assert.NotNull(info); + Assert.Equal("15", info!.Payload!["delayMinutes"]); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs index 256afed7..dd25af7b 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs @@ -5,7 +5,9 @@ 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 SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes; using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters @@ -43,15 +45,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters adapter.TriggerFromTest("alice@contoso.com", "abcd1234"); - var posted = Assert.Single(f.Ingress.Posted); - Assert.Equal(DecisionSignalKind.AadUserJoinedLate, posted.Kind); - Assert.Equal("AadJoinWatcher", posted.SourceOrigin); - Assert.Equal(EvidenceKind.Derived, posted.Evidence.Kind); - Assert.Equal("contoso.com", posted.Payload!["userDomain"]); - Assert.Equal("true", posted.Payload["hasThumbprint"]); + // Codex review 2026-05-01: dual-emission — decision signal + informational event. + var decisionSignal = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.AadUserJoinedLate); + Assert.Equal("AadJoinWatcher", decisionSignal.SourceOrigin); + Assert.Equal(EvidenceKind.Derived, decisionSignal.Evidence.Kind); + Assert.Equal("contoso.com", decisionSignal.Payload!["userDomain"]); + Assert.Equal("true", decisionSignal.Payload["hasThumbprint"]); - // PII guard: full email MUST NOT appear in payload. - Assert.DoesNotContain("alice@", string.Concat(posted.Payload.Values)); + // PII guard: full email MUST NOT appear in either rail's payload. + Assert.DoesNotContain("alice@", string.Concat(decisionSignal.Payload.Values)); + } + + [Fact] + public void TriggerFromTest_also_emits_aad_user_joined_late_informational_event() + { + using var f = new Fixture(); + using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerFromTest("alice@contoso.com", "abcd1234"); + + // FailureSnapshotBuilder reads the Events table — without this rail it cannot + // see that the real AAD user was ever observed (HandleAadUserJoinedLateV1 is + // observation-only, no timeline effect). Snapshot would always say + // aadJoinState=placeholder/unknown. + var info = f.Ingress.Posted.Single(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.AadUserJoinedLate); + Assert.Equal("AadJoinWatcher", info.Payload![SignalPayloadKeys.Source]); + Assert.Equal("true", info.Payload[SignalPayloadKeys.ImmediateUpload]); + Assert.Equal("Info", info.Payload[SignalPayloadKeys.Severity]); + Assert.Equal("contoso.com", info.Payload["userDomain"]); + Assert.Equal("true", info.Payload["hasThumbprint"]); + + // PII guard: the message must not glue local-part to domain. + Assert.DoesNotContain("alice@", info.Payload[SignalPayloadKeys.Message]); } [Fact] @@ -62,9 +91,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters adapter.TriggerFromTest("bob@tailspin.com", "x"); - var posted = f.Ingress.Posted.Single(); - Assert.Equal(DecisionSignalKind.UserAadSignInComplete, posted.Kind); - Assert.Contains("Post-reboot", posted.Evidence.Summary); + var decisionSignal = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.UserAadSignInComplete); + Assert.Contains("Post-reboot", decisionSignal.Evidence.Summary); + + // Part 2 mode also dual-emits the corresponding info event. + var info = f.Ingress.Posted.Single(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.UserAadSignInComplete); + Assert.Equal("tailspin.com", info.Payload!["userDomain"]); } [Fact] @@ -75,7 +111,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters adapter.TriggerFromTest("user@example.org", ""); - Assert.Equal("false", f.Ingress.Posted[0].Payload!["hasThumbprint"]); + var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.AadUserJoinedLate); + Assert.Equal("false", decision.Payload!["hasThumbprint"]); } [Fact] @@ -86,11 +123,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters adapter.TriggerFromTest("no-at-sign", "x"); - Assert.Equal("unknown", f.Ingress.Posted[0].Payload!["userDomain"]); + var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.AadUserJoinedLate); + Assert.Equal("unknown", decision.Payload!["userDomain"]); } [Fact] - public void Duplicate_trigger_is_deduplicated() + public void Duplicate_trigger_is_deduplicated_on_both_rails() { using var f = new Fixture(); using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); @@ -98,7 +136,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters adapter.TriggerFromTest("alice@contoso.com", "x"); adapter.TriggerFromTest("alice@contoso.com", "x"); - Assert.Single(f.Ingress.Posted); + // Exactly one decision signal + one informational event for the real-user + // path (fire-once semantics on _fired). + Assert.Single(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.AadUserJoinedLate); + Assert.Single( + f.Ingress.Posted, + p => p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.AadUserJoinedLate); + Assert.Equal(2, f.Ingress.Posted.Count); } [Fact] @@ -109,5 +156,130 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Throws(() => new AadJoinWatcherAdapter(f.Watcher, null!, f.Clock)); Assert.Throws(() => new AadJoinWatcherAdapter(f.Watcher, f.Ingress, null!)); } + + // ============================================================================ + // Placeholder path (Hybrid completion-gap fix, 2026-05-01) + // ============================================================================ + + [Fact] + public void TriggerPlaceholderFromTest_emits_aad_placeholder_user_detected_informational_event() + { + using var f = new Fixture(); + using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerPlaceholderFromTest("foouser@fabrikam.onmicrosoft.com"); + + var info = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent); + Assert.Equal(SharedEventTypes.AadPlaceholderUserDetected, info.Payload![SignalPayloadKeys.EventType]); + Assert.Equal("AadJoinWatcher", info.Payload[SignalPayloadKeys.Source]); + Assert.Equal("true", info.Payload[SignalPayloadKeys.ImmediateUpload]); + Assert.Equal("foouser", info.Payload["placeholderType"]); + Assert.Equal("fabrikam.onmicrosoft.com", info.Payload["userDomain"]); + Assert.Contains("registryKey", info.Payload.Keys); + + // PII guard: the local part of the email must not appear (only domain). + Assert.DoesNotContain("foouser@", info.Payload[SignalPayloadKeys.Message]); + } + + [Fact] + public void TriggerPlaceholderFromTest_classifies_autopilot_placeholder() + { + using var f = new Fixture(); + using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerPlaceholderFromTest("autopilot@contoso.com"); + + var info = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent); + Assert.Equal("autopilot", info.Payload!["placeholderType"]); + Assert.Equal("contoso.com", info.Payload["userDomain"]); + } + + [Fact] + public void Duplicate_placeholder_trigger_is_deduplicated() + { + using var f = new Fixture(); + using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerPlaceholderFromTest("foouser@example.com"); + adapter.TriggerPlaceholderFromTest("foouser@example.com"); + adapter.TriggerPlaceholderFromTest("foouser@example.com"); + + Assert.Single(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.InformationalEvent); + } + + [Fact] + public void Placeholder_and_real_user_emit_separate_signals_independently() + { + using var f = new Fixture(); + using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerPlaceholderFromTest("foouser@fabrikam.com"); + adapter.TriggerFromTest("alice@fabrikam.com", "abcd"); + + // 3 posts total after Codex review 2026-05-01 dual-emission: + // - placeholder informational event + // - real-user decision signal (AadUserJoinedLate) + // - real-user informational event (aad_user_joined_late) + Assert.Equal(3, f.Ingress.Posted.Count); + Assert.Single(f.Ingress.Posted, p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et1) + && et1 == SharedEventTypes.AadPlaceholderUserDetected); + Assert.Single(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.AadUserJoinedLate); + Assert.Single(f.Ingress.Posted, p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et2) + && et2 == SharedEventTypes.AadUserJoinedLate); + } + + // ============================================================================ + // onRealUserJoined callback (Pkt 5 — DesktopArrivalDetector reset wiring) + // ============================================================================ + + [Fact] + public void Real_user_join_invokes_onRealUserJoined_callback_exactly_once() + { + using var f = new Fixture(); + int invocations = 0; + using var adapter = new AadJoinWatcherAdapter( + f.Watcher, f.Ingress, f.Clock, + onRealUserJoined: () => invocations++); + + adapter.TriggerFromTest("alice@contoso.com", "x"); + adapter.TriggerFromTest("alice@contoso.com", "x"); // dedup — callback must not fire twice + + Assert.Equal(1, invocations); + } + + [Fact] + public void Placeholder_does_not_invoke_onRealUserJoined_callback() + { + using var f = new Fixture(); + int invocations = 0; + using var adapter = new AadJoinWatcherAdapter( + f.Watcher, f.Ingress, f.Clock, + onRealUserJoined: () => invocations++); + + adapter.TriggerPlaceholderFromTest("foouser@example.com"); + + Assert.Equal(0, invocations); + } + + [Fact] + public void Throwing_onRealUserJoined_callback_does_not_break_signal_post() + { + using var f = new Fixture(); + using var adapter = new AadJoinWatcherAdapter( + f.Watcher, f.Ingress, f.Clock, + onRealUserJoined: () => throw new InvalidOperationException("boom")); + + // The signal post completes BEFORE the callback fires — even if the callback + // throws, the decision signal must already be on the bus. + adapter.TriggerFromTest("alice@contoso.com", "x"); + + Assert.Single(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.AadUserJoinedLate); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs index 8063dd45..14c85400 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs @@ -67,6 +67,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals _pollingTimer = null; } + /// + /// Resets desktop-arrival tracking after a placeholder→real-user transition (Hybrid + /// User-Driven completion-gap fix, 2026-05-01). Used by the composition root when + /// fires for a real user — the previous + /// fooUser desktop the detector observed is invalidated, and polling restarts so the + /// AD-user desktop after the Hybrid reboot is detected as the actual real desktop. + /// + /// Idempotent: safe to call multiple times. No-op if the detector already fired + /// after the reset (a subsequent real-user join after a real-user desktop is also + /// idempotent because the polling timer is restarted regardless and the next match + /// against IsExcludedUser will short-circuit on the still-valid desktop). + /// + /// + public void ResetForRealUserSwitch() + { + _logger.Info("DesktopArrivalDetector: reset for real-user switch (placeholder→real user transition)"); + + // Drop any prior arrival state — subsequent polls must re-evaluate the current + // explorer.exe owner against IsExcludedUser anew. + _desktopArrived = false; + _excludedUserTraced = false; + + // Restart polling. Dispose any existing timer first to avoid leaking ticks + // from an already-running schedule. + _pollingTimer?.Dispose(); + _pollingTimer = new Timer( + PollForDesktop, + null, + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(PollingIntervalSeconds)); + } + private void PollForDesktop(object state) { if (_desktopArrived) @@ -179,14 +211,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals /// /// Returns true if the user name matches any excluded system/service account. - /// Handles both "User" and "DOMAIN\User" formats. - /// Also matches the pattern DefaultUser* (case-insensitive). + /// Handles "User", "DOMAIN\User", and UPN ("user@domain") formats. + /// Also matches the patterns DefaultUser* and the Autopilot provisioning placeholders + /// foouser@* / autopilot@* (case-insensitive). The placeholder match prevents the + /// fooUser OOBE shell on Hybrid User-Driven enrollments from being treated as a + /// real user desktop. /// internal static bool IsExcludedUser(string fullUserName) { if (string.IsNullOrEmpty(fullUserName)) return true; + // UPN form (user@domain) — delegate to the same placeholder oracle the + // AadJoinWatcher uses, so the foouser@/autopilot@ list stays in one place. + if (fullUserName.IndexOf('@') >= 0 + && AadJoinInfo.IsPlaceholderUserEmail(fullUserName)) + return true; + // Extract just the username part (after backslash if present) var userName = fullUserName; var backslashIndex = fullUserName.LastIndexOf('\\'); @@ -204,6 +245,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase)) return true; + // DOMAIN\foouser / DOMAIN\autopilot — the WMI GetOwner call sometimes returns a + // synthetic local-machine domain instead of the UPN. EXACT match the bare + // username here (not prefix), so legitimate real accounts like + // CONTOSO\autopilotadmin or DOMAIN\foouserservice stay through the gate. + // Codex review 2026-05-01 (Finding 3): the previous prefix match was too broad + // and reused via UserProfileResolver, which would have resolved to the wrong + // user profile for any account starting with "autopilot" or "foouser". + // The UPN form (foouser@*, autopilot@*) is still handled above by + // AadJoinInfo.IsPlaceholderUserEmail — that path is what covers the + // real-world Autopilot placeholders (foouser@.onmicrosoft.com). + if (string.Equals(userName, "foouser", StringComparison.OrdinalIgnoreCase) + || string.Equals(userName, "autopilot", StringComparison.OrdinalIgnoreCase)) + return true; + return false; } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/HybridLoginPendingDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/HybridLoginPendingDetector.cs new file mode 100644 index 00000000..8347a3d1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/HybridLoginPendingDetector.cs @@ -0,0 +1,157 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Shared.Models; +using SharedConstants = AutopilotMonitor.Shared.Constants; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Single-shot detector for the Hybrid User-Driven completion-gap (2026-05-01 trigger: + /// session e58bcfdb-3e68-4f23-a3c2-437429ca9e78). After a Hybrid AAD-Join reboot the + /// agent restarts and waits for the user to log in with their real AD/AAD account + /// (which replaces the foouser/autopilot placeholder in JoinInfo). When that login is + /// overdue the agent today goes silent until the backend 5-h watchdog fires; this + /// detector emits a hybrid_login_pending warning event after a short single-shot + /// timer so the operator sees an explicit "stuck waiting for AD login" signal in the + /// timeline. + /// + /// Conditions checked at : composition root must have already + /// confirmed a) post-reboot, b) isHybridJoin == true. The detector itself only + /// owns the timer + cancel-path. No polling. + /// No periodicity. Fires at most once per agent process. + /// + /// + internal sealed class HybridLoginPendingDetector : IDisposable + { + internal const int DefaultDelayMinutes = 10; + internal const string SourceLabel = "HybridLoginPendingDetector"; + + private readonly AadJoinWatcher _watcher; + private readonly InformationalEventPost _post; + private readonly AgentLogger _logger; + private readonly TimeSpan _delay; + private readonly object _lock = new object(); + + private Timer? _timer; + private bool _armed; + private bool _fired; + private bool _cancelledByRealUser; + private int _disposed; + + internal HybridLoginPendingDetector( + AadJoinWatcher watcher, + InformationalEventPost post, + AgentLogger logger, + TimeSpan? delay = null) + { + _watcher = watcher ?? throw new ArgumentNullException(nameof(watcher)); + _post = post ?? throw new ArgumentNullException(nameof(post)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _delay = delay ?? TimeSpan.FromMinutes(DefaultDelayMinutes); + + // Subscribe up-front so a real-user join that arrives BEFORE Arm() is still + // recorded — _cancelledByRealUser short-circuits any later Arm() call. + _watcher.AadUserJoined += OnAadUserJoined; + } + + /// + /// Starts the single-shot timer. Idempotent — repeated calls after the first arm + /// are no-ops. Has no effect if the watcher already saw a real AAD user (the + /// arming was racy and the cancel won), or if the detector already fired. + /// + public void Arm() + { + lock (_lock) + { + if (_disposed != 0) return; + if (_armed || _fired) return; + if (_cancelledByRealUser) + { + _logger.Info("HybridLoginPendingDetector: arm requested but real AAD user already joined — skipped"); + return; + } + + _armed = true; + _logger.Info( + $"HybridLoginPendingDetector: armed — will emit hybrid_login_pending in {_delay.TotalMinutes:F0} min if real AAD user has not joined by then"); + + _timer = new Timer(OnTimer, null, _delay, Timeout.InfiniteTimeSpan); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _watcher.AadUserJoined -= OnAadUserJoined; } catch { } + lock (_lock) + { + _timer?.Dispose(); + _timer = null; + } + } + + // Test seam — bypasses the timer schedule. Same lock + state guards as the real path. + internal void TriggerFromTest() => OnTimer(null); + + // Test seam — synthesises an AadUserJoined arrival without going through the + // watcher's private event-raise path. The watcher's events can only be invoked from + // inside the watcher class, so production unit tests have no way to observe the + // detector's cancel-on-real-user behavior otherwise. + internal void TriggerRealUserJoinedFromTest() => + OnAadUserJoined(this, new AadUserJoinedEventArgs("test@example.com", "test-thumbprint")); + + internal bool IsArmedForTest { get { lock (_lock) { return _armed; } } } + internal bool IsCancelledByRealUserForTest { get { lock (_lock) { return _cancelledByRealUser; } } } + internal bool HasFiredForTest { get { lock (_lock) { return _fired; } } } + + private void OnTimer(object? state) => EmitInternal(reason: "timer_fired"); + + private void OnAadUserJoined(object sender, AadUserJoinedEventArgs e) + { + lock (_lock) + { + _cancelledByRealUser = true; + if (!_armed || _fired) return; + + _timer?.Dispose(); + _timer = null; + _logger.Info("HybridLoginPendingDetector: cancelled — real AAD user joined before timeout"); + } + } + + private void EmitInternal(string reason) + { + lock (_lock) + { + // _armed guard (Codex review 2026-05-01): production is safe because the + // timer is only created in Arm(), but explicit guard makes the contract + // unambiguous — emission requires a deliberate Arm(), full stop. + if (!_armed || _fired || _cancelledByRealUser || _disposed != 0) return; + _fired = true; + _timer?.Dispose(); + _timer = null; + } + + var data = new Dictionary(StringComparer.Ordinal) + { + ["delayMinutes"] = ((int)_delay.TotalMinutes).ToString(), + ["reason"] = reason, + ["isHybridJoin"] = "true", // armed only when composition root verified this + }; + + _post.Emit( + eventType: SharedConstants.EventTypes.HybridLoginPending, + source: SourceLabel, + message: $"Hybrid AAD Join: {(int)_delay.TotalMinutes} min after reboot still no real AD user — login overdue (placeholder still active in JoinInfo)", + severity: EventSeverity.Warning, + immediateUpload: true, + data: data); + + _logger.Warning("HybridLoginPendingDetector: emitted hybrid_login_pending warning"); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 8d4dbd54..6ea8962e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -69,6 +69,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private ImeLogHost? _imeLogHost; private EspAndHelloHost? _espAndHelloHost; + private AadJoinHost? _aadJoinHost; private Transport.Telemetry.ITelemetrySpool? _telemetrySpool; /// @@ -84,6 +85,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// internal EspAndHelloHost? EspAndHelloHost => _espAndHelloHost; + /// + /// Exposes the so the runtime host can arm the Hybrid + /// User-Driven login-pending detector after observing a reboot-kill on a Hybrid-AAD + /// device (2026-05-01 completion-gap fix). Returns null before + /// has been called. + /// + internal AadJoinHost? AadJoinHost => _aadJoinHost; + /// /// Exposes the IME tracker's package-state list to peripheral consumers such as the /// FinalStatusBuilder in M4.6.β. Returns null before @@ -178,9 +187,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration stateDirectory: _stateDirectory); hosts.Add(_espAndHelloHost); - hosts.Add(new DesktopArrivalHost(logger, ingress, clock)); + // Hybrid User-Driven completion-gap fix (2026-05-01): the AadJoinHost notifies + // the DesktopArrivalHost when a real AAD user replaces the foouser/autopilot + // placeholder, so the detector resets and re-evaluates after the Hybrid reboot + // instead of staying latched on the foo desktop. Order matters — DesktopArrivalHost + // must exist before AadJoinHost so the callback target is available. + var desktopArrivalHost = new DesktopArrivalHost(logger, ingress, clock); + hosts.Add(desktopArrivalHost); - hosts.Add(new AadJoinHost(logger, ingress, clock)); + _aadJoinHost = new AadJoinHost( + logger, ingress, clock, + onRealUserJoined: desktopArrivalHost.RequestResetForRealUserSwitch); + hosts.Add(_aadJoinHost); // 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). diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/AadJoinHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/AadJoinHost.cs index 8ef9af85..1de89e15 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/AadJoinHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/AadJoinHost.cs @@ -1,4 +1,5 @@ #nullable enable +using System; using System.Threading; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; @@ -13,20 +14,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private readonly AadJoinWatcher _watcher; private readonly AadJoinWatcherAdapter _adapter; + private readonly HybridLoginPendingDetector _hybridLoginPendingDetector; private int _disposed; - public AadJoinHost(AgentLogger logger, ISignalIngressSink ingress, IClock clock) + public AadJoinHost( + AgentLogger logger, + ISignalIngressSink ingress, + IClock clock, + Action? onRealUserJoined = null) { _watcher = new AadJoinWatcher(logger); - _adapter = new AadJoinWatcherAdapter(_watcher, ingress, clock); + _adapter = new AadJoinWatcherAdapter(_watcher, ingress, clock, onRealUserJoined: onRealUserJoined); + _hybridLoginPendingDetector = new HybridLoginPendingDetector( + watcher: _watcher, + post: new InformationalEventPost(ingress, clock), + logger: logger); } public void Start() => _watcher.Start(); public void Stop() => _watcher.Stop(); + /// + /// Composition-root entry point for the Hybrid User-Driven completion-gap fix + /// (2026-05-01). Only call when both prerequisites hold: a) the prior agent process + /// was killed by an OS reboot (previousExitType=reboot_kill), b) the + /// Autopilot profile is Hybrid AAD Join (isHybridJoin=true). The detector + /// itself enforces single-shot semantics + cancel-on-real-user — repeated calls or + /// late calls after a real user joined are safe no-ops. + /// + public void ArmHybridLoginPendingDetector() => _hybridLoginPendingDetector.Arm(); + + // Test seam — exposes the detector for unit tests in the V2.Core.Tests project. + internal HybridLoginPendingDetector HybridLoginPendingDetectorForTest => _hybridLoginPendingDetector; + public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _hybridLoginPendingDetector.Dispose(); } catch { } try { _adapter.Dispose(); } catch { } try { _watcher.Dispose(); } catch { } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DesktopArrivalHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DesktopArrivalHost.cs index 39969198..12643d57 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DesktopArrivalHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DesktopArrivalHost.cs @@ -24,6 +24,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration public void Start() => _detector.Start(); public void Stop() => _detector.Stop(); + /// + /// Resets desktop-arrival tracking after a placeholder→real-user transition + /// (Hybrid User-Driven completion-gap fix, 2026-05-01). Wired by the composition + /// root through 's onRealUserJoined callback so the + /// fooUser desktop the detector observed during foo-OOBE is invalidated and polling + /// restarts to detect the AD-user desktop after the Hybrid reboot. + /// + public void RequestResetForRealUserSwitch() => _detector.ResetForRealUserSwitch(); + public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 1) return; diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs index 9516c78e..6f95180f 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs @@ -5,50 +5,70 @@ 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; +using SharedConstants = AutopilotMonitor.Shared.Constants; namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters { /// - /// Adapter for → - /// (Part 1) or (Part 2). Plan §2.1a / §2.2. - /// - /// Only the AadUserJoined-Event (real user, non-placeholder) ist Decision-relevant - /// und wird als Signal emittiert. PlaceholderUserDetected ist diagnostisch - /// (transienter Provisioning-Account) und wird aktuell nicht als Decision-Signal - /// weitergereicht — bei Bedarf später als Event-Timeline-Entry emittierbar. - /// + /// Adapter for : + /// + /// → + /// (Part 1) or + /// (Part 2). Plan §2.1a / §2.2. + /// → emits a + /// aad_placeholder_user_detected informational event for backend timeline + /// visibility (Hybrid User-Driven completion-gap fix, 2026-05-01). NOT a decision signal — + /// the engine has no for placeholder presence and the + /// placeholder is expected to be replaced by a real user. + /// /// internal sealed class AadJoinWatcherAdapter : IDisposable { + private const string SourceLabel = "AadJoinWatcher"; + private readonly AadJoinWatcher _watcher; private readonly ISignalIngressSink _ingress; private readonly IClock _clock; + private readonly InformationalEventPost _post; + private readonly Action? _onRealUserJoined; private readonly bool _part2Mode; private bool _fired; + private bool _placeholderFired; public AadJoinWatcherAdapter( AadJoinWatcher watcher, ISignalIngressSink ingress, IClock clock, - bool part2Mode = false) + bool part2Mode = false, + Action? onRealUserJoined = null) { _watcher = watcher ?? throw new ArgumentNullException(nameof(watcher)); _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _post = new InformationalEventPost(ingress, clock); _part2Mode = part2Mode; + _onRealUserJoined = onRealUserJoined; _watcher.AadUserJoined += OnAadUserJoined; + _watcher.PlaceholderUserDetected += OnPlaceholderUserDetected; } public void Dispose() { _watcher.AadUserJoined -= OnAadUserJoined; + _watcher.PlaceholderUserDetected -= OnPlaceholderUserDetected; } private void OnAadUserJoined(object sender, AadUserJoinedEventArgs e) => EmitInternal(e.UserEmail, e.Thumbprint); + private void OnPlaceholderUserDetected(object sender, AadPlaceholderUserDetectedEventArgs e) => + EmitPlaceholderInternal(e.UserEmail); + internal void TriggerFromTest(string userEmail, string thumbprint) => EmitInternal(userEmail, thumbprint); + internal void TriggerPlaceholderFromTest(string userEmail) => EmitPlaceholderInternal(userEmail); + private void EmitInternal(string userEmail, string thumbprint) { if (_fired) return; @@ -67,7 +87,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _ingress.Post( kind: kind, occurredAtUtc: _clock.UtcNow, - sourceOrigin: "AadJoinWatcher", + sourceOrigin: SourceLabel, evidence: new Evidence( kind: EvidenceKind.Derived, identifier: "aad-join-watcher-v1", @@ -79,6 +99,61 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters ["registryKey"] = @"HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo", }), payload: payload); + + // Codex review 2026-05-01 — also dual-emit as informational event. + // HandleAadUserJoinedLateV1 / Part 2 handler in DecisionEngine.Classic is + // observation-only (no timeline effect), so without this dual-emission the + // backend Events table never sees the real-user join. FailureSnapshotBuilder + // (and any other event-stream consumer) needs this anchor to correctly classify + // aadJoinState=real_user. Same PII-safe payload shape as the decision signal. + var infoEventType = _part2Mode + ? SharedConstants.EventTypes.UserAadSignInComplete + : SharedConstants.EventTypes.AadUserJoinedLate; + var infoMessage = _part2Mode + ? $"Post-reboot AAD user sign-in detected (domain={payload["userDomain"]})" + : $"Late AAD user join detected (domain={payload["userDomain"]})"; + _post.Emit( + eventType: infoEventType, + source: SourceLabel, + message: infoMessage, + severity: EventSeverity.Info, + immediateUpload: true, + data: payload, + occurredAtUtc: _clock.UtcNow); + + // Pkt 5 — notify the composition root that a real AAD user has appeared, so the + // DesktopArrivalDetector can be reset for the AD-user-after-Hybrid-reboot case. + // Fire AFTER the decision signal so reducer-state ordering remains the canonical source. + try { _onRealUserJoined?.Invoke(); } + catch { /* callback is best-effort — never let host wiring break the signal post */ } + } + + private void EmitPlaceholderInternal(string userEmail) + { + if (_placeholderFired) return; + _placeholderFired = true; + + // Hybrid User-Driven OOBE: foouser@/autopilot@ is the transient provisioning account. + // Emit a backend-timeline event so operators can see "Foo phase active" — gives the + // diagnose path an explicit anchor when the real-user join is overdue. + var placeholderType = ClassifyPlaceholder(userEmail); + var data = new Dictionary(StringComparer.Ordinal) + { + ["userDomain"] = ExtractDomain(userEmail), + ["placeholderType"] = placeholderType, + ["registryKey"] = @"HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo", + }; + + // PII-friendly message: placeholderType + domain reported separately so the + // local-part of the email is never glued to the domain in the message string. + _post.Emit( + eventType: SharedConstants.EventTypes.AadPlaceholderUserDetected, + source: SourceLabel, + message: $"Autopilot provisioning placeholder detected (type={placeholderType}, domain={data["userDomain"]}) — waiting for real AAD user join", + severity: EventSeverity.Info, + immediateUpload: true, + data: data, + occurredAtUtc: _clock.UtcNow); } private static string ExtractDomain(string email) @@ -87,5 +162,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters var at = email.IndexOf('@'); return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown"; } + + private static string ClassifyPlaceholder(string email) + { + if (string.IsNullOrEmpty(email)) return "unknown"; + if (email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)) return "foouser"; + if (email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase)) return "autopilot"; + return "unknown"; + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs index b2de0375..1fd2fb03 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs @@ -6,6 +6,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; using AutopilotMonitor.Agent.V2.Core.Orchestration; using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Termination; using AutopilotMonitor.DecisionCore.Classifiers; using AutopilotMonitor.DecisionCore.Engine; @@ -333,6 +334,22 @@ namespace AutopilotMonitor.Agent.V2.Runtime if (string.Equals(previousExit?.ExitType, "reboot_kill", StringComparison.OrdinalIgnoreCase)) { LifecycleEmitters.PostSystemRebootObserved(orchestrator.IngressSink, previousExit, logger); + + // Hybrid User-Driven completion-gap fix (2026-05-01): the reboot is the + // expected switch from foouser/autopilot OOBE to the real AD account. + // If the user never logs in we go silent until the 5-h backend watchdog; + // arm a single-shot 10-min detector that emits hybrid_login_pending + // once if the AAD user join is overdue. Cancelled when AadJoinWatcher + // sees the real user. Only armed for actual Hybrid devices — non-Hybrid + // reboots don't have the placeholder→real-user flow that motivates this. + if (!isWhiteGloveResume && EnrollmentRegistryDetector.DetectHybridJoin()) + { + try { componentFactory.AadJoinHost?.ArmHybridLoginPendingDetector(); } + catch (Exception ex) + { + logger.Warning($"ArmHybridLoginPendingDetector threw: {ex.Message}"); + } + } } // V2 race-fix follow-up (10c8e0bf review, 2026-04-27) — diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 2596e4ad..9b5dddac 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -520 +541 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index d729899c..39ccce40 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1235 +1240 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FailureSnapshotBuilderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FailureSnapshotBuilderTests.cs new file mode 100644 index 00000000..c3be7ab5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/FailureSnapshotBuilderTests.cs @@ -0,0 +1,350 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for the failure-state snapshot built when the maintenance 5h-timeout sweep +/// graduates a session to Failed (Hybrid User-Driven completion-gap fix, 2026-05-01). +/// The snapshot is the single point of post-mortem context for stuck sessions, so the +/// shape contract has to be stable: schemaVersion, missingSignals list, and the lifecycle +/// anchors all need to round-trip cleanly. Tests focus on the diagnostic value: would an +/// operator looking at the snapshot for session e58bcfdb-… understand what went wrong? +/// +public class FailureSnapshotBuilderTests +{ + private static readonly DateTime Now = new DateTime(2026, 5, 1, 18, 45, 31, DateTimeKind.Utc); + + private static EnrollmentEvent Event(string type, DateTime ts, string? message = null, + Dictionary? data = null) => new() + { + EventType = type, + Timestamp = ts, + Source = "test", + Message = message ?? type, + Data = data!, + }; + + [Fact] + public void Build_returns_null_for_empty_event_list() + { + Assert.Null(FailureSnapshotBuilder.Build(null, Now)); + Assert.Null(FailureSnapshotBuilder.Build(Array.Empty(), Now)); + } + + [Fact] + public void Build_carries_schemaVersion_and_generation_metadata() + { + var json = FailureSnapshotBuilder.Build(new[] + { + Event("agent_started", Now.AddHours(-5)), + }, Now); + + Assert.NotNull(json); + var obj = JObject.Parse(json!); + Assert.Equal(FailureSnapshotBuilder.CurrentSchemaVersion, (int)obj["schemaVersion"]!); + Assert.NotNull(obj["generatedAtUtc"]); + Assert.Equal(1, (int)obj["eventCount"]!); + } + + [Fact] + public void Build_extracts_last_esp_phase_from_esp_phase_changed_events() + { + var t0 = Now.AddHours(-5); + var json = FailureSnapshotBuilder.Build(new[] + { + Event("esp_phase_changed", t0.AddSeconds(1), + data: new Dictionary { ["espPhase"] = "DeviceSetup" }), + Event("esp_phase_changed", t0.AddSeconds(3), + data: new Dictionary { ["espPhase"] = "AccountSetup" }), + }, Now); + + var obj = JObject.Parse(json!); + Assert.Equal("AccountSetup", (string?)obj["lastEspPhase"]); + Assert.NotNull(obj["lastEspPhaseAtUtc"]); + } + + [Fact] + public void Build_records_desktop_arrival_with_timestamp() + { + var t = Now.AddHours(-5).AddSeconds(7); + var json = FailureSnapshotBuilder.Build(new[] + { + Event("desktop_arrived", t), + }, Now); + + var obj = JObject.Parse(json!); + Assert.True((bool)obj["desktopArrived"]!); + Assert.NotNull(obj["desktopArrivedAtUtc"]); + } + + [Fact] + public void Build_records_hello_policy_disabled_state() + { + var json = FailureSnapshotBuilder.Build(new[] + { + Event("hello_policy_detected", Now.AddHours(-4), + data: new Dictionary { ["helloEnabled"] = "false" }), + }, Now); + + var obj = JObject.Parse(json!); + Assert.True((bool)obj["helloPolicyDetected"]!); + Assert.False((bool)obj["helloPolicyEnabled"]!); + } + + // ============================================================================ + // AAD join-state classification + // ============================================================================ + + [Fact] + public void Build_classifies_placeholder_state_from_aad_placeholder_event() + { + var json = FailureSnapshotBuilder.Build(new[] + { + Event("aad_placeholder_user_detected", Now.AddHours(-4), + data: new Dictionary { ["placeholderType"] = "foouser" }), + }, Now); + + var obj = JObject.Parse(json!); + Assert.Equal("placeholder", (string?)obj["aadJoinState"]); + } + + [Fact] + public void Build_classifies_real_user_state_from_aad_user_joined_late() + { + var json = FailureSnapshotBuilder.Build(new[] + { + Event("aad_user_joined_late", Now.AddHours(-3)), + }, Now); + + var obj = JObject.Parse(json!); + Assert.Equal("real_user", (string?)obj["aadJoinState"]); + } + + [Fact] + public void Build_aad_user_join_slot_is_satisfied_by_part_1_event() + { + // Codex review 2026-05-01 (Finding 1): the dual-emitted informational event + // is what makes this work. Before the fix, AadJoinWatcherAdapter only posted + // a DecisionSignal — HandleAadUserJoinedLateV1 emits no timeline effect, so + // the Events table never held an aad_user_joined_late row. The snapshot then + // always marked it missing AND classified aadJoinState as placeholder/unknown + // even on healthy real-user joins. + var json = FailureSnapshotBuilder.Build(new[] + { + Event("aad_user_joined_late", Now.AddHours(-3)), + }, Now); + + var obj = JObject.Parse(json!); + var missing = obj["missingSignals"]!.ToObject>()!; + Assert.DoesNotContain("aad_user_join", missing); // slot satisfied + Assert.DoesNotContain("aad_user_joined_late", missing); // literal must never appear + } + + [Fact] + public void Build_aad_user_join_slot_is_satisfied_by_part_2_event() + { + // Codex review 2026-05-01 (Finding 4): Part 2 (post-reboot user sign-in) emits + // user_aad_signin_complete instead of aad_user_joined_late. Both must satisfy + // the same conceptual slot — analog to hello_terminal admitting any of the + // four Hello terminal event types. Without the new branch, every healthy Part-2 + // session would falsely list aad_user_joined_late as missing. + var json = FailureSnapshotBuilder.Build(new[] + { + Event("user_aad_signin_complete", Now.AddHours(-3)), + }, Now); + + var obj = JObject.Parse(json!); + Assert.Equal("real_user", (string?)obj["aadJoinState"]); + + var missing = obj["missingSignals"]!.ToObject>()!; + Assert.DoesNotContain("aad_user_join", missing); // slot satisfied + Assert.DoesNotContain("aad_user_joined_late", missing); // literal must never appear + Assert.DoesNotContain("user_aad_signin_complete", missing); // ditto + } + + [Fact] + public void Build_aad_user_join_slot_is_listed_missing_when_neither_part_event_present() + { + // The trigger session e58bcfdb saw no real-user join at all — the slot must + // be reported missing. Bare event list (just agent_started) keeps the test + // focused on the slot logic; the e58bcfdb-replay test below covers the + // realistic full anchor set. + var json = FailureSnapshotBuilder.Build(new[] + { + Event("agent_started", Now.AddHours(-5)), + }, Now); + + var obj = JObject.Parse(json!); + var missing = obj["missingSignals"]!.ToObject>()!; + Assert.Contains("aad_user_join", missing); + } + + [Fact] + public void Build_falls_back_to_aad_join_status_isFooUser_when_only_that_is_present() + { + var json = FailureSnapshotBuilder.Build(new[] + { + Event("aad_join_status", Now.AddHours(-4), + data: new Dictionary { ["isFooUser"] = "true" }), + }, Now); + + var obj = JObject.Parse(json!); + Assert.Equal("placeholder", (string?)obj["aadJoinState"]); + } + + [Fact] + public void Build_real_user_event_overrides_earlier_placeholder() + { + // The temporal pattern in Hybrid User-Driven: foo desktop appears (placeholder), + // reboot, real user signs in (real_user). Snapshot should report the LATEST state. + var json = FailureSnapshotBuilder.Build(new[] + { + Event("aad_placeholder_user_detected", Now.AddHours(-5)), + Event("aad_user_joined_late", Now.AddHours(-3)), + }, Now); + + var obj = JObject.Parse(json!); + Assert.Equal("real_user", (string?)obj["aadJoinState"]); + } + + // ============================================================================ + // Reboot + Hybrid context + // ============================================================================ + + [Fact] + public void Build_records_reboot_observation_and_hybrid_flag() + { + var json = FailureSnapshotBuilder.Build(new[] + { + Event("autopilot_profile", Now.AddHours(-5), + data: new Dictionary { ["isHybridJoin"] = "true", ["enrollmentType"] = "v1" }), + Event("system_reboot_detected", Now.AddHours(-4)), + }, Now); + + var obj = JObject.Parse(json!); + Assert.True((bool)obj["rebootObserved"]!); + Assert.True((bool)obj["isHybridJoin"]!); + Assert.Equal("v1", (string?)obj["enrollmentType"]); + } + + // ============================================================================ + // Missing-signal computation — the diagnostic core of the snapshot + // ============================================================================ + + [Fact] + public void Build_lists_missing_signals_for_e58bcfdb_style_session() + { + // Replays the canonical anchors from session e58bcfdb-… (the trigger session for + // this fix). Confirms the snapshot would have surfaced exactly the gaps we + // identified in the diagnosis: no esp_exiting, no hello terminal, no + // aad_user_joined_late, no enrollment_complete. + var t0 = new DateTime(2026, 5, 1, 13, 45, 31, DateTimeKind.Utc); + var nowAtTimeout = t0.AddHours(5); + + var events = new[] + { + Event("agent_started", t0), + Event("autopilot_profile", t0.AddSeconds(1), + data: new Dictionary { ["isHybridJoin"] = "true", ["enrollmentType"] = "v1" }), + Event("esp_phase_changed", t0.AddSeconds(2), + data: new Dictionary { ["espPhase"] = "DeviceSetup" }), + Event("aad_join_status", t0.AddSeconds(2), + data: new Dictionary { ["isFooUser"] = "true" }), + Event("esp_phase_changed", t0.AddSeconds(3), + data: new Dictionary { ["espPhase"] = "AccountSetup" }), + Event("desktop_arrived", t0.AddSeconds(6)), + Event("system_reboot_detected", t0.AddMinutes(25).AddSeconds(53)), + }; + + var json = FailureSnapshotBuilder.Build(events, nowAtTimeout); + var obj = JObject.Parse(json!); + + var missing = obj["missingSignals"]!.ToObject>()!; + Assert.Contains("esp_exiting", missing); + Assert.Contains("hello_policy_detected", missing); + Assert.Contains("hello_terminal", missing); + Assert.Contains("aad_user_join", missing); // conceptual slot — Part 1 OR Part 2 + Assert.Contains("enrollment_complete", missing); + + // Things the session DID see should NOT be in the missing list. + Assert.DoesNotContain("esp_phase_changed", missing); + Assert.DoesNotContain("desktop_arrived", missing); + + // Slot label is the conceptual name; the literal Part 1 / Part 2 event-type + // names must NOT appear directly in missingSignals (that would double-count). + Assert.DoesNotContain("aad_user_joined_late", missing); + Assert.DoesNotContain("user_aad_signin_complete", missing); + + // High-level diagnostic fields render correctly. + Assert.Equal("AccountSetup", (string?)obj["lastEspPhase"]); + Assert.True((bool)obj["desktopArrived"]!); + Assert.True((bool)obj["rebootObserved"]!); + Assert.True((bool)obj["isHybridJoin"]!); + Assert.Equal("placeholder", (string?)obj["aadJoinState"]); + } + + [Fact] + public void Build_treats_any_hello_terminal_as_satisfying_the_slot() + { + // hello_terminal is the conceptual slot — any of {completed, failed, blocked, skipped} + // satisfies it. Tested with hello_skipped which is the most common Hybrid path. + var json = FailureSnapshotBuilder.Build(new[] + { + Event("hello_skipped", Now.AddHours(-4)), + }, Now); + + var obj = JObject.Parse(json!); + var missing = obj["missingSignals"]!.ToObject>()!; + Assert.DoesNotContain("hello_terminal", missing); + } + + // ============================================================================ + // Timing / stale silence + // ============================================================================ + + [Fact] + public void Build_computes_silenceMinutes_from_last_event_to_now() + { + var t = Now.AddHours(-3); // last event was 3h ago + var json = FailureSnapshotBuilder.Build(new[] { Event("agent_started", t) }, Now); + + var obj = JObject.Parse(json!); + var silence = (int)obj["silenceMinutes"]!; + Assert.InRange(silence, 179, 181); + } + + [Fact] + public void Build_handles_unsorted_input_via_secondary_sort() + { + // The contract says events come sorted, but the builder defensively re-sorts. + var t0 = Now.AddHours(-5); + var json = FailureSnapshotBuilder.Build(new[] + { + Event("esp_phase_changed", t0.AddSeconds(3), + data: new Dictionary { ["espPhase"] = "AccountSetup" }), + Event("esp_phase_changed", t0.AddSeconds(1), + data: new Dictionary { ["espPhase"] = "DeviceSetup" }), + }, Now); + + var obj = JObject.Parse(json!); + Assert.Equal("AccountSetup", (string?)obj["lastEspPhase"]); + } + + [Fact] + public void Build_tolerates_events_with_null_or_missing_data() + { + // Events stored before the Data field was added still have to parse cleanly. + var json = FailureSnapshotBuilder.Build(new[] + { + new EnrollmentEvent { EventType = "agent_started", Timestamp = Now.AddHours(-5), Source = "test", Data = null! }, + }, Now); + + Assert.NotNull(json); + var obj = JObject.Parse(json!); + Assert.Equal(1, (int)obj["eventCount"]!); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs index af609be1..27cbc8e3 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -49,11 +49,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null, DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false, - string? failureSource = null, string? adminMarkedAction = null) + string? failureSource = null, string? adminMarkedAction = null, + string? failureSnapshotJson = null) => _storage.UpdateSessionStatusAsync(tenantId, sessionId, status, currentPhase, failureReason, completedAt, earliestEventTimestamp, latestEventTimestamp, isPreProvisioned, isUserDriven, resumedAt, - stalledAt, clearStalledAt, clearFailureReason, failureSource, adminMarkedAction); + stalledAt, clearStalledAt, clearFailureReason, failureSource, adminMarkedAction, + failureSnapshotJson); public Task QueueServerActionAsync(string tenantId, string sessionId, ServerAction action) => _storage.QueueServerActionAsync(tenantId, sessionId, action); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs new file mode 100644 index 00000000..848546f8 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Builds a compact JSON snapshot of "what we last knew about the session" at the + /// moment the maintenance watchdog times it out. Hybrid User-Driven completion-gap + /// fix (2026-05-01 trigger: session e58bcfdb-3e68-4f23-a3c2-437429ca9e78). Today's + /// 5h-timeout failure reason is just the literal string "Session timed out after 5 + /// hours" — that gives operators no insight into where the session was stuck. This + /// builder walks the session's events and extracts the canonical lifecycle anchors + /// (last ESP phase, desktop arrival, Hello policy, AAD-join state, network state, + /// reboot observation) plus a list of MISSING signals so the snapshot answers + /// "why did this not complete?" at a glance. + /// + /// The shape is intentionally flat and small (≤ ~1 KB JSON typical) so it fits in a + /// single Sessions-table column without strangling Storage costs. Schema is versioned + /// so the Web UI can render forward-compatibly. + /// + /// + public static class FailureSnapshotBuilder + { + public const int CurrentSchemaVersion = 1; + + // Canonical signals we expect to have seen by the time a healthy session completes. + // Missing entries are surfaced in the snapshot under "missingSignals". Some entries + // are conceptual slots that admit multiple concrete event types (see special-case + // branches in Build): "hello_terminal" is satisfied by any Hello terminal event, + // and "aad_user_join" by either the Part 1 (aad_user_joined_late) or Part 2 + // (user_aad_signin_complete) real-user event. + private static readonly string[] CanonicalCompletionSignals = + { + "esp_phase_changed", + "esp_exiting", + "desktop_arrived", + "hello_policy_detected", + "hello_provisioning_completed", // OR _failed / _blocked / _skipped — emitted as "hello_terminal" slot + "aad_user_joined_late", // OR user_aad_signin_complete — emitted as "aad_user_join" slot + "enrollment_complete", + }; + + /// + /// Build a snapshot from a session's chronological event list. Returns + /// null when the input is empty (caller should write nothing rather than + /// the literal "{}"). All inputs are tolerated — missing/malformed event data is + /// recorded as "unknown" rather than causing the maintenance pass to throw. + /// + public static string? Build(IReadOnlyList? events, DateTime nowUtc) + { + if (events == null || events.Count == 0) + return null; + + // Events arrive sorted by Sequence (ascending) per ISessionRepository contract. + // Defensive copy + secondary sort by Timestamp keeps the snapshot stable even + // if the caller passes an unsorted list. + var ordered = events.OrderBy(e => e.Timestamp).ToList(); + + var seenTypes = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var evt in ordered) seenTypes.Add(evt.EventType ?? ""); + + // ---- Last ESP phase (from esp_phase_changed events) ------------------------ + string? lastEspPhase = null; + DateTime? lastEspPhaseAt = null; + foreach (var evt in ordered) + { + if (string.Equals(evt.EventType, "esp_phase_changed", StringComparison.OrdinalIgnoreCase)) + { + lastEspPhase = TryGetDataString(evt, "espPhase") ?? lastEspPhase; + lastEspPhaseAt = evt.Timestamp; + } + } + + // ---- Desktop arrival ------------------------------------------------------- + var desktopArrivedEvt = ordered.FirstOrDefault(e => + string.Equals(e.EventType, "desktop_arrived", StringComparison.OrdinalIgnoreCase)); + var desktopArrived = desktopArrivedEvt != null; + DateTime? desktopArrivedAt = desktopArrivedEvt?.Timestamp; + + // ---- Hello policy ---------------------------------------------------------- + var helloPolicyEvt = ordered.FirstOrDefault(e => + string.Equals(e.EventType, "hello_policy_detected", StringComparison.OrdinalIgnoreCase)); + bool? helloPolicyEnabled = null; + if (helloPolicyEvt != null) + { + var raw = TryGetDataString(helloPolicyEvt, "helloEnabled"); + if (bool.TryParse(raw, out var parsed)) helloPolicyEnabled = parsed; + } + + // ---- AAD join state — placeholder vs real user vs unknown ------------------ + // Sources (newest wins): + // - aad_placeholder_user_detected → placeholder + // - aad_user_joined_late → real_user (Part 1 path) + // - user_aad_signin_complete → real_user (Part 2 path; future) + // - aad_join_status → fallback heuristic via isFooUser flag + string aadJoinState = "unknown"; + DateTime? aadJoinStateAt = null; + foreach (var evt in ordered) + { + var et = evt.EventType ?? ""; + if (string.Equals(et, "aad_placeholder_user_detected", StringComparison.OrdinalIgnoreCase)) + { + aadJoinState = "placeholder"; + aadJoinStateAt = evt.Timestamp; + } + else if (string.Equals(et, "aad_user_joined_late", StringComparison.OrdinalIgnoreCase) + || string.Equals(et, "user_aad_signin_complete", StringComparison.OrdinalIgnoreCase)) + { + aadJoinState = "real_user"; + aadJoinStateAt = evt.Timestamp; + } + else if (aadJoinState == "unknown" + && string.Equals(et, "aad_join_status", StringComparison.OrdinalIgnoreCase)) + { + var isFoo = TryGetDataString(evt, "isFooUser"); + if (bool.TryParse(isFoo, out var foo)) + { + aadJoinState = foo ? "placeholder" : "real_user"; + aadJoinStateAt = evt.Timestamp; + } + } + } + + // ---- Reboot observed ------------------------------------------------------- + var rebootEvt = ordered.FirstOrDefault(e => + string.Equals(e.EventType, "system_reboot_detected", StringComparison.OrdinalIgnoreCase)); + var rebootObserved = rebootEvt != null; + + // ---- Hybrid-join hint from autopilot_profile / enrollment_type_detected ---- + bool? isHybridJoin = null; + string? enrollmentType = null; + foreach (var evt in ordered) + { + var et = evt.EventType ?? ""; + if (string.Equals(et, "autopilot_profile", StringComparison.OrdinalIgnoreCase) + || string.Equals(et, "enrollment_facts_observed", StringComparison.OrdinalIgnoreCase)) + { + var hybridRaw = TryGetDataString(evt, "isHybridJoin"); + if (bool.TryParse(hybridRaw, out var h)) isHybridJoin = h; + enrollmentType ??= TryGetDataString(evt, "enrollmentType"); + } + else if (enrollmentType == null + && string.Equals(et, "enrollment_type_detected", StringComparison.OrdinalIgnoreCase)) + { + enrollmentType = TryGetDataString(evt, "enrollmentType"); + } + } + + // ---- Last network state ---------------------------------------------------- + string? lastNetworkState = null; + for (var i = ordered.Count - 1; i >= 0; i--) + { + if (string.Equals(ordered[i].EventType, "network_state_change", StringComparison.OrdinalIgnoreCase)) + { + lastNetworkState = ordered[i].Message; + break; + } + } + + // ---- Timing facts ---------------------------------------------------------- + var lastEventAt = ordered[ordered.Count - 1].Timestamp; + var silenceMinutes = (int)Math.Max(0, (nowUtc - lastEventAt).TotalMinutes); + + // ---- Missing canonical signals -------------------------------------------- + var missing = new List(); + foreach (var sig in CanonicalCompletionSignals) + { + if (sig == "hello_provisioning_completed") + { + // Treat any Hello terminal as satisfying this slot. + if (!seenTypes.Contains("hello_provisioning_completed") + && !seenTypes.Contains("hello_provisioning_failed") + && !seenTypes.Contains("hello_provisioning_blocked") + && !seenTypes.Contains("hello_skipped")) + { + missing.Add("hello_terminal"); + } + } + else if (sig == "aad_user_joined_late") + { + // Codex review 2026-05-01: Part 1 (Classic / WhiteGlove) emits + // aad_user_joined_late; Part 2 (post-reboot user sign-in) emits + // user_aad_signin_complete. Both satisfy the same conceptual slot. + // Without this branch, every healthy Part-2 session would falsely + // list aad_user_joined_late as missing even though aadJoinState + // is correctly classified as real_user above. + if (!seenTypes.Contains("aad_user_joined_late") + && !seenTypes.Contains("user_aad_signin_complete")) + { + missing.Add("aad_user_join"); + } + } + else if (!seenTypes.Contains(sig)) + { + missing.Add(sig); + } + } + + var snapshot = new + { + schemaVersion = CurrentSchemaVersion, + generatedAtUtc = nowUtc.ToString("o"), + eventCount = ordered.Count, + lastEventAtUtc = lastEventAt.ToString("o"), + silenceMinutes, + lastEspPhase, + lastEspPhaseAtUtc = lastEspPhaseAt?.ToString("o"), + desktopArrived, + desktopArrivedAtUtc = desktopArrivedAt?.ToString("o"), + helloPolicyDetected = helloPolicyEvt != null, + helloPolicyEnabled, + aadJoinState, + aadJoinStateAtUtc = aadJoinStateAt?.ToString("o"), + rebootObserved, + isHybridJoin, + enrollmentType, + lastNetworkState, + missingSignals = missing, + }; + + return JsonConvert.SerializeObject(snapshot, Formatting.None); + } + + private static string? TryGetDataString(EnrollmentEvent evt, string key) + { + if (evt?.Data == null) return null; + if (!evt.Data.TryGetValue(key, out var raw) || raw == null) return null; + return raw.ToString(); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index 75b733bf..38f7ec4b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -274,11 +274,31 @@ namespace AutopilotMonitor.Functions.Services foreach (var session in stalledSessions) { + // Hybrid User-Driven completion-gap fix (2026-05-01): before + // graduating the session to terminal Failed, build a compact + // snapshot of "what we last knew" so operators don't have to + // scroll through hundreds of events to reconstruct where the + // session was stuck. Best-effort — a snapshot read failure + // must never block the timeout transition itself. + string? snapshotJson = null; + try + { + var sessionEvents = await _sessionRepo.GetSessionEventsAsync( + session.TenantId, session.SessionId, maxResults: 1000); + snapshotJson = FailureSnapshotBuilder.Build(sessionEvents, now); + } + catch (Exception snapEx) + { + _logger.LogWarning(snapEx, + $"Failed to build failure snapshot for session {session.SessionId}; proceeding with timeout transition without snapshot"); + } + await _sessionRepo.UpdateSessionStatusAsync( session.TenantId, session.SessionId, SessionStatus.Failed, - failureReason: $"Session timed out after {timeoutHours} hours (started at {session.StartedAt:yyyy-MM-dd HH:mm:ss} UTC)" + failureReason: $"Session timed out after {timeoutHours} hours (started at {session.StartedAt:yyyy-MM-dd HH:mm:ss} UTC)", + failureSnapshotJson: snapshotJson ); sessionCount++; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index 4ca7a755..bbd5ffb4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -249,7 +249,8 @@ namespace AutopilotMonitor.Functions.Services GeoLoc = entity.GetString("GeoLoc") ?? string.Empty, PlatformScriptCount = SafeGetInt32(entity, "PlatformScriptCount") ?? 0, RemediationScriptCount = SafeGetInt32(entity, "RemediationScriptCount") ?? 0, - ExcessiveEventsAlerted = entity.GetBoolean("ExcessiveEventsAlerted") ?? false + ExcessiveEventsAlerted = entity.GetBoolean("ExcessiveEventsAlerted") ?? false, + FailureSnapshotJson = entity.GetString("FailureSnapshotJson") ?? string.Empty }; } @@ -963,7 +964,7 @@ namespace AutopilotMonitor.Functions.Services /// back to the session's LastEventAt and only to UtcNow as a last resort, so DurationSeconds /// reflects when the session went silent rather than when the operator clicked the button. /// - public async Task UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null, DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false, string? failureSource = null, string? adminMarkedAction = null) + public async Task UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null, DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false, string? failureSource = null, string? adminMarkedAction = null, string? failureSnapshotJson = null) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); @@ -1109,6 +1110,16 @@ namespace AutopilotMonitor.Functions.Services update["FailureReason"] = failureReason; } + // Persist the failure-state snapshot when provided. Only the maintenance + // 5h-timeout path supplies this today (Hybrid User-Driven completion-gap + // fix, 2026-05-01); other terminal paths leave the field untouched, which + // is fine — null/empty means "no snapshot available" and the UI falls back + // to the existing FailureReason rendering. + if (status == SessionStatus.Failed && !string.IsNullOrEmpty(failureSnapshotJson)) + { + update["FailureSnapshotJson"] = failureSnapshotJson; + } + // Record the origin of a Failed status (agent / rule: / manual). When the caller // passes null we leave the existing value untouched — concurrent updates from different // sources shouldn't stomp on each other's attribution. @@ -1307,6 +1318,11 @@ namespace AutopilotMonitor.Functions.Services if (status == SessionStatus.Failed && !string.IsNullOrEmpty(failureReason)) forceUpdate["FailureReason"] = failureReason; + // Mirror the FailureSnapshotJson from the regular path so the + // force-merge fallback never silently drops the snapshot. + if (status == SessionStatus.Failed && !string.IsNullOrEmpty(failureSnapshotJson)) + forceUpdate["FailureSnapshotJson"] = failureSnapshotJson; + if (isPreProvisioned.HasValue) forceUpdate["IsPreProvisioned"] = isPreProvisioned.Value; @@ -1862,7 +1878,8 @@ namespace AutopilotMonitor.Functions.Services GeoLoc = entity.GetString("GeoLoc") ?? string.Empty, PlatformScriptCount = SafeGetInt32(entity, "PlatformScriptCount") ?? 0, RemediationScriptCount = SafeGetInt32(entity, "RemediationScriptCount") ?? 0, - ExcessiveEventsAlerted = entity.GetBoolean("ExcessiveEventsAlerted") ?? false + ExcessiveEventsAlerted = entity.GetBoolean("ExcessiveEventsAlerted") ?? false, + FailureSnapshotJson = entity.GetString("FailureSnapshotJson") ?? string.Empty }; } diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 560697a3..70796e89 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -231,6 +231,19 @@ namespace AutopilotMonitor.Shared // TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary public const string ShadowDiscrepancy = "shadow_discrepancy"; + + // Hybrid User-Driven enrollment observability (V2 — Hybrid completion gaps, 2026-05-01). + // Pure observability — never consumed by the DecisionEngine, no DecisionSignalKind wired up. + public const string AadPlaceholderUserDetected = "aad_placeholder_user_detected"; // foouser@/autopilot@ first appearance in JoinInfo + public const string HybridLoginPending = "hybrid_login_pending"; // Hybrid: 10 min after reboot still placeholder, real AD login overdue + + // Real-user join — companion to AadPlaceholderUserDetected. The DecisionEngine consumes + // these as DecisionSignalKind only (HandleAadUserJoinedLateV1 is observation-only and + // emits no timeline effect), so the AadJoinWatcherAdapter dual-emits them as + // informational events too. Without this the FailureSnapshotBuilder cannot tell that + // the real AAD user was ever observed (Codex review 2026-05-01). + public const string AadUserJoinedLate = "aad_user_joined_late"; // Real user replaces placeholder mid-session (Part 1 path) + public const string UserAadSignInComplete = "user_aad_signin_complete"; // Real user signs in post-reboot (Part 2 path) } // ----------------------------------------------------------------------- diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs index 294075f3..f367d81f 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs @@ -27,7 +27,8 @@ namespace AutopilotMonitor.Shared.DataAccess DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null, DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false, - string? failureSource = null, string? adminMarkedAction = null); + string? failureSource = null, string? adminMarkedAction = null, + string? failureSnapshotJson = null); Task IncrementSessionEventCountAsync( string tenantId, string sessionId, int increment, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs index 29b5946f..5161402e 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs @@ -298,6 +298,18 @@ namespace AutopilotMonitor.Shared.Models /// on long-dead sessions. /// public DateTime? PendingActionsQueuedAt { get; set; } + + /// + /// Compact JSON snapshot of "last known session state" written by the maintenance + /// 5h-timeout sweep when a session graduates to terminal Failed (Hybrid User-Driven + /// completion-gap fix, 2026-05-01). Captures the canonical lifecycle anchors — + /// last ESP phase, desktop arrival, Hello policy, AAD-join state, missing signals — + /// so operators don't have to scroll through hundreds of events to reconstruct + /// where a stuck session was when the watchdog fired. Empty / null on + /// healthy-completion paths and on sessions that predate the field. Built by + /// . + /// + public string FailureSnapshotJson { get; set; } = string.Empty; } /// diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/FailureSnapshotBlock.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/FailureSnapshotBlock.tsx new file mode 100644 index 00000000..51d2c6fe --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/FailureSnapshotBlock.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState } from "react"; + +/** + * Renders the maintenance-built failure snapshot when present (Hybrid User-Driven + * completion-gap fix, 2026-05-01). The backend writes the JSON only on the 5h-timeout + * graduation path, so this block is hidden for healthy completions and for sessions + * that predate the field. The shape is best-effort — unknown fields are tolerated; + * legacy snapshots without `schemaVersion` simply render the fields they do carry. + */ +interface FailureSnapshot { + schemaVersion?: number; + generatedAtUtc?: string; + eventCount?: number; + lastEventAtUtc?: string; + silenceMinutes?: number; + lastEspPhase?: string | null; + lastEspPhaseAtUtc?: string | null; + desktopArrived?: boolean; + desktopArrivedAtUtc?: string | null; + helloPolicyDetected?: boolean; + helloPolicyEnabled?: boolean | null; + aadJoinState?: string; + aadJoinStateAtUtc?: string | null; + rebootObserved?: boolean; + isHybridJoin?: boolean | null; + enrollmentType?: string | null; + lastNetworkState?: string | null; + missingSignals?: string[]; +} + +export default function FailureSnapshotBlock({ failureSnapshotJson }: { failureSnapshotJson?: string }) { + const [expanded, setExpanded] = useState(false); + + if (!failureSnapshotJson || failureSnapshotJson.trim().length === 0) return null; + + let snapshot: FailureSnapshot | null = null; + try { + snapshot = JSON.parse(failureSnapshotJson) as FailureSnapshot; + } catch { + // Invalid JSON — render the raw string in a debug fallback so operators can still + // copy/paste it into a JSON formatter offline. No exception bubbles up to break + // the surrounding card. + return ( +
+ Failure Snapshot (raw): +
{failureSnapshotJson}
+
+ ); + } + + const missing = snapshot.missingSignals ?? []; + + return ( +
+ + {expanded && ( +
+
+ + + + + + + + + {snapshot.lastNetworkState && ( + + )} +
+ {missing.length > 0 && ( +
+
Missing canonical signals at timeout:
+
+ {missing.map((sig) => ( + + {sig} + + ))} +
+
+ )} + {snapshot.generatedAtUtc && ( +
+ Snapshot generated {new Date(snapshot.generatedAtUtc).toLocaleString()} + {snapshot.schemaVersion !== undefined && <> · schema v{snapshot.schemaVersion}} +
+ )} +
+ )} +
+ ); +} + +function SnapshotItem({ label, value, sub, className }: { label: string; value: React.ReactNode; sub?: string; className?: string }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function fmtBool(v: boolean | undefined | null) { + if (v === true) return "yes"; + if (v === false) return "no"; + return "unknown"; +} + +function fmtHelloPolicy(s: FailureSnapshot): string { + if (!s.helloPolicyDetected) return "not detected"; + if (s.helloPolicyEnabled === true) return "detected (enabled)"; + if (s.helloPolicyEnabled === false) return "detected (disabled)"; + return "detected"; +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx index 9b58076e..23a22ecd 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Session } from "@/types"; +import FailureSnapshotBlock from "./FailureSnapshotBlock"; interface SessionInfoCardProps { session: Session; @@ -81,6 +82,7 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt )} + ); } diff --git a/src/Web/autopilot-monitor-web/types/session.ts b/src/Web/autopilot-monitor-web/types/session.ts index 71cd7945..c8aaf4fa 100644 --- a/src/Web/autopilot-monitor-web/types/session.ts +++ b/src/Web/autopilot-monitor-web/types/session.ts @@ -33,4 +33,12 @@ export interface Session { geoCountry?: string; geoRegion?: string; geoCity?: string; + /** + * Compact JSON snapshot of "last known session state" written by the maintenance + * 5h-timeout sweep when a session graduates to terminal Failed (Hybrid User-Driven + * completion-gap fix, 2026-05-01). Empty for healthy completions and sessions that + * predate the field. The detail page renders a collapsible "Failure Snapshot" block + * when populated. + */ + failureSnapshotJson?: string; }