diff --git a/internal b/internal index 398d32a4..6c79026c 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 398d32a48bd2f0fabca8746ab17272357a1d7241 +Subproject commit 6c79026c33bb9d7cd305117a1c9654d5c9e98150 diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs index 14822ce3..84619da5 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs @@ -266,12 +266,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration } [Fact] - public void Deadline_fired_late_is_stamped_with_the_firing_clock_and_carries_its_due_time() + public void Deadline_fired_late_keeps_the_due_time_as_stamp_and_carries_it_in_the_payload() { // A deadline that was due while the device slept (or the agent was down) fires only - // afterwards. The signal must carry the firing clock as OccurredAtUtc — that time - // stamps every effect of the step, and a due time inside the outage would place the - // verdict there — while the due time travels in the payload for the stale-fire guards. + // afterwards. The signal keeps the due time as OccurredAtUtc (D-238: the outage must not + // become session duration) and repeats it in the payload so the stale-fire guards + // identify the incarnation independently of the stamp. using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); sut.Start(); @@ -293,7 +293,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration if (sig.Kind == DecisionSignalKind.DeadlineFired) { fired = sig; break; } } Assert.NotNull(fired); - Assert.Equal(At, fired!.OccurredAtUtc); + Assert.Equal(dueAt, fired!.OccurredAtUtc); Assert.NotNull(fired.Payload); Assert.Equal("hello_safety", fired.Payload![SignalPayloadKeys.Deadline]); Assert.Equal(dueAt.ToString("O"), fired.Payload[SignalPayloadKeys.DeadlineDueAtUtc]); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DeadlineFiredEventArgs.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DeadlineFiredEventArgs.cs index 0033a8ce..3df4a9c1 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DeadlineFiredEventArgs.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DeadlineFiredEventArgs.cs @@ -9,10 +9,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// /// The orchestrator consumes this event and translates it into a synthetic /// DeadlineFired- - /// whose OccurredAtUtc is — the moment the agent actually - /// decided — and whose payload carries the deadline's . - /// A timer that was due during Modern Standby or a reboot fires only afterwards; stamping - /// the due time instead would date the verdict into the outage. + /// whose OccurredAtUtc equals the deadline's + /// (the policy instant the wait expired), with the due time repeated in the payload. The + /// firing clock is deliberately NOT the stamp: a timer due during Modern Standby or a + /// reboot fires only afterwards, and the outage would otherwise become session duration + /// (D-238). /// /// public sealed class DeadlineFiredEventArgs : EventArgs @@ -25,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration public ActiveDeadline Deadline { get; } - /// Wall-clock time the timer fired; becomes the signal's OccurredAtUtc. + /// Wall-clock time the event was raised. For observability; does NOT influence the signal's OccurredAtUtc. public DateTime FiredAtUtc { get; } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs index b2d4a618..a63d0185 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs @@ -1181,15 +1181,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration payload[SignalPayloadKeys.Deadline] = e.Deadline.Name; } - // OccurredAtUtc = the firing clock, never DueAtUtc: every effect of the resulting - // step (phase_transition, enrollment_complete) is stamped with the signal time, and a - // due time that passed during Modern Standby or a reboot would place the verdict - // inside the outage. The due time travels in the payload for the stale-fire guards. + // OccurredAtUtc = DueAtUtc, not the firing clock (D-238). Every effect of the + // resulting step (phase_transition, enrollment_complete) is stamped with the signal + // time; a timer that came due during Modern Standby or a reboot fires only afterwards, + // and the firing clock would make the verdict — and the session duration — grow by + // the whole outage (measured: days for devices closed right after provisioning). + // The due time also travels in the payload so the stale-fire guards identify the + // deadline incarnation independently of the stamp. payload[SignalPayloadKeys.DeadlineDueAtUtc] = e.Deadline.DueAtUtc.ToString("O", CultureInfo.InvariantCulture); _ingress.Post( kind: DecisionSignalKind.DeadlineFired, - occurredAtUtc: e.FiredAtUtc, + occurredAtUtc: e.Deadline.DueAtUtc, sourceOrigin: "DeadlineScheduler", evidence: evidence, payload: payload); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IDeadlineScheduler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IDeadlineScheduler.cs index 4d7d558e..394d081a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IDeadlineScheduler.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IDeadlineScheduler.cs @@ -39,7 +39,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// /// Wird vom Timer-Thread geraised, wenn ein Deadline fällig wird. Subscriber /// ist typischerweise der Orchestrator, der daraus ein synthetisches - /// DeadlineFired-Signal mit OccurredAtUtc = deadline.DueAtUtc erzeugt. + /// DeadlineFired-Signal mit OccurredAtUtc = deadline.DueAtUtc erzeugt; die + /// Fälligkeit reist zusätzlich im Payload (SignalPayloadKeys.DeadlineDueAtUtc). /// event EventHandler? Fired; diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DeadlineFireTimeStampTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DeadlineFireTimeStampTests.cs index 2c3729e5..847e1a00 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DeadlineFireTimeStampTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DeadlineFireTimeStampTests.cs @@ -10,14 +10,12 @@ using Xunit; namespace AutopilotMonitor.DecisionCore.Tests { /// - /// A DeadlineFired signal is stamped with the wall-clock firing time, not the due - /// time: a deadline due during Modern Standby or a reboot fires only afterwards, and every - /// effect of the resulting step (phase_transition, enrollment_complete) inherits the signal - /// time — a due-time stamp would date the verdict into the outage (session ac5660b8, - /// "Completed" drawn inside the Asleep block). The due time travels in the payload under - /// for the stale-fire guards that identify - /// the deadline incarnation; without the key they fall back to the signal time (pre-key - /// signal logs, where the two were equal by contract). + /// The reducer identifies a deadline incarnation through the due time carried in the + /// payload (), never through the signal + /// time: the host stamps DeadlineFired with the due time (D-238), but the guards + /// must stay correct for any stamp — a late fire after Modern Standby or a restart, a + /// replayed signal, or a future switch to the firing clock. Without the key they fall back + /// to the signal time (pre-key signal logs, where the two were equal by contract). /// public sealed class DeadlineFireTimeStampTests { @@ -74,8 +72,9 @@ namespace AutopilotMonitor.DecisionCore.Tests { // Session ac5660b8 shape: ESP exits, the user leaves the device at the Hello prompt, it // enters Modern Standby, HelloSafety is due during the sleep and fires ~3 min late after - // wake. The Hello timeout fact, the Finalizing step and the FinalizingGrace due time must - // all sit at the firing clock — never inside the sleep window. + // wake. Fed with the firing clock as signal time, the reducer must stamp the Hello + // timeout fact, the Finalizing step and the FinalizingGrace due time from that time — + // the stamp policy is the host's choice (D-238), the reducer follows the signal. var engine = new DecisionEngine(); var state = DecisionState.CreateInitial("sess-standby", "tenant", T0); state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs index 73d0475e..2cea329c 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs @@ -706,7 +706,7 @@ namespace AutopilotMonitor.DecisionCore.Tests var deadline = Assert.Single(signalStep.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); Assert.Equal(T0.AddMinutes(7), deadline.DueAtUtc); - // DeadlineFired (OccurredAtUtc = DueAtUtc per scheduler contract) → terminal. + // DeadlineFired at the due time (no payload due key → helper falls back to the signal time) → terminal. var step = engine.Reduce(signalStep.NewState, MakeSignal(3, DecisionSignalKind.DeadlineFired, T0.AddMinutes(7), new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs index d13ce094..dc0b584b 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs @@ -443,8 +443,9 @@ namespace AutopilotMonitor.DecisionCore.Tests { // Cancel-then-rearm race: old deadline (DueAt=T+8) fires queued; before processing, // DeviceSetupProvisioningComplete arrives (signal at T+10) → cancel+rearm to DueAt=T+15. - // The queued OLD fire (OccurredAtUtc=T+8) arrives. Guard C must dead-end WITHOUT - // cancelling the new deadline. + // The queued OLD fire arrives carrying its due time T+8 (no payload key here, so the + // helper falls back to the signal time). Guard C must dead-end WITHOUT cancelling the + // new deadline. var engine = new DecisionEngine(); var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-11", "t", T0)); @@ -467,7 +468,7 @@ namespace AutopilotMonitor.DecisionCore.Tests firesPayload: new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); state = rearmedBuilder.Build(); - // OLD queued fire arrives with OccurredAtUtc=T+8 — mismatches new deadline at T+15. + // OLD queued fire arrives with due time T+8 — mismatches the new deadline at T+15. var step = engine.Reduce(state, MakeSignal(99, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts index fb7b9595..d8284e40 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts @@ -5,6 +5,7 @@ import { EnrollmentEvent, Session } from "@/types"; import { V1_PHASE_NAMES, V2_PHASE_NAMES, V1_PHASE_ORDER, V2_PHASE_ORDER } from "../utils/phaseConstants"; import { detectSkipUserStatusPage } from "../utils/espConfig"; import { computeWhiteGloveDurations, computeWhiteGloveSplitSequence, groupEventsByPhase } from "../utils/eventHelpers"; +import type { WhiteGloveDurations } from "../utils/eventHelpers"; import { sumStandbySeconds } from "@/lib/standby"; interface PhaseGrouping { @@ -36,11 +37,7 @@ export interface UseSessionDerivedDataReturn { whiteGloveSplitSequence: number; preProvEvents: EnrollmentEvent[]; userEnrollEvents: EnrollmentEvent[]; - whiteGloveDurations: { - preProvDuration: string | null; - userEnrollDuration: string | null; - combinedDuration: string | null; - }; + whiteGloveDurations: WhiteGloveDurations; eventsByPhase: Record; orderedPhases: string[]; preProvGrouped: PhaseGrouping; @@ -177,15 +174,6 @@ export function useSessionDerivedData( return max; }, [events, sessionLastEventAt]); - // Sleep/standby seconds inside the enrollment window above (system_sleep_episode ground - // truth). The wall-clock duration deliberately keeps the pause — this number tells the story - // next to it ("1h 20m, 56m of it standby") and is clipped to the same window so the two agree - // with the time-attribution chip; sleep after the verdict is not enrollment time. - const standbySeconds = useMemo( - () => sumStandbySeconds(events, enrollmentWindow?.startMs ?? null, enrollmentWindow?.endMs ?? null), - [events, enrollmentWindow], - ); - const phaseNamesMap = session?.enrollmentType === "v2" ? V2_PHASE_NAMES : V1_PHASE_NAMES; const phaseOrder = session?.enrollmentType === "v2" ? V2_PHASE_ORDER : V1_PHASE_ORDER; @@ -221,13 +209,26 @@ export function useSessionDerivedData( // Compute per-block durations for WhiteGlove sessions (using unfiltered events for accuracy). // Duration 1 = pre-provisioning, Duration 2 = user enrollment, combined = D1 + D2 (pause excluded). - const whiteGloveDurations = useMemo(() => { + const whiteGloveDurations = useMemo(() => { if (!isWhiteGloveSession) { - return { preProvDuration: null as string | null, userEnrollDuration: null as string | null, combinedDuration: null as string | null }; + return { preProvDuration: null, userEnrollDuration: null, combinedDuration: null, preProvWindowMs: null, userEnrollWindowMs: null }; } return computeWhiteGloveDurations(events, whiteGloveSplitSequence, session?.startedAt); }, [events, isWhiteGloveSession, whiteGloveSplitSequence, session?.startedAt]); + // Sleep/standby seconds inside the span(s) the Duration cell is measured over + // (system_sleep_episode ground truth): the two WhiteGlove parts when the card shows the + // combined WhiteGlove duration (the shelf pause between them contributes nothing), otherwise + // the enrollment window above. The wall-clock duration deliberately keeps the pause — this + // number tells the story next to it ("1h 20m, 56m of it standby") and, clipped to the same + // spans, agrees with the time-attribution chip; sleep after the verdict is not enrollment time. + const standbySeconds = useMemo(() => { + const windows = isWhiteGloveSession && whiteGloveDurations.combinedDuration + ? [whiteGloveDurations.preProvWindowMs, whiteGloveDurations.userEnrollWindowMs].filter((w): w is NonNullable => w !== null) + : enrollmentWindow ? [enrollmentWindow] : null; + return sumStandbySeconds(events, windows); + }, [events, enrollmentWindow, isWhiteGloveSession, whiteGloveDurations]); + // Group events by phase — single timeline for normal sessions, two groups for WhiteGlove const { eventsByPhase, orderedPhases } = useMemo(() => { if (isWhiteGloveSession) return { eventsByPhase: {} as Record, orderedPhases: [] as string[] }; diff --git a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/NetworkBand.tsx b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/NetworkBand.tsx index 6bb6da54..1e7f43d3 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/NetworkBand.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/NetworkBand.tsx @@ -118,6 +118,14 @@ export default function NetworkBand({ model }: { model: NetworkModel }) { )} ))} + {lifeMarkers.some((m) => m.timeout) && ( + + + Verdict by timeout (a wait ran out; not an observed event) + + )} ✓ @@ -310,7 +318,12 @@ export default function NetworkBand({ model }: { model: NetworkModel }) { strokeWidth="1" className="text-gray-400" /> - + {m.timeout ? ( + // Hollow: a wait expired at this instant — nothing observed on the device. + + ) : ( + + )} {/* invisible enlarged hit target — the dot alone is hard to hover */} {showLabel && ( diff --git a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/__tests__/networkTimelineModel.test.ts b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/__tests__/networkTimelineModel.test.ts index 0c81e290..aef2f9b9 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/__tests__/networkTimelineModel.test.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/__tests__/networkTimelineModel.test.ts @@ -274,3 +274,49 @@ describe('location-gated WiFi payloads', () => { expect(withReading?.dataLimitedReason).toBeUndefined(); }); }); + +describe('timeout verdict markers', () => { + it('draws a Hello-timeout completion as a hollow timeout marker with a plain-language reason', () => { + // ac5660b8 shape: the completion is stamped at the instant the Hello wait ran out — a + // policy instant that fell into a sleep episode, not something the device did. + const events = [ + ethernetInfo(1, 0), + ev(2, 'system_sleep_episode', 12 * MIN, { + kind: 'modern_standby', + enteredAt: iso(7 * MIN), + exitedAt: iso(12 * MIN), + durationSeconds: 300, + }), + ev(3, 'enrollment_complete', 9 * MIN, { trigger: 'DeadlineFired:finalizing_grace', helloOutcome: 'Timeout' }), + ]; + const model = buildNetworkModel(session, events)!; + const marker = model.lifeMarkers.find((m) => m.label.startsWith('Completed')); + expect(marker?.label).toBe('Completed (timeout)'); + expect(marker?.timeout).toBe(true); + expect(marker?.detail).toContain('Windows Hello wait ran out'); + // The sleep block stays one segment — no wake is depicted at the marker. + expect(model.segments.filter((s) => s.kind === 'asleep')).toHaveLength(1); + }); + + it('keeps an observed completion as a plain marker even though the grace deadline delivered it', () => { + const events = [ + ethernetInfo(1, 0), + ev(2, 'enrollment_complete', 9 * MIN, { trigger: 'DeadlineFired:finalizing_grace', helloOutcome: 'Success' }), + ]; + const model = buildNetworkModel(session, events)!; + const marker = model.lifeMarkers.find((m) => m.label.startsWith('Completed')); + expect(marker?.label).toBe('Completed'); + expect(marker?.timeout).toBeUndefined(); + }); + + it('treats the device-only detection window as a timeout verdict', () => { + const events = [ + ethernetInfo(1, 0), + ev(2, 'enrollment_complete', 9 * MIN, { trigger: 'DeadlineFired:device_only_esp_detection' }), + ]; + const model = buildNetworkModel(session, events)!; + const marker = model.lifeMarkers.find((m) => m.label.startsWith('Completed')); + expect(marker?.label).toBe('Completed (timeout)'); + expect(marker?.detail).toContain('user phase'); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/networkTimelineModel.ts b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/networkTimelineModel.ts index 08b025a3..1b97b1f3 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/networkTimelineModel.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/networkTimelineModel.ts @@ -79,6 +79,26 @@ export interface LifeMarker { label: string; /** Optional extra tooltip line (clock markers: old→new time, reason). */ detail?: string; + /** + * The verdict came from a wait that ran out (Hello wait, device-only detection), stamped at + * the wall-clock instant the wait expired — a policy instant, not something observed on the + * device, which may have been asleep. Drawn hollow so it never reads as an event. + */ + timeout?: boolean; +} + +/** Why a terminal verdict is a timeout verdict, in product words — or null when it was observed. */ +export function timeoutVerdictDetail(data: Record | undefined): string | null { + if (!data) return null; + const helloOutcome = typeof data.helloOutcome === 'string' ? data.helloOutcome.toLowerCase() : ''; + const trigger = typeof data.trigger === 'string' ? data.trigger : ''; + if (helloOutcome === 'timeout') { + return 'The Windows Hello wait ran out at this instant; nothing happened on the device here — the enrollment counted as complete from this moment.'; + } + if (trigger === 'DeadlineFired:device_only_esp_detection') { + return 'The wait for a user phase after device setup ran out at this instant; nothing happened on the device here — the enrollment counted as complete from this moment.'; + } + return null; } export interface NetworkModel { @@ -535,17 +555,14 @@ export function buildNetworkModel(session: Session, rawEvents: EnrollmentEvent[] .filter((e) => ['system_reboot_detected', 'desktop_arrived', 'enrollment_complete', 'enrollment_failed'].includes(e.eventType), ) - .map((e) => ({ - t: Date.parse(e.timestamp), - label: - e.eventType === 'system_reboot_detected' - ? 'Reboot' - : e.eventType === 'desktop_arrived' - ? 'Desktop' - : e.eventType === 'enrollment_complete' - ? 'Completed' - : 'Failed', - })) + .map((e): LifeMarker => { + const t = Date.parse(e.timestamp); + if (e.eventType === 'system_reboot_detected') return { t, label: 'Reboot' }; + if (e.eventType === 'desktop_arrived') return { t, label: 'Desktop' }; + const base = e.eventType === 'enrollment_complete' ? 'Completed' : 'Failed'; + const detail = timeoutVerdictDetail(e.data as Record | undefined); + return detail ? { t, label: `${base} (timeout)`, detail, timeout: true } : { t, label: base }; + }) .filter((m) => !isNaN(m.t)); // Clock-step markers: live steps sit at their corrected changepoint; diff --git a/src/Web/autopilot-monitor-web/app/sessions/utils/eventHelpers.ts b/src/Web/autopilot-monitor-web/app/sessions/utils/eventHelpers.ts index 9f49d1a0..68a88de2 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/utils/eventHelpers.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/utils/eventHelpers.ts @@ -83,10 +83,18 @@ export function computeWhiteGloveSplitSequence(events: EnrollmentEvent[]): numbe // Sequence is the canonical event order, so the block edges by sequence are the robust // span anchors. Part 1 additionally starts at session.startedAt (registration) when // provided — the same anchor the backend uses for DurationSeconds. +export interface TimeWindowMs { + startMs: number; + endMs: number; +} + export interface WhiteGloveDurations { preProvDuration: string | null; userEnrollDuration: string | null; combinedDuration: string | null; + /** The two spans the durations are measured over — the standby note clips to the same spans. */ + preProvWindowMs: TimeWindowMs | null; + userEnrollWindowMs: TimeWindowMs | null; } export function computeWhiteGloveDurations( @@ -97,8 +105,8 @@ export function computeWhiteGloveDurations( const preProvEvts = splitSequence < 0 ? events : events.filter(e => e.sequence <= splitSequence); const userEnrollEvts = splitSequence < 0 ? [] : events.filter(e => e.sequence > splitSequence); - const spanMs = (evts: EnrollmentEvent[], startOverrideMs?: number): number => { - if (evts.length === 0) return 0; + const span = (evts: EnrollmentEvent[], startOverrideMs?: number): TimeWindowMs | null => { + if (evts.length === 0) return null; let first = evts[0]; let last = evts[0]; for (const e of evts) { @@ -107,8 +115,9 @@ export function computeWhiteGloveDurations( } const start = startOverrideMs ?? new Date(first.timestamp).getTime(); const end = new Date(last.timestamp).getTime(); - return Math.max(0, end - start); + return { startMs: start, endMs: Math.max(start, end) }; }; + const spanMs = (w: TimeWindowMs | null): number => (w ? w.endMs - w.startMs : 0); const fmt = (ms: number): string | null => { const sec = Math.round(ms / 1000); @@ -119,13 +128,17 @@ export function computeWhiteGloveDurations( }; const startedAtMs = startedAt ? new Date(startedAt).getTime() : NaN; - const preProvMs = spanMs(preProvEvts, Number.isFinite(startedAtMs) ? startedAtMs : undefined); - const userEnrollMs = spanMs(userEnrollEvts); + const preProvWindowMs = span(preProvEvts, Number.isFinite(startedAtMs) ? startedAtMs : undefined); + const userEnrollWindowMs = span(userEnrollEvts); + const preProvMs = spanMs(preProvWindowMs); + const userEnrollMs = spanMs(userEnrollWindowMs); return { preProvDuration: fmt(preProvMs), userEnrollDuration: fmt(userEnrollMs), combinedDuration: fmt(preProvMs + userEnrollMs), + preProvWindowMs, + userEnrollWindowMs, }; } diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/standby.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/standby.test.ts index 50d27356..2efc3e0d 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/standby.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/standby.test.ts @@ -4,14 +4,17 @@ import { sumStandbySeconds } from "../standby"; const T = (iso: string) => Date.parse(iso); -function sleepEpisode(seq: number, enteredAt: string, exitedAt: string, durationSeconds: number): EnrollmentEvent { +function sleepEpisode(seq: number, enteredAt: string | null, exitedAt: string | null, durationSeconds: number): EnrollmentEvent { + const data: Record = { kind: "modern_standby", durationSeconds }; + if (enteredAt) data.enteredAt = enteredAt; + if (exitedAt) data.exitedAt = exitedAt; return { eventType: "system_sleep_episode", source: "SystemTimelineWatcher", - timestamp: exitedAt, + timestamp: exitedAt ?? "2026-09-09T06:45:19Z", sequence: seq, message: "Device exited Modern Standby", - data: { kind: "modern_standby", enteredAt, exitedAt, durationSeconds }, + data, } as unknown as EnrollmentEvent; } @@ -20,42 +23,51 @@ describe("sumStandbySeconds", () => { const episode = sleepEpisode(163, "2026-09-09T06:40:28.6907161Z", "2026-09-09T06:45:19.0307161Z", 290); const windowStart = T("2026-09-09T06:32:46.772Z"); const completedAt = T("2026-09-09T06:42:55.2144855Z"); + const window = [{ startMs: windowStart, endMs: completedAt }]; it("clips an episode that straddles the end of the window", () => { - expect(sumStandbySeconds([episode], windowStart, completedAt)).toBe(147); + expect(sumStandbySeconds([episode], window)).toBe(147); }); it("counts the whole episode when it lies inside the window", () => { - expect(sumStandbySeconds([episode], windowStart, T("2026-09-09T06:45:49Z"))).toBe(290); + expect(sumStandbySeconds([episode], [{ startMs: windowStart, endMs: T("2026-09-09T06:45:49Z") }])).toBe(290); }); - it("counts the whole episode without a window", () => { - expect(sumStandbySeconds([episode], null, null)).toBe(290); + it("counts the whole episode without windows", () => { + expect(sumStandbySeconds([episode], null)).toBe(290); }); it("returns null for an episode entirely outside the window", () => { - expect(sumStandbySeconds([episode], windowStart, T("2026-09-09T06:40:00Z"))).toBeNull(); + expect(sumStandbySeconds([episode], [{ startMs: windowStart, endMs: T("2026-09-09T06:40:00Z") }])).toBeNull(); + }); + + it("sums only the in-window flanks across two WhiteGlove windows", () => { + // Episode 10:00–14:00; part 1 ends 10:30, part 2 runs 13:45–14:20 → 30 min + 15 min. + const shelf = sleepEpisode(9, "2026-09-09T10:00:00Z", "2026-09-09T14:00:00Z", 4 * 3600); + const windows = [ + { startMs: T("2026-09-09T09:40:00Z"), endMs: T("2026-09-09T10:30:00Z") }, + { startMs: T("2026-09-09T13:45:00Z"), endMs: T("2026-09-09T14:20:00Z") }, + ]; + expect(sumStandbySeconds([shelf], windows)).toBe(45 * 60); }); it("dedups the same episode observed twice", () => { const again = sleepEpisode(200, "2026-09-09T06:40:28.6907161Z", "2026-09-09T06:45:19.0307161Z", 290); - expect(sumStandbySeconds([episode, again], null, null)).toBe(290); + expect(sumStandbySeconds([episode, again], null)).toBe(290); + }); + + it("does not let an unusable observation shadow a later usable one", () => { + const broken = sleepEpisode(150, "2026-09-09T06:40:28.6907161Z", null, 0); + expect(sumStandbySeconds([broken, episode], null)).toBe(290); }); it("falls back to durationSeconds when the payload carries no instants", () => { - const legacy = { - eventType: "system_sleep_episode", - source: "SystemTimelineWatcher", - timestamp: "2026-09-09T06:45:19Z", - sequence: 5, - message: "", - data: { kind: "sleep", durationSeconds: 120 }, - } as unknown as EnrollmentEvent; - expect(sumStandbySeconds([legacy], windowStart, completedAt)).toBe(120); + const legacy = sleepEpisode(5, null, null, 120); + expect(sumStandbySeconds([legacy], window)).toBe(120); }); it("ignores other events and returns null when nothing slept", () => { const other = { ...episode, eventType: "performance_snapshot" } as EnrollmentEvent; - expect(sumStandbySeconds([other], null, null)).toBeNull(); + expect(sumStandbySeconds([other], null)).toBeNull(); }); }); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/whiteGloveDurations.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/whiteGloveDurations.test.ts index 5771d9c7..57b42f73 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/whiteGloveDurations.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/whiteGloveDurations.test.ts @@ -29,6 +29,9 @@ describe("computeWhiteGloveDurations", () => { expect(d.preProvDuration).toBe("57m 0s"); expect(d.userEnrollDuration).toBe("56m 0s"); expect(d.combinedDuration).toBe("1h 53m"); // pause 09:28→11:00 excluded + // The spans the durations are measured over — the standby note clips to exactly these. + expect(d.preProvWindowMs).toEqual({ startMs: Date.parse("2026-07-23T08:31:00Z"), endMs: Date.parse("2026-07-23T09:28:00Z") }); + expect(d.userEnrollWindowMs).toEqual({ startMs: Date.parse("2026-07-23T11:00:00Z"), endMs: Date.parse("2026-07-23T11:56:00Z") }); }); it("ignores a historical event time re-emitted into the user block (post-resume IME re-parse)", () => { diff --git a/src/Web/autopilot-monitor-web/lib/standby.ts b/src/Web/autopilot-monitor-web/lib/standby.ts index a96b1ddb..5c784634 100644 --- a/src/Web/autopilot-monitor-web/lib/standby.ts +++ b/src/Web/autopilot-monitor-web/lib/standby.ts @@ -1,19 +1,25 @@ import type { EnrollmentEvent } from "@/types"; +export interface StandbyWindow { + startMs: number; + endMs: number; +} + /** - * Seconds the device spent asleep inside a window, from `system_sleep_episode` ground truth. + * Seconds the device spent asleep inside the given windows, from `system_sleep_episode` + * ground truth. * * The payload's `enteredAt` / `exitedAt` are the authoritative instants (the event itself is - * stamped at wake). Each episode is clipped to `[windowStartMs, windowEndMs]` so the number sits - * next to a duration that covers the same window — an episode that straddles the end of the - * enrollment contributes only its in-window part, matching the backend's time-attribution - * `SleepSpans`. Episodes are dedup'd on `enteredAt`: an agent restart can re-backfill the same - * episode from a different event-log record. Returns null when nothing was asleep. + * stamped at wake). Each episode is clipped to the windows so the number sits next to a + * duration measured over the same spans — one span for a regular session, the two WhiteGlove + * parts for a pre-provisioned one (the shelf pause between them contributes nothing, as in the + * backend's time-attribution `SleepSpans`). `windows === null` means unclipped. Episodes are + * dedup'd on `enteredAt` once they contributed: an agent restart can re-backfill the same episode + * from a different event-log record. Returns null when nothing was asleep. */ export function sumStandbySeconds( events: EnrollmentEvent[], - windowStartMs: number | null, - windowEndMs: number | null, + windows: ReadonlyArray | null, ): number | null { const seen = new Set(); let totalMs = 0; @@ -23,19 +29,27 @@ export function sumStandbySeconds( const exitedRaw = typeof e.data.exitedAt === "string" ? e.data.exitedAt : null; const key = enteredRaw ?? `seq-${e.sequence}`; if (seen.has(key)) continue; - seen.add(key); - let start = enteredRaw ? Date.parse(enteredRaw) : NaN; - let end = exitedRaw ? Date.parse(exitedRaw) : NaN; + const start = enteredRaw ? Date.parse(enteredRaw) : NaN; + const end = exitedRaw ? Date.parse(exitedRaw) : NaN; if (!Number.isFinite(start) || !Number.isFinite(end)) { // No usable instants: fall back to the reported duration, unclipped. const duration = Number(e.data.durationSeconds); - if (Number.isFinite(duration) && duration > 0) totalMs += duration * 1000; + if (!Number.isFinite(duration) || duration <= 0) continue; + seen.add(key); + totalMs += duration * 1000; continue; } - if (windowStartMs !== null) start = Math.max(start, windowStartMs); - if (windowEndMs !== null) end = Math.min(end, windowEndMs); - if (end > start) totalMs += end - start; + seen.add(key); + if (windows === null) { + if (end > start) totalMs += end - start; + continue; + } + for (const w of windows) { + const s = Math.max(start, w.startMs); + const t = Math.min(end, w.endMs); + if (t > s) totalMs += t - s; + } } return totalMs > 0 ? Math.round(totalMs / 1000) : null; }