From 96fc88462d40fd2f4aa8c43ef120438182655275 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 12 Oct 2026 22:22:44 +0200 Subject: [PATCH] Push: the session watch fires at the WhiteGlove seal "Notify me when done" now also fires on the first genuine pre-provisioning seal ("Pre-provisioning finished") on the gate of the Pre-Provisioning Completed alert, and the watch is consumed there; watching the user phase takes a new click. A watch placed on a sealed session lives 30 days instead of 48 hours. EventIngestProcessor.WatchOutcomeFor is the one gate, the watch text comes from PushSessionWatchService.WatchMessage. The portal button re-reads the watch on every status change and names what it waits for. The Success hint says it covers a finished pre-provisioning; the receiver labels the preprovisioning_* alerts. Co-Authored-By: Claude Opus 5.5 --- .../Push/PushSessionWatchServiceTests.cs | 90 ++++++++++++++----- .../Push/SessionWatchOutcomeGateTests.cs | 71 +++++++++++++++ .../Functions/Push/SessionWatchFunctions.cs | 5 +- .../EventIngestProcessor.Notifications.cs | 29 +++++- .../Services/Push/PushSessionWatchService.cs | 60 +++++++++---- .../components/SessionWatchButton.tsx | 18 ++-- .../notifications/ChannelEditor.tsx | 2 +- .../lib/__tests__/historyFilter.test.ts | 2 + .../lib/push/historyFilter.ts | 3 + 9 files changed, 226 insertions(+), 54 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/Push/SessionWatchOutcomeGateTests.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs index 9ce44f75..1a75baa3 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs @@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Services.Push; using AutopilotMonitor.Push; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -15,8 +16,8 @@ namespace AutopilotMonitor.Functions.Tests.Push; /// Session watches ("notify my devices when this session ends"): the watch row lives in the /// SESSION's tenant, the devices it fires on are the watcher's OWN scopes (home tenant, plus the /// platform partition for a Global Admin), gated by an enabled Push channel and the role check at -/// watch time and again at notify time. 48-hour lifetime, one-hour push TTL, consumed at the -/// terminal status, fail-soft towards the ingest path. +/// watch time and again at notify time. 48-hour lifetime (30 days on a sealed session), one-hour +/// push TTL, consumed at the terminal status or the WhiteGlove seal, fail-soft towards the ingest path. /// public class PushSessionWatchServiceTests { @@ -40,7 +41,7 @@ public class PushSessionWatchServiceTests h.AddDevice("d1", Admin, HomeTenantId); h.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny())).ReturnsAsync(false); - var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin); + var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin); Assert.Equal(PushOpError.ChannelRequired, result.Error); Assert.Empty(h.Repo.Watches); @@ -53,7 +54,7 @@ public class PushSessionWatchServiceTests h.AddDevice("d1", Admin, HomeTenantId); h.Eligibility.Setup(e => e.IsEligibleAsync(Home, Admin.Upn, Admin.ObjectId, Admin.HomeTenantId)).ReturnsAsync(false); - var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin); + var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin); Assert.Equal(PushOpError.NotEligible, result.Error); Assert.Empty(h.Repo.Watches); @@ -68,7 +69,7 @@ public class PushSessionWatchServiceTests var h = new Harness(); h.AddDevice("d1", Admin, HomeTenantId, status); - var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin); + var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin); Assert.Equal(PushOpError.Conflict, result.Error); Assert.Empty(h.Repo.Watches); @@ -80,7 +81,7 @@ public class PushSessionWatchServiceTests var h = new Harness(); h.AddDevice("d1", Operator, HomeTenantId); - var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin); + var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin); Assert.Equal(PushOpError.Conflict, result.Error); Assert.Empty(h.Repo.Watches); @@ -93,7 +94,7 @@ public class PushSessionWatchServiceTests h.AddDevice("d1", Admin, HomeTenantId); var before = DateTime.UtcNow; - var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin); + var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin); var after = DateTime.UtcNow; Assert.True(result.Ok); @@ -107,13 +108,29 @@ public class PushSessionWatchServiceTests Assert.Equal(row.ExpiresUtc, result.Value.ExpiresUtc!.Value); } + [Fact] + public async Task Watch_on_a_sealed_session_lives_thirty_days_because_the_user_phase_starts_whenever_the_device_is_handed_out() + { + var h = new Harness(); + h.AddDevice("d1", Admin, HomeTenantId); + + var before = DateTime.UtcNow; + var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.Pending, Admin); + var after = DateTime.UtcNow; + + Assert.True(result.Ok); + var row = h.Repo.Watches[(HomeTenantId, SessionId, Admin.ObjectId)]; + Assert.InRange(row.ExpiresUtc, before.AddDays(30), after.AddDays(30)); + Assert.Equal(row.ExpiresUtc, result.Value!.ExpiresUtc!.Value); + } + [Fact] public async Task Global_admin_with_only_a_platform_device_watches_a_customer_session_from_the_platform_scope() { var h = new Harness(); h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope); - var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin); + var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin); Assert.True(result.Ok); // The row lives in the SESSION's tenant partition, not in the platform or the home partition. @@ -130,7 +147,7 @@ public class PushSessionWatchServiceTests h.AddDevice("h1", GlobalAdmin, HomeTenantId); h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope); - var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin); + var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin); Assert.True(result.Ok); Assert.Equal($"{Constants.Push.PlatformScope};{HomeTenantId}", h.Repo.Watches[(CustomerTenantId, SessionId, GlobalAdmin.ObjectId)].OwnerScopes); @@ -144,7 +161,7 @@ public class PushSessionWatchServiceTests h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope); h.Eligibility.Setup(e => e.HasPushChannelAsync(Home)).ReturnsAsync(false); - var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin); + var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin); Assert.True(result.Ok); Assert.Equal(Constants.Push.PlatformScope, h.Repo.Watches[(CustomerTenantId, SessionId, GlobalAdmin.ObjectId)].OwnerScopes); @@ -263,7 +280,7 @@ public class PushSessionWatchServiceTests h.AddDevice("c2", CustomerAdmin, CustomerTenantId); h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope); - await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890"); + await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890"); Assert.Equal(Endpoints(p1, p2), SentTo(h)); Assert.Empty(h.Repo.Watches); @@ -283,12 +300,40 @@ public class PushSessionWatchServiceTests h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId); h.AddWatch(HomeTenantId, SessionId, Operator, HomeTenantId); - await h.Sut.NotifyAsync(HomeTenantId, SessionId, success: false, "DESKTOP-4711", null); + await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Failed,"DESKTOP-4711", null); Assert.Equal(Endpoints(d1, d2), SentTo(h)); Assert.Empty(h.Repo.Watches); } + [Fact] + public async Task Notify_at_the_seal_pushes_and_consumes_the_watch_so_the_user_phase_needs_a_new_one() + { + var h = new Harness(); + var device = h.AddDevice("d1", Admin, HomeTenantId); + h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId); + + await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Sealed, "DESKTOP-4711", "1234567890"); + + Assert.Equal(device.Endpoint, Assert.Single(h.Http.Requests).RequestUri!.ToString()); + Assert.Empty(h.Repo.Watches); + } + + [Theory] + [InlineData(SessionWatchOutcome.Succeeded, "Enrollment finished", "DESKTOP-4711 (…7890) finished enrollment.", "success")] + [InlineData(SessionWatchOutcome.Failed, "Enrollment failed", "DESKTOP-4711 (…7890) failed enrollment.", "error")] + [InlineData(SessionWatchOutcome.Sealed, "Pre-provisioning finished", "DESKTOP-4711 (…7890) finished pre-provisioning.", "success")] + public void Watch_message_names_the_outcome_with_the_device_and_the_masked_serial(SessionWatchOutcome outcome, string title, string body, string severity) + { + Assert.Equal((title, body, severity), PushSessionWatchService.WatchMessage(outcome, "DESKTOP-4711", "1234567890")); + } + + [Fact] + public void Watch_message_without_a_device_name_or_serial_says_the_device() + { + Assert.Equal("The device finished pre-provisioning.", PushSessionWatchService.WatchMessage(SessionWatchOutcome.Sealed, null, null).Body); + } + [Fact] public async Task Notify_with_empty_owner_scopes_falls_back_to_the_session_tenant() { @@ -296,7 +341,7 @@ public class PushSessionWatchServiceTests var device = h.AddDevice("d1", Admin, HomeTenantId); h.AddWatch(HomeTenantId, SessionId, Admin, ownerScopes: string.Empty); // legacy row - await h.Sut.NotifyAsync(HomeTenantId, SessionId, success: true, "DESKTOP-4711", null); + await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", null); var request = Assert.Single(h.Http.Requests); Assert.Equal(device.Endpoint, request.RequestUri!.ToString()); @@ -310,7 +355,7 @@ public class PushSessionWatchServiceTests h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope); h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope, expiresUtc: DateTime.UtcNow.AddMinutes(-1)); - await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: false, "DESKTOP-4711", "1234567890"); + await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Failed,"DESKTOP-4711", "1234567890"); Assert.Empty(h.Http.Requests); Assert.Empty(h.Repo.Watches); @@ -323,7 +368,7 @@ public class PushSessionWatchServiceTests repo.Setup(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId)).ReturnsAsync(new List()); var h = new Harness(repo.Object); - await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", null); + await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", null); repo.Verify(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId), Times.Once); repo.VerifyNoOtherCalls(); @@ -331,15 +376,16 @@ public class PushSessionWatchServiceTests } [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task Notify_push_carries_the_one_hour_ttl_and_high_urgency(bool success) + [InlineData(SessionWatchOutcome.Succeeded)] + [InlineData(SessionWatchOutcome.Failed)] + [InlineData(SessionWatchOutcome.Sealed)] + public async Task Notify_push_carries_the_one_hour_ttl_and_high_urgency(SessionWatchOutcome outcome) { var h = new Harness(); h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope); h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope); - await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success, "DESKTOP-4711", "1234567890"); + await h.Sut.NotifyAsync(CustomerTenantId, SessionId, outcome, "DESKTOP-4711", "1234567890"); var request = Assert.Single(h.Http.Requests); Assert.Equal("3600", Header(request, "TTL")); // WatchPushTtl: "Watch bis Session-Ende + 1 h" @@ -354,7 +400,7 @@ public class PushSessionWatchServiceTests h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope); h.Eligibility.Setup(e => e.IsEligibleAsync(NotificationScope.Platform, GlobalAdmin.Upn, GlobalAdmin.ObjectId, GlobalAdmin.HomeTenantId)).ReturnsAsync(false); - await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890"); + await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890"); Assert.Empty(h.Http.Requests); Assert.Equal(Constants.Push.DeviceStatus.Active, h.Repo.Devices[(Constants.Push.PlatformScope, "p1")].Status); @@ -369,7 +415,7 @@ public class PushSessionWatchServiceTests h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope); h.Eligibility.Setup(e => e.IsOwnerFreshAsync(GlobalAdmin.HomeTenantId, GlobalAdmin.ObjectId, It.IsAny())).ReturnsAsync(false); - await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890"); + await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890"); var row = h.Repo.Devices[(Constants.Push.PlatformScope, "p1")]; Assert.Equal(Constants.Push.DeviceStatus.Paused, row.Status); @@ -387,7 +433,7 @@ public class PushSessionWatchServiceTests repo.Setup(r => r.GetWatchesForSessionAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("storage unavailable")); var h = new Harness(repo.Object); - var exception = await Record.ExceptionAsync(() => h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890")); + var exception = await Record.ExceptionAsync(() => h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890")); Assert.Null(exception); Assert.Empty(h.Http.Requests); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/SessionWatchOutcomeGateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/SessionWatchOutcomeGateTests.cs new file mode 100644 index 00000000..bd27e180 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/SessionWatchOutcomeGateTests.cs @@ -0,0 +1,71 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Push; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests.Push; + +/// +/// The ingest gate that decides whether a batch fires the session watches: the terminal status on +/// the enrollment alert's gate, else the first genuine WhiteGlove seal on the pre-provisioning +/// alert's gate. A duplicate seal or a status that did not change fires nothing. +/// +public class SessionWatchOutcomeGateTests +{ + private static EnrollmentEvent Event(string type) => new() { EventType = type }; + + [Fact] + public void A_completion_that_changed_the_status_fires_succeeded() + { + var c = new EventClassification { CompletionEvent = Event("enrollment_complete") }; + + Assert.Equal(SessionWatchOutcome.Succeeded, EventIngestProcessor.WatchOutcomeFor(true, false, c, null)); + } + + [Fact] + public void A_failure_with_a_failure_verdict_fires_failed() + { + var c = new EventClassification { FailureEvent = Event("enrollment_failed") }; + + Assert.Equal(SessionWatchOutcome.Failed, EventIngestProcessor.WatchOutcomeFor(true, false, c, "ESP timeout (0x800705b4)")); + } + + [Fact] + public void An_agent_timeout_that_classified_without_a_failure_reason_fires_nothing() + { + var c = new EventClassification { FailureEvent = Event("enrollment_failed") }; + + Assert.Null(EventIngestProcessor.WatchOutcomeFor(true, false, c, null)); + } + + [Fact] + public void A_terminal_event_without_a_status_change_fires_nothing() + { + var c = new EventClassification { CompletionEvent = Event("enrollment_complete") }; + + Assert.Null(EventIngestProcessor.WatchOutcomeFor(false, false, c, null)); + } + + [Fact] + public void The_first_genuine_seal_fires_sealed() + { + var c = new EventClassification { WhiteGloveEvent = Event("whiteglove_complete") }; + + Assert.Equal(SessionWatchOutcome.Sealed, EventIngestProcessor.WatchOutcomeFor(false, true, c, null)); + } + + [Fact] + public void The_duplicate_whiteglove_complete_fires_nothing() + { + // The agent emits whiteglove_complete twice; the second batch finds the session already + // sealed, so whiteGloveStatusTransitioned stays false and the watch is not consumed again. + var c = new EventClassification { WhiteGloveEvent = Event("whiteglove_complete") }; + + Assert.Null(EventIngestProcessor.WatchOutcomeFor(false, false, c, null)); + } + + [Fact] + public void A_batch_without_terminal_or_seal_events_fires_nothing() + { + Assert.Null(EventIngestProcessor.WatchOutcomeFor(true, true, new EventClassification(), null)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Push/SessionWatchFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Push/SessionWatchFunctions.cs index bb37f841..0c229713 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Push/SessionWatchFunctions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Push/SessionWatchFunctions.cs @@ -51,10 +51,11 @@ public class SessionWatchFunctions if (!PushFunctionSupport.IsPlausibleId(sessionId)) return await req.NotFoundAsync("Session not found."); var (tenantId, caller) = await ScopeAsync(req, sessionId); - if (await _sessionRepo.GetSessionAsync(tenantId, sessionId) == null) + var session = await _sessionRepo.GetSessionAsync(tenantId, sessionId); + if (session == null) return await req.NotFoundAsync("Session not found."); - var result = await _watches.WatchAsync(tenantId, sessionId, caller); + var result = await _watches.WatchAsync(tenantId, sessionId, session.Status, caller); return result.Ok ? await req.OkAsync(result.Value!) : await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs index ca9cf8dd..8c2f383e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs @@ -2,6 +2,7 @@ using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using System.Linq; using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Functions.Services.Push; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Notifications; using Microsoft.Azure.Functions.Worker; @@ -27,11 +28,13 @@ namespace AutopilotMonitor.Functions.Services var failureVerdictApplies = c.FailureEvent != null && failureReason != null; var reachedTerminalStatus = statusTransitioned && (c.CompletionEvent != null || failureVerdictApplies); - // Session watches ("notify me when this session ends") fire on the same terminal gate - // as the enrollment alert, independent of any channel configuration. Fail-soft inside. - if (reachedTerminalStatus && _pushWatchNotifier != null) + // Session watches ("notify me when this session ends") fire on the same gates as the + // enrollment and pre-provisioning alerts, independent of any channel configuration. + // Fail-soft inside. + var watchOutcome = WatchOutcomeFor(statusTransitioned, whiteGloveStatusTransitioned, c, failureReason); + if (watchOutcome != null && _pushWatchNotifier != null) { - await _pushWatchNotifier.NotifyAsync(request.TenantId, request.SessionId, c.CompletionEvent != null, + await _pushWatchNotifier.NotifyAsync(request.TenantId, request.SessionId, watchOutcome.Value, updatedSession?.DeviceName, updatedSession?.SerialNumber).ConfigureAwait(false); } @@ -126,6 +129,24 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// What a session watch fires on for this batch, or null: the terminal status on the + /// enrollment alert's gate, else the first genuine WhiteGlove seal on the pre-provisioning + /// alert's gate (never the duplicate whiteglove_complete, which leaves + /// false). The seal consumes the watch. + /// + internal static SessionWatchOutcome? WatchOutcomeFor( + bool statusTransitioned, bool whiteGloveStatusTransitioned, EventClassification c, string? failureReason) + { + if (statusTransitioned && c.CompletionEvent != null) + return SessionWatchOutcome.Succeeded; + if (statusTransitioned && c.FailureEvent != null && failureReason != null) + return SessionWatchOutcome.Failed; + if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null) + return SessionWatchOutcome.Sealed; + return null; + } + /// /// Hands an alert to the notification-dispatch queue. The send used to be fire-and-forget /// after the HTTP response; the host does not count such work at shutdown, so a Flex diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs index f2fc516a..23d07ea7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs @@ -6,24 +6,38 @@ using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services.Push; -/// Terminal-status hook for the ingest path: fires the pushes of everyone watching the session. +/// What a watch fires on: the session's terminal status, or the WhiteGlove seal as an intermediate goal. +public enum SessionWatchOutcome +{ + Succeeded, + Failed, + /// Pre-provisioning (WhiteGlove part 1) sealed the device; the user phase follows later. + Sealed, +} + +/// Ingest-path hook: fires the pushes of everyone watching the session. public interface IPushSessionWatchNotifier { - Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber); + Task NotifyAsync(string tenantId, string sessionId, SessionWatchOutcome outcome, string? deviceName, string? serialNumber); } /// /// "Notify my devices when this session ends" (plan use case 3): one watch per (session, person), -/// consumed at the terminal status. The watch row lives in the SESSION's tenant (tenant isolation -/// of the session), the devices it fires on are the watcher's OWN: their home-tenant scope and, -/// for a Global Admin, the platform scope — so an operator can watch a customer's session from -/// their own paired phone. Admin and Operator only (F14), only in a scope with an enabled Push -/// channel (K4), and the fan-out runs through the same role and freshness filter as any alert. -/// Watches expire after 48 hours (the agent's emergency break) and die with the session's tenant. +/// consumed at the terminal status — or at the WhiteGlove seal, the moment the technician at the +/// bench is waiting for; watching the user phase takes a second click. The watch row lives in the +/// SESSION's tenant (tenant isolation of the session), the devices it fires on are the watcher's +/// OWN: their home-tenant scope and, for a Global Admin, the platform scope — so an operator can +/// watch a customer's session from their own paired phone. Admin and Operator only (F14), only in +/// a scope with an enabled Push channel (K4), and the fan-out runs through the same role and +/// freshness filter as any alert. Watches expire after 48 hours (the agent's emergency break), a +/// watch on a sealed session after 30 days (the user phase starts whenever the device is handed +/// out), and die with the session's tenant. /// public class PushSessionWatchService : IPushSessionWatchNotifier { public static readonly TimeSpan WatchLifetime = TimeSpan.FromHours(48); + /// A sealed (Pending) session resumes only when the device reaches its user; matches the owner inactivity window. + public static readonly TimeSpan WatchLifetimeSealed = TimeSpan.FromDays(30); /// The terminal push waits for an offline phone longer than a system message: plan "Watch bis Session-Ende + 1 h". public static readonly TimeSpan WatchPushTtl = TimeSpan.FromHours(1); private const char ScopeSeparator = ';'; @@ -56,7 +70,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier return scopes; } - public async Task> WatchAsync(string tenantId, string sessionId, PushCaller caller) + public async Task> WatchAsync(string tenantId, string sessionId, SessionStatus sessionStatus, PushCaller caller) { var anyChannel = false; var anyEligible = false; @@ -95,7 +109,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier OwnerUpn = caller.Upn, OwnerScopes = string.Join(ScopeSeparator, usable), CreatedUtc = now, - ExpiresUtc = now + WatchLifetime, + ExpiresUtc = now + (sessionStatus == SessionStatus.Pending ? WatchLifetimeSealed : WatchLifetime), }; await _repo.UpsertWatchAsync(watch).ConfigureAwait(false); return PushOpResult.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc, Available = true }); @@ -137,7 +151,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier } /// Fail-soft: a watch push must never cost the ingest path anything. - public async Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber) + public async Task NotifyAsync(string tenantId, string sessionId, SessionWatchOutcome outcome, string? deviceName, string? serialNumber) { try { @@ -146,10 +160,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier return; var now = DateTime.UtcNow; - var device = PushAlertProjector.Clean(deviceName, 48); - var serial = PushAlertProjector.MaskSerial(serialNumber); - var body = (device.Length > 0 ? device : "The device") + (serial.Length > 0 ? $" ({serial})" : string.Empty) - + (success ? " finished enrollment." : " failed enrollment."); + var (title, body, severity) = WatchMessage(outcome, deviceName, serialNumber); foreach (var watch in watches) { @@ -161,9 +172,8 @@ public class PushSessionWatchService : IPushSessionWatchNotifier foreach (var scope in ScopesOf(watch, tenantId)) { - var payload = PushAlertProjector.SystemMessage("session_watch", - success ? "Enrollment finished" : "Enrollment failed", - body, success ? "success" : "error", scope, now, Constants.PortalSessionUrl(sessionId)); + var payload = PushAlertProjector.SystemMessage("session_watch", title, body, severity, scope, now, + Constants.PortalSessionUrl(sessionId)); await _delivery.SendSystemToOwnerAsync(scope, watch.OwnerObjectId, payload, WatchPushTtl).ConfigureAwait(false); } @@ -176,6 +186,20 @@ public class PushSessionWatchService : IPushSessionWatchNotifier } } + /// The watch push's lock-screen text: device name and masked serial only (D-328). + internal static (string Title, string Body, string Severity) WatchMessage(SessionWatchOutcome outcome, string? deviceName, string? serialNumber) + { + var device = PushAlertProjector.Clean(deviceName, 48); + var serial = PushAlertProjector.MaskSerial(serialNumber); + var subject = (device.Length > 0 ? device : "The device") + (serial.Length > 0 ? $" ({serial})" : string.Empty); + return outcome switch + { + SessionWatchOutcome.Succeeded => ("Enrollment finished", subject + " finished enrollment.", "success"), + SessionWatchOutcome.Sealed => ("Pre-provisioning finished", subject + " finished pre-provisioning.", "success"), + _ => ("Enrollment failed", subject + " failed enrollment.", "error"), + }; + } + private static IEnumerable ScopesOf(PushSessionWatch watch, string sessionTenantId) { var keys = (watch.OwnerScopes ?? string.Empty).Split(ScopeSeparator, StringSplitOptions.RemoveEmptyEntries); diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionWatchButton.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionWatchButton.tsx index 2e339a0d..3f16ba34 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/SessionWatchButton.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionWatchButton.tsx @@ -21,10 +21,12 @@ interface SessionWatchButtonProps { /** * "Notify me when done" (plan push-relay): registers a watch that pushes to the caller's own - * paired devices when this session reaches a terminal status. Hidden once the session is - * terminal; the host gates it to Admins/Operators (and Global Admins) like Report Session. - * The GET says whether watching is possible for this person at all (a Push channel in one of - * their own scopes); without that the button stays hidden instead of letting a click fail. + * paired devices when this session reaches a terminal status — or when pre-provisioning seals + * the device, which consumes the watch; on the sealed (Pending) session a new click watches the + * user phase. Hidden once the session is terminal; the host gates it to Admins/Operators (and + * Global Admins) like Report Session. The GET says whether watching is possible for this person + * at all (a Push channel in one of their own scopes); without that the button stays hidden + * instead of letting a click fail. Every status change re-reads the watch, so the seal shows here. */ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessionStatus, getAccessToken, addNotification }: SessionWatchButtonProps) { const [watching, setWatching] = useState(null); @@ -32,6 +34,8 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi const [busy, setBusy] = useState(false); const active = sessionId.length > 0 && !!sessionStatus && !TERMINAL_STATUSES.has(sessionStatus); + const sealed = sessionStatus === "Pending"; + const until = sealed ? "the user phase ends" : "this session ends or pre-provisioning finishes"; useEffect(() => { if (!active) return; @@ -51,7 +55,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi return () => { cancelled = true; }; - }, [active, sessionId, effectiveTenantId, getAccessToken]); + }, [active, sessionStatus, sessionId, effectiveTenantId, getAccessToken]); if (!active || !available) return null; @@ -63,7 +67,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi const result = await setSessionWatch(sessionId, effectiveTenantId, next, getAccessToken); setWatching(result.watching); if (result.watching) { - addNotification("success", TOAST_TITLE, "Your paired devices will be notified when this session ends.", TOAST_KEY); + addNotification("success", TOAST_TITLE, `Your paired devices will be notified when ${until}.`, TOAST_KEY); } else { addNotification("info", TOAST_TITLE, "You will not be notified for this session.", TOAST_KEY); } @@ -84,7 +88,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi onClick={() => void toggle()} disabled={busy || watching === null} aria-pressed={watching === true} - title={watching ? "Stop notifying my devices when this session ends." : "Push to my paired devices when this session ends."} + title={watching ? `Stop notifying my devices when ${until}.` : `Push to my paired devices when ${until}.`} className={`px-4 py-2 rounded-md transition-colors flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed ${ watching ? "bg-sky-100 border border-sky-300 text-sky-800 hover:bg-sky-200" diff --git a/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx b/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx index f40382f2..12d44107 100644 --- a/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx +++ b/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx @@ -55,7 +55,7 @@ export const PROVIDERS: { const EVENT_TOGGLES: { key: keyof NotificationChannel; label: string; hint: string }[] = [ { key: "notifyOnStart", label: "Start", hint: "Enrollment started (one signal per fresh session and per Pre-Provisioning resume)" }, - { key: "notifyOnSuccess", label: "Success", hint: "Enrollment completed successfully" }, + { key: "notifyOnSuccess", label: "Success", hint: "Enrollment completed successfully, or pre-provisioning finished" }, { key: "notifyOnFailure", label: "Failure", hint: "Enrollment failed" }, { key: "notifyOnHardwareRejection", label: "Hardware rejection", hint: "Device rejected by the hardware whitelist" }, { key: "notifyOnSlaEvents", label: "SLA", hint: "SLA breach / resolved / consecutive failures (which breaches are evaluated is configured under SLA Targets)" }, diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/historyFilter.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/historyFilter.test.ts index d9831287..623f2028 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/historyFilter.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/historyFilter.test.ts @@ -44,6 +44,8 @@ describe("classifyEntry", () => { expect(classifyEntry(entry("b", "X", "platform", [{ name: "Category", value: "Consent" }]))).toEqual({ group: "platform", kind: "consent", label: "Consent" }); expect(classifyEntry(entry("c", "test", "platform"))).toEqual({ group: "platform", kind: "other", label: "Other" }); expect(classifyEntry(entry("d", "enrollment_succeeded"))).toEqual({ group: "enrollment", kind: "enrollment_succeeded", label: "Enrollment succeeded" }); + // The WhiteGlove seal travels on the Success toggle as its own alert kind. + expect(classifyEntry(entry("f", "preprovisioning_completed"))).toEqual({ group: "enrollment", kind: "preprovisioning_completed", label: "Pre-provisioning completed" }); expect(classifyEntry(entry("e", "some_new_kind"))).toEqual({ group: "enrollment", kind: "some_new_kind", label: "Some new kind" }); }); }); diff --git a/src/Web/autopilot-monitor-web/lib/push/historyFilter.ts b/src/Web/autopilot-monitor-web/lib/push/historyFilter.ts index d41e67d1..cdd2b816 100644 --- a/src/Web/autopilot-monitor-web/lib/push/historyFilter.ts +++ b/src/Web/autopilot-monitor-web/lib/push/historyFilter.ts @@ -40,6 +40,9 @@ const ENROLLMENT_LABELS: Record = { enrollment_failed: "Enrollment failed", enrollment_complete: "Enrollment complete", enrollment_end: "Enrollment ended", + preprovisioning_completed: "Pre-provisioning completed", + preprovisioning_resumed: "Pre-provisioning resumed", + preprovisioning_failed: "Pre-provisioning failed", session_stalled: "Session stalled", session_watch: "Session watch", consecutive_failures: "Consecutive failures",