diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs new file mode 100644 index 00000000..2bfa84cd --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// M4.6.β — fires the terminal-stage callback exactly + /// once, at the transition from a non-terminal to a terminal . + /// + public sealed class DecisionStepProcessorTerminalStageTests + { + private static DateTime At => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public FakeJournalWriter Journal { get; } = new FakeJournalWriter(); + public FakeEffectRunner Effects { get; } = new FakeEffectRunner(); + public FakeSnapshotPersistence Snapshot { get; } = new FakeSnapshotPersistence(); + public FakeQuarantineSink Quarantine { get; } = new FakeQuarantineSink(); + + public Rig() { Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); } + + public DecisionStepProcessor Build(DecisionState initial, Action? onTerminal) => + new DecisionStepProcessor( + initialState: initial, + journal: Journal, + effectRunner: Effects, + snapshot: Snapshot, + quarantineSink: Quarantine, + logger: Logger, + quarantineThreshold: DecisionStepProcessor.DefaultQuarantineThreshold, + onTerminalStageReached: onTerminal); + + public void Dispose() => Tmp.Dispose(); + } + + private static DecisionSignal Sig(long ordinal, DateTime at) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 1, + occurredAtUtc: at, + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Raw, $"raw-{ordinal}", $"evidence-{ordinal}"), + payload: null); + + private static DecisionStep StepToStage(DecisionState oldState, SessionStage newStage, long signalOrdinal, DateTime at) + { + var builder = new DecisionStateBuilder(oldState) { Stage = newStage, StepIndex = oldState.StepIndex + 1 }; + var newState = builder.Build(); + var transition = new DecisionTransition( + stepIndex: newState.StepIndex, + sessionTraceOrdinal: signalOrdinal, + signalOrdinalRef: signalOrdinal, + occurredAtUtc: at, + trigger: "TestTerminalTrigger", + fromStage: oldState.Stage, + toStage: newStage, + taken: true, + deadEndReason: null, + reducerVersion: "2.0.0.0"); + return new DecisionStep(newState, transition, Array.Empty()); + } + + [Theory] + [InlineData(SessionStage.Completed)] + [InlineData(SessionStage.Failed)] + [InlineData(SessionStage.WhiteGloveSealed)] + [InlineData(SessionStage.WhiteGloveCompletedPart2)] + public void Terminal_stage_transition_fires_callback_with_terminal_state(SessionStage terminal) + { + using var rig = new Rig(); + DecisionState? captured = null; + var callCount = 0; + var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), + onTerminal: state => + { + Interlocked.Increment(ref callCount); + captured = state; + }); + + var step = StepToStage(sut.CurrentState, terminal, signalOrdinal: 1, at: At); + sut.ApplyStep(step, Sig(1, At)); + + Assert.Equal(1, callCount); + Assert.NotNull(captured); + Assert.Equal(terminal, captured!.Stage); + } + + [Fact] + public void Terminal_callback_does_not_fire_for_non_terminal_transitions() + { + using var rig = new Rig(); + var callCount = 0; + var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), + onTerminal: _ => Interlocked.Increment(ref callCount)); + + var step = StepToStage(sut.CurrentState, SessionStage.AwaitingEspPhaseChange, signalOrdinal: 1, at: At); + sut.ApplyStep(step, Sig(1, At)); + + Assert.Equal(0, callCount); + } + + [Fact] + public void Terminal_callback_fires_at_most_once_per_processor_instance() + { + using var rig = new Rig(); + var callCount = 0; + var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), + onTerminal: _ => Interlocked.Increment(ref callCount)); + + // Transition to Completed, then apply a second step that leaves the stage terminal. + var firstStep = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At); + sut.ApplyStep(firstStep, Sig(1, At)); + + // Synthetic second step — stage still terminal (same value). + var secondStep = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 2, at: At.AddSeconds(1)); + sut.ApplyStep(secondStep, Sig(2, At.AddSeconds(1))); + + Assert.Equal(1, callCount); + } + + [Fact] + public void Terminal_callback_suppressed_when_initial_state_is_already_terminal() + { + // Recovery scenario: snapshot loaded a state whose stage is already terminal + // (e.g. crash after terminal transition but before Stop). The processor must not + // re-fire the callback on the first post-recovery step. + using var rig = new Rig(); + var callCount = 0; + var recoveredState = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) + { + Stage = SessionStage.Completed, + }.Build(); + + var sut = rig.Build(recoveredState, + onTerminal: _ => Interlocked.Increment(ref callCount)); + + // No ApplyStep needed — the ctor already observed the terminal state. Even if we + // apply a follow-up terminal-preserving step, callback stays silent. + var step = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At); + sut.ApplyStep(step, Sig(1, At)); + + Assert.Equal(0, callCount); + } + + [Fact] + public void Terminal_callback_null_is_supported() + { + using var rig = new Rig(); + var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), onTerminal: null); + var step = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At); + + // Should not throw when callback is null. + sut.ApplyStep(step, Sig(1, At)); + Assert.Equal(SessionStage.Completed, sut.CurrentState.Stage); + } + + [Fact] + public void Terminal_callback_exception_is_swallowed_and_logged() + { + using var rig = new Rig(); + var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), + onTerminal: _ => throw new InvalidOperationException("handler bug")); + + var step = StepToStage(sut.CurrentState, SessionStage.Failed, signalOrdinal: 1, at: At); + // Must not bubble the handler's exception — the step is already committed. + sut.ApplyStep(step, Sig(1, At)); + Assert.Equal(SessionStage.Failed, sut.CurrentState.Stage); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs new file mode 100644 index 00000000..e65c5168 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs @@ -0,0 +1,221 @@ +#nullable enable +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Termination; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination +{ + public sealed class EnrollmentTerminationHandlerTests + { + private static DateTime StartUtc => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + private static DateTime EndUtc => StartUtc.AddMinutes(10); + + /// + /// CleanupService is virtual for testability. ExecuteSelfDestruct is a best-effort + /// fire-and-forget in production — in tests we just count invocations. + /// + private sealed class RecordingCleanupService : CleanupService + { + public int Invocations; + public RecordingCleanupService(AgentConfiguration config, AgentLogger logger) : base(config, logger) { } + public override void ExecuteSelfDestruct() => Interlocked.Increment(ref Invocations); + } + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public string StateDir { get; } + public AgentLogger Logger { get; } + public DecisionState State { get; set; } = DecisionState.CreateInitial("S1", "T1"); + public AppPackageStateList Packages { get; } + public RecordingCleanupService CleanupService { get; } + public int DiagnosticsUploads { get; private set; } + public bool? LastDiagnosticsSucceededFlag { get; private set; } + public string? LastDiagnosticsSuffix { get; private set; } + public DiagnosticsUploadResult? DiagnosticsResult { get; set; } + public int ShutdownSignalled; + + public Rig() + { + StateDir = Path.Combine(Tmp.Path, "State"); + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Packages = new AppPackageStateList(Logger); + CleanupService = new RecordingCleanupService(BuildConfig(), Logger); + } + + public AgentConfiguration BuildConfig( + bool selfDestruct = true, + bool showDialog = false, + bool diagEnabled = false, + string diagMode = "Off") + { + return new AgentConfiguration + { + SessionId = "S1", + TenantId = "T1", + ApiBaseUrl = "http://localhost", + SelfDestructOnComplete = selfDestruct, + ShowEnrollmentSummary = showDialog, + DiagnosticsUploadEnabled = diagEnabled, + DiagnosticsUploadMode = diagMode, + EnrollmentSummaryTimeoutSeconds = 60, + }; + } + + public EnrollmentTerminationHandler Build(AgentConfiguration? config = null) + { + config ??= BuildConfig(); + return new EnrollmentTerminationHandler( + configuration: config, + logger: Logger, + stateDirectory: StateDir, + agentStartTimeUtc: StartUtc, + currentStateAccessor: () => State, + packageStatesAccessor: () => Packages, + cleanupServiceFactory: () => CleanupService, + uploadDiagnosticsAsync: (succeeded, suffix) => + { + DiagnosticsUploads++; + LastDiagnosticsSucceededFlag = succeeded; + LastDiagnosticsSuffix = suffix; + return Task.FromResult(DiagnosticsResult ?? new DiagnosticsUploadResult { BlobName = "blob" }); + }, + signalShutdown: () => Interlocked.Increment(ref ShutdownSignalled)); + } + + public void Dispose() => Tmp.Dispose(); + } + + private static EnrollmentTerminatedEventArgs Args( + EnrollmentTerminationReason reason, + EnrollmentTerminationOutcome outcome, + SessionStage stage, + DateTime? at = null) => + new EnrollmentTerminatedEventArgs( + reason, outcome, stage.ToString(), at ?? EndUtc); + + [Fact] + public void Handle_writes_final_status_json_in_state_directory() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + + rig.Build().Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + var statusPath = Path.Combine(rig.StateDir, SummaryDialogLauncher.FinalStatusFileName); + Assert.True(File.Exists(statusPath), "final-status.json should be written even when ShowEnrollmentSummary=false"); + } + + [Fact] + public void Handle_writes_enrollment_complete_marker_on_non_whiteglove_terminal() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + + rig.Build().Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.True(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker"))); + } + + [Fact] + public void Handle_skips_marker_and_cleanup_on_whiteglove_part1_exit() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build(); + + rig.Build().Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed)); + + Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker"))); + Assert.Equal(0, rig.CleanupService.Invocations); + } + + [Fact] + public void Handle_runs_cleanup_on_success_when_self_destruct_enabled() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + + rig.Build().Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.Equal(1, rig.CleanupService.Invocations); + } + + [Fact] + public void Handle_skips_cleanup_when_self_destruct_disabled() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + var cfg = rig.BuildConfig(selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.Equal(0, rig.CleanupService.Invocations); + } + + [Fact] + public void Handle_signals_shutdown_exactly_once_even_on_error() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build(); + + var sut = rig.Build(); + sut.Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed)); + + // Idempotent — second Handle is a no-op. + sut.Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed)); + + Assert.Equal(1, rig.ShutdownSignalled); + } + + [Theory] + [InlineData("Off", EnrollmentTerminationOutcome.Succeeded, false, 0)] + [InlineData("Always", EnrollmentTerminationOutcome.Succeeded, true, 1)] + [InlineData("Always", EnrollmentTerminationOutcome.Failed, true, 1)] + [InlineData("OnFailure", EnrollmentTerminationOutcome.Succeeded, true, 0)] + [InlineData("OnFailure", EnrollmentTerminationOutcome.Failed, true, 1)] + public void Handle_diagnostics_upload_respects_mode_and_outcome( + string mode, EnrollmentTerminationOutcome outcome, bool enabled, int expected) + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + var cfg = rig.BuildConfig(diagEnabled: enabled, diagMode: mode, selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, outcome, + outcome == EnrollmentTerminationOutcome.Succeeded ? SessionStage.Completed : SessionStage.Failed)); + + Assert.Equal(expected, rig.DiagnosticsUploads); + } + + [Fact] + public void Handle_diagnostics_upload_suffix_reflects_success_vs_failure() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build(); + var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed)); + + Assert.Equal("failure", rig.LastDiagnosticsSuffix); + Assert.Equal(false, rig.LastDiagnosticsSucceededFlag); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs new file mode 100644 index 00000000..ebad4d30 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs @@ -0,0 +1,207 @@ +#nullable enable +using System; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Termination; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using Newtonsoft.Json; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination +{ + public sealed class FinalStatusBuilderTests + { + private static DateTime StartUtc => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + private static DateTime EndUtc => StartUtc.AddMinutes(45); + + private static DecisionState StateWith(SessionStage stage, string? helloOutcome = null) + { + var initial = DecisionState.CreateInitial("S1", "T1"); + var builder = new DecisionStateBuilder(initial) { Stage = stage }; + if (helloOutcome != null) + builder.HelloOutcome = new SignalFact(helloOutcome, sourceSignalOrdinal: 1); + return builder.Build(); + } + + private static EnrollmentTerminatedEventArgs Args( + EnrollmentTerminationReason reason, + EnrollmentTerminationOutcome outcome, + SessionStage stage) => + new EnrollmentTerminatedEventArgs( + reason: reason, + outcome: outcome, + stageName: stage.ToString(), + terminatedAtUtc: EndUtc); + + [Theory] + [InlineData(SessionStage.Completed, EnrollmentTerminationOutcome.Succeeded, "succeeded")] + [InlineData(SessionStage.Failed, EnrollmentTerminationOutcome.Failed, "failed")] + [InlineData(SessionStage.WhiteGloveSealed, EnrollmentTerminationOutcome.Succeeded, "whiteglove_part1")] + [InlineData(SessionStage.WhiteGloveCompletedPart2, EnrollmentTerminationOutcome.Succeeded, "whiteglove_part2")] + public void Build_maps_outcome_from_terminal_stage(SessionStage stage, EnrollmentTerminationOutcome outcome, string expected) + { + var state = StateWith(stage); + var args = Args(EnrollmentTerminationReason.DecisionTerminalStage, outcome, stage); + + var status = FinalStatusBuilder.Build(state, args, packageStates: null, agentStartTimeUtc: StartUtc); + + Assert.Equal(expected, status.Outcome); + } + + [Fact] + public void Build_reports_agent_uptime_in_seconds() + { + var state = StateWith(SessionStage.Completed); + var args = Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed); + + var status = FinalStatusBuilder.Build(state, args, packageStates: null, agentStartTimeUtc: StartUtc); + + Assert.Equal((EndUtc - StartUtc).TotalSeconds, status.AgentUptimeSeconds); + } + + [Fact] + public void Build_records_completion_source_from_reason() + { + var state = StateWith(SessionStage.Completed); + var args = Args(EnrollmentTerminationReason.MaxLifetimeExceeded, EnrollmentTerminationOutcome.TimedOut, SessionStage.Completed); + + var status = FinalStatusBuilder.Build(state, args, packageStates: null, agentStartTimeUtc: StartUtc); + + Assert.Equal(nameof(EnrollmentTerminationReason.MaxLifetimeExceeded), status.CompletionSource); + } + + [Fact] + public void Build_reports_hello_outcome_from_fact_or_unknown() + { + var state1 = StateWith(SessionStage.Completed, helloOutcome: "completed"); + var status1 = FinalStatusBuilder.Build(state1, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: null, agentStartTimeUtc: StartUtc); + Assert.Equal("completed", status1.HelloOutcome); + + var state2 = StateWith(SessionStage.Completed, helloOutcome: null); + var status2 = FinalStatusBuilder.Build(state2, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: null, agentStartTimeUtc: StartUtc); + Assert.Equal("unknown", status2.HelloOutcome); + } + + [Fact] + public void Build_signalsSeen_from_decision_state_facts() + { + var initial = DecisionState.CreateInitial("S1", "T1"); + var state = new DecisionStateBuilder(initial) + { + Stage = SessionStage.Completed, + HelloResolvedUtc = new SignalFact(StartUtc.AddMinutes(5), 1), + DesktopArrivedUtc = new SignalFact(StartUtc.AddMinutes(10), 2), + AadJoinedWithUser = new SignalFact(true, 3), + ImeMatchedPatternId = new SignalFact("esp_done", 4), + }.Build(); + + var status = FinalStatusBuilder.Build(state, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: null, agentStartTimeUtc: StartUtc); + + Assert.Contains("hello_resolved", status.SignalsSeen); + Assert.Contains("desktop_arrived", status.SignalsSeen); + Assert.Contains("aad_user_joined", status.SignalsSeen); + Assert.Contains("ime_pattern:esp_done", status.SignalsSeen); + } + + [Fact] + public void Build_with_null_package_states_produces_empty_app_summary() + { + var state = StateWith(SessionStage.Completed); + var status = FinalStatusBuilder.Build(state, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: null, agentStartTimeUtc: StartUtc); + + Assert.Equal(0, status.AppSummary.TotalApps); + Assert.Empty(status.AppSummary.AppsByPhase); + Assert.Empty(status.PackageStatesByPhase); + } + + [Fact] + public void Build_counts_apps_by_installation_state_and_targeted_phase() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + var list = new AppPackageStateList(logger); + + var okDevice = new AppPackageState("app-1", 0); // default installationState = Unknown + okDevice.UpdateState(AppInstallationState.Installed); + TestHelpers.SetTargeted(okDevice, AppTargeted.Device); + list.Add(okDevice); + + var errDevice = new AppPackageState("app-2", 1); + errDevice.UpdateState(AppInstallationState.Error); + TestHelpers.SetTargeted(errDevice, AppTargeted.Device); + list.Add(errDevice); + + var errUser = new AppPackageState("app-3", 2); + errUser.UpdateState(AppInstallationState.Error); + TestHelpers.SetTargeted(errUser, AppTargeted.User); + list.Add(errUser); + + var inProgress = new AppPackageState("app-4", 3); + inProgress.UpdateState(AppInstallationState.InProgress); + TestHelpers.SetTargeted(inProgress, AppTargeted.Device); + list.Add(inProgress); + + var state = StateWith(SessionStage.Completed); + var status = FinalStatusBuilder.Build(state, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: list, agentStartTimeUtc: StartUtc); + + Assert.Equal(4, status.AppSummary.TotalApps); + Assert.Equal(3, status.AppSummary.CompletedApps); // Installed + 2 Errors count as completed (terminal) + Assert.Equal(2, status.AppSummary.ErrorCount); + Assert.Equal(1, status.AppSummary.DeviceErrors); + Assert.Equal(1, status.AppSummary.UserErrors); + Assert.Equal(3, status.AppSummary.AppsByPhase["Device"]); + Assert.Equal(1, status.AppSummary.AppsByPhase["User"]); + + Assert.True(status.PackageStatesByPhase.ContainsKey("Device")); + Assert.True(status.PackageStatesByPhase.ContainsKey("User")); + Assert.Equal(3, status.PackageStatesByPhase["Device"].Count); + Assert.Single(status.PackageStatesByPhase["User"]); + } + + [Fact] + public void Build_serializes_to_json_with_expected_property_names() + { + var state = StateWith(SessionStage.Completed); + var status = FinalStatusBuilder.Build(state, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: null, agentStartTimeUtc: StartUtc); + + var json = JsonConvert.SerializeObject(status); + + // Dialog-wire contract: all fields lower-camelCase (matches SummaryDialog's FinalStatus model). + Assert.Contains("\"timestamp\":", json); + Assert.Contains("\"outcome\":", json); + Assert.Contains("\"completionSource\":", json); + Assert.Contains("\"helloOutcome\":", json); + Assert.Contains("\"enrollmentType\":", json); + Assert.Contains("\"agentUptimeSeconds\":", json); + Assert.Contains("\"signalsSeen\":", json); + Assert.Contains("\"appSummary\":", json); + Assert.Contains("\"packageStatesByPhase\":", json); + } + } + + /// Mini helper for reflection-based field setters — AppPackageState.Targeted is a private-set property. + internal static class TestHelpers + { + public static void SetTargeted(AppPackageState pkg, AppTargeted targeted) + { + var prop = typeof(AppPackageState).GetProperty(nameof(AppPackageState.Targeted))!; + prop.SetValue(pkg, targeted); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs index 2914593d..cbc49356 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs @@ -50,10 +50,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private readonly IQuarantineSink _quarantineSink; private readonly AgentLogger _logger; private readonly int _quarantineThreshold; + private readonly Action? _onTerminalStageReached; private DecisionState _currentState; private int _consecutiveJournalFailures; private bool _quarantineTriggered; + private bool _terminalNotified; public DecisionStepProcessor( DecisionState initialState, @@ -62,7 +64,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration ISnapshotPersistence snapshot, IQuarantineSink quarantineSink, AgentLogger logger, - int quarantineThreshold = DefaultQuarantineThreshold) + int quarantineThreshold = DefaultQuarantineThreshold, + Action? onTerminalStageReached = null) { if (quarantineThreshold <= 0) { @@ -78,6 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration _quarantineSink = quarantineSink ?? throw new ArgumentNullException(nameof(quarantineSink)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _quarantineThreshold = quarantineThreshold; + _onTerminalStageReached = onTerminalStageReached; + + // If recovery loaded a state that already sits on a terminal stage (e.g. a crash + // after a success-path step but before Stop()), treat it as already-notified so we + // do not re-fire the hook. + _terminalNotified = initialState.Stage.IsTerminal(); } public DecisionState CurrentState => _currentState; @@ -164,6 +173,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration // 4) State-Forward + Counter-Reset. _currentState = step.NewState; _consecutiveJournalFailures = 0; + + // 5) Terminal-stage detection (M4.6.β). Fires exactly once per agent run when the + // DecisionEngine transitions the session into a terminal SessionStage — the + // orchestrator turns this into the public EnrollmentTerminated event so peripheral + // consumers (CleanupService, SummaryDialog, DiagnosticsPackageService) can react + // without touching the kernel state machine. + if (!_terminalNotified && _currentState.Stage.IsTerminal()) + { + _terminalNotified = true; + try { _onTerminalStageReached?.Invoke(_currentState); } + catch (Exception ex) + { + _logger.Error( + $"DecisionStepProcessor: onTerminalStageReached handler threw for stage {_currentState.Stage}.", + ex); + } + } } private void TryTriggerQuarantine(string reason) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 3f2a02a0..d490f41e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -59,6 +59,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private readonly string _agentVersion; private readonly string _stateDirectory; + private ImeLogHost? _imeLogHost; + + /// + /// Exposes the IME tracker's package-state list to peripheral consumers such as the + /// FinalStatusBuilder in M4.6.β. Returns null before + /// has been called (Orchestrator start order). + /// + public Monitoring.Enrollment.Ime.AppPackageStateList? ImePackageStates => _imeLogHost?.PackageStates; + public DefaultComponentFactory( AgentConfiguration agentConfig, AgentConfigResponse remoteConfig, @@ -113,7 +122,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration hosts.Add(new AadJoinHost(logger, ingress, clock)); - hosts.Add(new ImeLogHost( + _imeLogHost = new ImeLogHost( sessionId: sessionId, tenantId: tenantId, onEnrollmentEvent: onEnrollmentEvent, @@ -124,7 +133,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration imeMatchLogPath: _agentConfig.ImeMatchLogPath, imePatterns: _remoteConfig.ImeLogPatterns, stateDirectory: _stateDirectory, - whiteGloveSealingPatternIds: whiteGloveSealingPatternIds)); + whiteGloveSealingPatternIds: whiteGloveSealingPatternIds); + hosts.Add(_imeLogHost); if (collectors.StallProbeEnabled) { @@ -275,6 +285,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private readonly AgentLogger _logger; private int _disposed; + /// + /// Exposes the tracker's live package-state list for peripheral consumers + /// (M4.6.β FinalStatusBuilder). + /// + public Monitoring.Enrollment.Ime.AppPackageStateList PackageStates => _tracker.PackageStates; + public ImeLogHost( string sessionId, string tenantId, diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs index b5dd44e0..ec362ff6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs @@ -322,7 +322,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration snapshot: _snapshot, clock: _clock); - // 8) Processor — owns the initial state + journal + snapshot + quarantine hook. + // 8) Processor — owns the initial state + journal + snapshot + quarantine hook + + // M4.6.β terminal-stage hook (fires Terminated event when the engine reaches a + // terminal SessionStage). _processor = new DecisionStepProcessor( initialState: initialState, journal: _journal, @@ -330,7 +332,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration snapshot: _snapshot, quarantineSink: this, logger: _logger, - quarantineThreshold: _quarantineThreshold); + quarantineThreshold: _quarantineThreshold, + onTerminalStageReached: OnDecisionTerminalStage); // 9) Trace-Ordinal. TODO(M4.4.5.f): seed aus max(SignalLog.LastOrdinal, // Journal.LastStepIndex, Spool.LastAssignedItemId via SessionTraceOrdinal). @@ -449,6 +452,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration catch (Exception ex) { _logger.Error("EnrollmentOrchestrator: Terminated handler threw.", ex); } } + /// + /// M4.6.β — DecisionStepProcessor callback: the engine has transitioned the session + /// into a terminal . Fires the public + /// event with and an + /// outcome derived from the stage (Completed/Part2→Succeeded, Failed→Failed, + /// WhiteGloveSealed→Succeeded but with + /// signalled to callers via the stage name — callers decide whether to self-destruct). + /// + private void OnDecisionTerminalStage(DecisionState terminalState) + { + if (Volatile.Read(ref _stopRequested) == 1) return; + if (Interlocked.Exchange(ref _terminatedFired, 1) == 1) return; + + // Stop the max-lifetime watchdog — the real terminal arrived before it could trip. + try { _maxLifetimeTimer?.Dispose(); _maxLifetimeTimer = null; } catch { } + + var outcome = terminalState.Stage switch + { + SessionStage.Completed or SessionStage.WhiteGloveCompletedPart2 or SessionStage.WhiteGloveSealed + => EnrollmentTerminationOutcome.Succeeded, + SessionStage.Failed => EnrollmentTerminationOutcome.Failed, + _ => EnrollmentTerminationOutcome.TimedOut, + }; + + _logger.Info( + $"EnrollmentOrchestrator: decision terminal stage reached (stage={terminalState.Stage}, outcome={outcome}) — firing Terminated(DecisionTerminalStage)."); + + var terminatedArgs = new EnrollmentTerminatedEventArgs( + reason: EnrollmentTerminationReason.DecisionTerminalStage, + outcome: outcome, + stageName: terminalState.Stage.ToString(), + terminatedAtUtc: _clock.UtcNow, + details: terminalState.Stage.IsPauseBeforePart2() + ? "WhiteGlove Part 1 sealed — session will resume on Part 2 post-reboot; self-destruct suppressed." + : null); + + try { Terminated?.Invoke(this, terminatedArgs); } + catch (Exception ex) { _logger.Error("EnrollmentOrchestrator: Terminated handler threw.", ex); } + } + /// /// Stop the pipeline. Idempotent; calls from as well. /// diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs new file mode 100644 index 00000000..17fd02a1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs @@ -0,0 +1,230 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared; + +namespace AutopilotMonitor.Agent.V2.Core.Termination +{ + /// + /// Orchestrates peripheral termination work in response to + /// . Plan §4.x M4.6.β. + /// + /// Sequence (Legacy parity — single-writer, best-effort each step, never throws): + /// + /// + /// Compose via . + /// Write final-status.json + launch SummaryDialog via . + /// Upload diagnostics via (respects + /// DiagnosticsUploadMode: Off / Always / OnFailure). + /// Write enrollment-complete.marker (so ghost-restart detection on next boot + /// exits cleanly even if failed). + /// Run — UNLESS the stage is + /// (Part 1 exit, session resumes Part 2). + /// Signal the caller-owned shutdown so Program.cs' + /// main loop exits the shutdown.Wait() and calls orchestrator.Stop(). + /// + /// + /// Each step logs + continues on failure — nothing in here is allowed to prevent the agent + /// from shutting down. CleanupService is fire-and-forget (spawns a PowerShell cleanup script + /// that waits for process exit); this handler does not block waiting for the script. + /// + /// + public sealed class EnrollmentTerminationHandler + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly string _stateDirectory; + private readonly DateTime _agentStartTimeUtc; + private readonly Func _currentStateAccessor; + private readonly Func _packageStatesAccessor; + private readonly Func _cleanupServiceFactory; + private readonly Func> _uploadDiagnosticsAsync; + private readonly Action _signalShutdown; + private readonly string _dialogExePathOverride; + + private int _handled; + + public EnrollmentTerminationHandler( + AgentConfiguration configuration, + AgentLogger logger, + string stateDirectory, + DateTime agentStartTimeUtc, + Func currentStateAccessor, + Func packageStatesAccessor, + Func cleanupServiceFactory, + Func> uploadDiagnosticsAsync, + Action signalShutdown, + string dialogExePathOverride = null) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _stateDirectory = string.IsNullOrEmpty(stateDirectory) ? throw new ArgumentNullException(nameof(stateDirectory)) : stateDirectory; + _agentStartTimeUtc = agentStartTimeUtc; + _currentStateAccessor = currentStateAccessor ?? throw new ArgumentNullException(nameof(currentStateAccessor)); + _packageStatesAccessor = packageStatesAccessor ?? throw new ArgumentNullException(nameof(packageStatesAccessor)); + _cleanupServiceFactory = cleanupServiceFactory ?? throw new ArgumentNullException(nameof(cleanupServiceFactory)); + _uploadDiagnosticsAsync = uploadDiagnosticsAsync ?? throw new ArgumentNullException(nameof(uploadDiagnosticsAsync)); + _signalShutdown = signalShutdown ?? throw new ArgumentNullException(nameof(signalShutdown)); + _dialogExePathOverride = dialogExePathOverride; + } + + /// + /// Handler for . Idempotent — runs at most once. + /// + public void Handle(object sender, EnrollmentTerminatedEventArgs args) + { + if (Interlocked.Exchange(ref _handled, 1) == 1) return; + + try + { + _logger.Info( + $"EnrollmentTerminationHandler: handling Terminated (reason={args.Reason}, outcome={args.Outcome}, stage={args.StageName})."); + + var state = TryGetCurrentState(); + if (state == null) + { + _logger.Warning("EnrollmentTerminationHandler: current state unavailable — skipping FinalStatus + SummaryDialog."); + } + else + { + RunBuildAndLaunchDialog(state, args); + } + + RunUploadDiagnostics(args); + WriteEnrollmentCompleteMarker(args); + RunSelfDestructIfAppropriate(args); + } + catch (Exception ex) + { + _logger.Error("EnrollmentTerminationHandler: unhandled exception during termination sequence.", ex); + } + finally + { + try { _signalShutdown(); } + catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: signalShutdown threw: {ex.Message}"); } + } + } + + private DecisionState TryGetCurrentState() + { + try { return _currentStateAccessor(); } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: current state accessor threw: {ex.Message}"); + return null; + } + } + + private AppPackageStateList TryGetPackageStates() + { + try { return _packageStatesAccessor(); } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: package states accessor threw: {ex.Message}"); + return null; + } + } + + private void RunBuildAndLaunchDialog(DecisionState state, EnrollmentTerminatedEventArgs args) + { + try + { + var packages = TryGetPackageStates(); + var status = FinalStatusBuilder.Build(state, args, packages, _agentStartTimeUtc); + SummaryDialogLauncher.WriteAndLaunch(status, _configuration, _stateDirectory, _logger, _dialogExePathOverride); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: FinalStatus/SummaryDialog step failed: {ex.Message}"); + } + } + + private void RunUploadDiagnostics(EnrollmentTerminatedEventArgs args) + { + var mode = _configuration.DiagnosticsUploadMode ?? "Off"; + if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase)) + { + _logger.Debug("EnrollmentTerminationHandler: diagnostics upload skipped (disabled or mode=Off)."); + return; + } + + var enrollmentSucceeded = args.Outcome == EnrollmentTerminationOutcome.Succeeded; + if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded) + { + _logger.Debug("EnrollmentTerminationHandler: diagnostics upload skipped (mode=OnFailure + enrollment succeeded)."); + return; + } + + try + { + var suffix = enrollmentSucceeded ? "success" : "failure"; + var result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult(); + if (result != null && result.Success) + _logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName})."); + else + _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload returned failure: {result?.ErrorCode ?? "null-result"}."); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload threw: {ex.Message}"); + } + } + + private void WriteEnrollmentCompleteMarker(EnrollmentTerminatedEventArgs args) + { + // On WhiteGlove Part 1 exit we keep the session alive for Part 2 — DO NOT write the + // marker (ghost-restart detection would fire + destroy the in-flight session state). + if (args.StageName == SessionStage.WhiteGloveSealed.ToString()) + { + _logger.Info("EnrollmentTerminationHandler: WhiteGlove Part 1 exit — enrollment-complete marker NOT written."); + return; + } + + try + { + Directory.CreateDirectory(_stateDirectory); + var markerPath = Path.Combine(_stateDirectory, "enrollment-complete.marker"); + File.WriteAllText(markerPath, + $"Terminated at {args.TerminatedAtUtc:O} (reason={args.Reason}, outcome={args.Outcome}, stage={args.StageName})."); + _logger.Info($"EnrollmentTerminationHandler: enrollment-complete.marker written to {markerPath}."); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: enrollment-complete.marker write failed: {ex.Message}"); + } + } + + private void RunSelfDestructIfAppropriate(EnrollmentTerminatedEventArgs args) + { + if (!_configuration.SelfDestructOnComplete) + { + _logger.Info("EnrollmentTerminationHandler: SelfDestructOnComplete=false — cleanup skipped."); + return; + } + + if (args.StageName == SessionStage.WhiteGloveSealed.ToString()) + { + _logger.Info("EnrollmentTerminationHandler: WhiteGlove Part 1 exit — cleanup skipped (session resumes on reboot)."); + return; + } + + try + { + var service = _cleanupServiceFactory(); + service.ExecuteSelfDestruct(); + _logger.Info("EnrollmentTerminationHandler: CleanupService.ExecuteSelfDestruct() invoked (fire-and-forget)."); + } + catch (Exception ex) + { + _logger.Error("EnrollmentTerminationHandler: cleanup service invocation threw.", ex); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs new file mode 100644 index 00000000..bac6025b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Termination +{ + /// + /// Wire-format DTO consumed by AutopilotMonitor.SummaryDialog.exe. Plan §4.x M4.6.β. + /// + /// Mirrors AutopilotMonitor.SummaryDialog.Models.FinalStatus — the dialog deserializes + /// this JSON on launch and renders the summary. We cannot reference the dialog project + /// directly (WPF net48 WinExe), so the shape is duplicated here. Keep the JSON property names + /// in lockstep with the dialog — changes here require a corresponding update in the dialog. + /// + /// + public sealed class FinalStatus + { + [JsonProperty("timestamp")] + public string Timestamp { get; set; } + + [JsonProperty("outcome")] + public string Outcome { get; set; } + + [JsonProperty("completionSource")] + public string CompletionSource { get; set; } + + [JsonProperty("helloOutcome")] + public string HelloOutcome { get; set; } + + [JsonProperty("enrollmentType")] + public string EnrollmentType { get; set; } + + [JsonProperty("agentUptimeSeconds")] + public double AgentUptimeSeconds { get; set; } + + [JsonProperty("signalsSeen")] + public List SignalsSeen { get; set; } = new List(); + + [JsonProperty("appSummary")] + public FinalStatusAppSummary AppSummary { get; set; } = new FinalStatusAppSummary(); + + [JsonProperty("packageStatesByPhase")] + public Dictionary> PackageStatesByPhase { get; set; } = + new Dictionary>(); + } + + public sealed class FinalStatusAppSummary + { + [JsonProperty("totalApps")] + public int TotalApps { get; set; } + + [JsonProperty("completedApps")] + public int CompletedApps { get; set; } + + [JsonProperty("errorCount")] + public int ErrorCount { get; set; } + + [JsonProperty("deviceErrors")] + public int DeviceErrors { get; set; } + + [JsonProperty("userErrors")] + public int UserErrors { get; set; } + + [JsonProperty("appsByPhase")] + public Dictionary AppsByPhase { get; set; } = new Dictionary(); + } + + public sealed class FinalStatusPackageInfo + { + [JsonProperty("appName")] + public string AppName { get; set; } + + [JsonProperty("state")] + public string State { get; set; } + + [JsonProperty("isError")] + public bool IsError { get; set; } + + [JsonProperty("isCompleted")] + public bool IsCompleted { get; set; } + + [JsonProperty("targeted")] + public string Targeted { get; set; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs new file mode 100644 index 00000000..6f76262d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs @@ -0,0 +1,145 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Termination +{ + /// + /// Composes a snapshot from the kernel + /// plus the IME tracker's live . Plan §4.x M4.6.β. + /// + /// Pure function: no I/O, no side effects. The writer () + /// takes the built DTO and serialises it to JSON at the path the dialog reads. Splitting + /// builder from writer keeps this testable without touching the file system. + /// + /// + public static class FinalStatusBuilder + { + /// + /// Constructs a from the orchestrator outcome plus a live snapshot + /// of the IME package state list (may be null when the IME host was not started — + /// the summary then reports an empty app section). + /// + public static FinalStatus Build( + DecisionState state, + EnrollmentTerminatedEventArgs terminated, + AppPackageStateList? packageStates, + DateTime agentStartTimeUtc) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + if (terminated == null) throw new ArgumentNullException(nameof(terminated)); + + var uptimeSeconds = Math.Max(0, (terminated.TerminatedAtUtc - agentStartTimeUtc).TotalSeconds); + + var status = new FinalStatus + { + Timestamp = terminated.TerminatedAtUtc.ToString("O"), + Outcome = MapOutcome(terminated.Outcome, state.Stage), + CompletionSource = terminated.Reason.ToString(), + HelloOutcome = state.HelloOutcome?.Value ?? "unknown", + EnrollmentType = state.EnrollmentType?.Reason ?? state.EnrollmentType?.Level.ToString() ?? "unknown", + AgentUptimeSeconds = uptimeSeconds, + SignalsSeen = BuildSignalsSeen(state), + AppSummary = BuildAppSummary(packageStates), + PackageStatesByPhase = BuildPackageStatesByPhase(packageStates), + }; + + return status; + } + + private static string MapOutcome(EnrollmentTerminationOutcome outcome, SessionStage stage) + { + if (stage == SessionStage.WhiteGloveSealed) return "whiteglove_part1"; + if (stage == SessionStage.WhiteGloveCompletedPart2) return "whiteglove_part2"; + switch (outcome) + { + case EnrollmentTerminationOutcome.Succeeded: return "succeeded"; + case EnrollmentTerminationOutcome.Failed: return "failed"; + case EnrollmentTerminationOutcome.TimedOut: return "timed_out"; + default: return "unknown"; + } + } + + private static List BuildSignalsSeen(DecisionState state) + { + var signals = new List(); + if (state.EspFinalExitUtc != null) signals.Add("esp_final_exit"); + if (state.HelloResolvedUtc != null) signals.Add("hello_resolved"); + if (state.DesktopArrivedUtc != null) signals.Add("desktop_arrived"); + if (state.SystemRebootUtc != null) signals.Add("system_reboot"); + if (state.AadJoinedWithUser != null && state.AadJoinedWithUser.Value) signals.Add("aad_user_joined"); + if (state.ShellCoreWhiteGloveSuccessSeen != null && state.ShellCoreWhiteGloveSuccessSeen.Value) + signals.Add("whiteglove_shellcore_success"); + if (state.WhiteGloveSealingPatternSeen != null && state.WhiteGloveSealingPatternSeen.Value) + signals.Add("whiteglove_sealing_pattern"); + if (state.ImeMatchedPatternId != null) signals.Add($"ime_pattern:{state.ImeMatchedPatternId.Value}"); + if (state.UserAadSignInCompleteUtc != null) signals.Add("part2_user_aad_signin"); + if (state.HelloResolvedPart2Utc != null) signals.Add("part2_hello_resolved"); + if (state.DesktopArrivedPart2Utc != null) signals.Add("part2_desktop_arrived"); + if (state.AccountSetupCompletedPart2Utc != null) signals.Add("part2_account_setup_completed"); + return signals; + } + + private static FinalStatusAppSummary BuildAppSummary(AppPackageStateList? packageStates) + { + var summary = new FinalStatusAppSummary(); + if (packageStates == null || packageStates.Count == 0) return summary; + + foreach (var pkg in packageStates) + { + summary.TotalApps++; + if (IsCompleted(pkg.InstallationState)) summary.CompletedApps++; + if (pkg.InstallationState == AppInstallationState.Error) + { + summary.ErrorCount++; + if (pkg.Targeted == AppTargeted.Device) summary.DeviceErrors++; + else if (pkg.Targeted == AppTargeted.User) summary.UserErrors++; + } + + var phaseKey = pkg.Targeted.ToString(); + if (!summary.AppsByPhase.TryGetValue(phaseKey, out var cnt)) cnt = 0; + summary.AppsByPhase[phaseKey] = cnt + 1; + } + + return summary; + } + + private static Dictionary> BuildPackageStatesByPhase( + AppPackageStateList? packageStates) + { + var result = new Dictionary>(); + if (packageStates == null) return result; + + foreach (var pkg in packageStates) + { + var phaseKey = pkg.Targeted.ToString(); + if (!result.TryGetValue(phaseKey, out var bucket)) + { + bucket = new List(); + result[phaseKey] = bucket; + } + + bucket.Add(new FinalStatusPackageInfo + { + AppName = string.IsNullOrEmpty(pkg.Name) ? pkg.Id : pkg.Name, + State = pkg.InstallationState.ToString(), + IsError = pkg.InstallationState == AppInstallationState.Error, + IsCompleted = IsCompleted(pkg.InstallationState), + Targeted = pkg.Targeted.ToString(), + }); + } + + return result; + } + + private static bool IsCompleted(AppInstallationState s) => + s == AppInstallationState.Installed + || s == AppInstallationState.Skipped + || s == AppInstallationState.Postponed + || s == AppInstallationState.Error; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs new file mode 100644 index 00000000..573f55d0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using System.Reflection; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Termination +{ + /// + /// Launches AutopilotMonitor.SummaryDialog.exe in the signed-in user's session + /// with a freshly-written final-status.json. Plan §4.x M4.6.β. + /// + /// Sequence (Legacy parity): + /// + /// + /// Resolve the dialog exe next to the agent binary (Assembly.Location). + /// Write the final-status.json to the agent state directory. + /// Copy exe + dependencies + final-status.json to a per-launch temp directory under + /// %ProgramData%\AutopilotMonitor-Summary\ (the dialog deletes itself via --cleanup). + /// Grant the signed-in user delete permission on that temp directory so the dialog's + /// self-cleanup step works (runs as user). + /// Invoke with the right args. + /// + /// + /// Fire-and-forget: the launcher does not wait for the dialog to exit. Returns false + /// on any failure, never throws. Result is logged by the caller so a dialog failure never + /// blocks the cleanup/shutdown path. + /// + /// + public static class SummaryDialogLauncher + { + internal const string DialogExeName = "AutopilotMonitor.SummaryDialog.exe"; + internal const string DialogConfigName = "AutopilotMonitor.SummaryDialog.exe.config"; + internal const string NewtonsoftDependency = "Newtonsoft.Json.dll"; + internal const string SummaryTempRootEnvVar = "AutopilotMonitor-Summary"; + internal const string FinalStatusFileName = "final-status.json"; + + /// + /// Writes to /final-status.json + /// and launches the dialog. When is + /// false, only the JSON is written (skipping the launch) — tests and backend-consumable + /// audit trail benefit from always having the file on disk. + /// + /// true when both write and launch (if enabled) succeeded. + public static bool WriteAndLaunch( + FinalStatus status, + AgentConfiguration configuration, + string stateDirectory, + AgentLogger logger, + string dialogExePathOverride = null) + { + if (status == null) throw new ArgumentNullException(nameof(status)); + if (configuration == null) throw new ArgumentNullException(nameof(configuration)); + if (string.IsNullOrEmpty(stateDirectory)) throw new ArgumentException("stateDirectory required.", nameof(stateDirectory)); + if (logger == null) throw new ArgumentNullException(nameof(logger)); + + // 1) Write final-status.json always (cheap, useful for audit even if dialog skipped). + string statusPath; + try + { + Directory.CreateDirectory(stateDirectory); + statusPath = Path.Combine(stateDirectory, FinalStatusFileName); + File.WriteAllText(statusPath, JsonConvert.SerializeObject(status, Formatting.Indented)); + logger.Info($"SummaryDialogLauncher: final-status.json written to {statusPath}."); + } + catch (Exception ex) + { + logger.Warning($"SummaryDialogLauncher: final-status.json write failed: {ex.Message}"); + return false; + } + + if (!configuration.ShowEnrollmentSummary) + { + logger.Info("SummaryDialogLauncher: ShowEnrollmentSummary=false — skipping dialog launch."); + return true; + } + + // 2) Resolve dialog exe. + var dialogExe = dialogExePathOverride; + if (string.IsNullOrEmpty(dialogExe)) + { + var agentDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty; + dialogExe = Path.Combine(agentDir, DialogExeName); + } + + if (!File.Exists(dialogExe)) + { + logger.Warning($"SummaryDialogLauncher: dialog exe not found at '{dialogExe}' — skipping launch."); + return false; + } + + // 3) Copy to temp dir (dialog self-deletes via --cleanup). + string tempDir; + string tempExe; + try + { + tempDir = Path.Combine(Path.GetTempPath(), SummaryTempRootEnvVar, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + + tempExe = Path.Combine(tempDir, DialogExeName); + File.Copy(dialogExe, tempExe, overwrite: true); + + var sourceDir = Path.GetDirectoryName(dialogExe) ?? string.Empty; + TryCopy(Path.Combine(sourceDir, DialogConfigName), Path.Combine(tempDir, DialogConfigName)); + TryCopy(Path.Combine(sourceDir, NewtonsoftDependency), Path.Combine(tempDir, NewtonsoftDependency)); + + var tempStatusFile = Path.Combine(tempDir, FinalStatusFileName); + File.Copy(statusPath, tempStatusFile, overwrite: true); + } + catch (Exception ex) + { + logger.Warning($"SummaryDialogLauncher: temp dir setup failed: {ex.Message}"); + return false; + } + + // 4) Compose args. Legacy contract: --status-file --timeout --cleanup [--branding-url ]. + var tempStatusArg = Path.Combine(tempDir, FinalStatusFileName); + var args = $"--status-file \"{tempStatusArg}\" --timeout {configuration.EnrollmentSummaryTimeoutSeconds} --cleanup"; + if (!string.IsNullOrWhiteSpace(configuration.EnrollmentSummaryBrandingImageUrl)) + args += $" --branding-url \"{configuration.EnrollmentSummaryBrandingImageUrl}\""; + + // 5) Launch in user session with retry. + var retrySeconds = configuration.EnrollmentSummaryLaunchRetrySeconds > 0 + ? configuration.EnrollmentSummaryLaunchRetrySeconds + : 120; + + var launched = UserSessionProcessLauncher.LaunchInUserSession(tempExe, args, logger, retrySeconds); + if (launched) + logger.Info($"SummaryDialogLauncher: dialog launched (exe='{tempExe}', retrySeconds={retrySeconds})."); + else + logger.Warning("SummaryDialogLauncher: dialog launch failed or no user session available."); + + return launched; + } + + private static void TryCopy(string source, string destination) + { + try { if (File.Exists(source)) File.Copy(source, destination, overwrite: true); } + catch { /* best-effort — the dialog degrades gracefully on missing config/deps */ } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index 5dad498f..7377ee5e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -4,12 +4,14 @@ using System.Linq; using System.Net.Http; using System.Reflection; using System.Threading; +using System.Threading.Tasks; using AutopilotMonitor.Agent.V2.Core.Configuration; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.Agent.V2.Core.Orchestration; using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Termination; using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; using AutopilotMonitor.DecisionCore.Classifiers; using AutopilotMonitor.DecisionCore.Engine; @@ -334,6 +336,14 @@ namespace AutopilotMonitor.Agent.V2 ? (TimeSpan?)TimeSpan.FromMinutes(agentConfig.AgentMaxLifetimeMinutes) : null; + // Diagnostics upload delegate — wraps the production DiagnosticsPackageService. + // Instantiated lazily + per-invocation (cheap) so we always pick up current config. + var diagnosticsService = new DiagnosticsPackageService(agentConfig, logger, backendApiClient); + Func> uploadDiagnosticsAsync = + (succeeded, suffix) => diagnosticsService.CreateAndUploadAsync(succeeded, suffix); + + var agentStartTimeUtc = DateTime.UtcNow; + using (var orchestrator = new EnrollmentOrchestrator( sessionId: agentConfig.SessionId, tenantId: agentConfig.TenantId, @@ -349,6 +359,56 @@ namespace AutopilotMonitor.Agent.V2 { using (var shutdown = new ManualResetEventSlim(false)) { + // M4.6.β — the peripheral termination sequence (FinalStatus + SummaryDialog + // + diagnostics upload + enrollment-complete.marker + CleanupService) lives + // in Program.cs, not in the kernel. We compose it here and hook it onto the + // orchestrator's typed Terminated event. + var terminationHandler = new EnrollmentTerminationHandler( + configuration: agentConfig, + logger: logger, + stateDirectory: stateSubdir, + agentStartTimeUtc: agentStartTimeUtc, + currentStateAccessor: () => orchestrator.CurrentState, + packageStatesAccessor: () => componentFactory.ImePackageStates, + cleanupServiceFactory: () => new CleanupService(agentConfig, logger), + uploadDiagnosticsAsync: uploadDiagnosticsAsync, + signalShutdown: () => shutdown.Set()); + + // ServerActionDispatcher — ready for M5 backend wiring. For now it has no + // ingest-response consumer (V2 BackendTelemetryUploader's batch endpoint is + // M5 scope). The callbacks are live: RotateConfig refetches remote config; + // RequestDiagnostics triggers the same diagnostics pipeline as enrollment-end; + // TerminateSession routes to the termination handler (reason: server-requested). + var serverActionDispatcher = new ServerActionDispatcher( + configuration: agentConfig, + logger: logger, + rotateConfigAsync: async () => + { + try { var _ = await remoteConfigService.FetchConfigAsync(); return true; } + catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; } + }, + uploadDiagnosticsAsync: async (suffix) => + await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix), + onTerminateRequested: action => + { + logger.Warning($"ServerAction terminate_session received (ruleId={action?.RuleId}) — invoking termination handler."); + // Synthesise a Terminated event as if the kernel fired it. + terminationHandler.Handle( + sender: null, + args: new EnrollmentTerminatedEventArgs( + reason: EnrollmentTerminationReason.DecisionTerminalStage, + outcome: EnrollmentTerminationOutcome.Failed, + stageName: orchestrator.CurrentState?.Stage.ToString(), + terminatedAtUtc: DateTime.UtcNow, + details: $"Server-requested termination: ruleId={action?.RuleId}")); + return Task.CompletedTask; + }, + emitEvent: evt => { orchestrator.EventEmitter.Emit(evt); }); + + // Keep the dispatcher alive for the duration of the run. M5 will plug it into + // the BackendTelemetryUploader response path; M4.6.β wires the API contract. + _ = serverActionDispatcher; + ConsoleCancelEventHandler cancelHandler = (s, e) => { e.Cancel = true; @@ -360,15 +420,10 @@ namespace AutopilotMonitor.Agent.V2 logger.Info("ProcessExit — initiating graceful shutdown."); shutdown.Set(); }; - EventHandler terminatedHandler = (s, e) => - { - logger.Info($"Orchestrator signalled termination (reason={e.Reason}, outcome={e.Outcome})."); - shutdown.Set(); - }; Console.CancelKeyPress += cancelHandler; AppDomain.CurrentDomain.ProcessExit += processExitHandler; - orchestrator.Terminated += terminatedHandler; + orchestrator.Terminated += terminationHandler.Handle; try { @@ -388,7 +443,7 @@ namespace AutopilotMonitor.Agent.V2 { Console.CancelKeyPress -= cancelHandler; AppDomain.CurrentDomain.ProcessExit -= processExitHandler; - orchestrator.Terminated -= terminatedHandler; + orchestrator.Terminated -= terminationHandler.Handle; try { orchestrator.Stop(); } catch (Exception ex) { logger.Error("Orchestrator stop failed.", ex); } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index f64f5d8d..f5c89552 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -27 +32 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 00146f25..b9650fd8 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1066 +1068 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/SessionStageExtensionsTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/SessionStageExtensionsTests.cs new file mode 100644 index 00000000..d51abebd --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/SessionStageExtensionsTests.cs @@ -0,0 +1,35 @@ +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.State +{ + public sealed class SessionStageExtensionsTests + { + [Theory] + [InlineData(SessionStage.Completed, true)] + [InlineData(SessionStage.Failed, true)] + [InlineData(SessionStage.WhiteGloveSealed, true)] + [InlineData(SessionStage.WhiteGloveCompletedPart2, true)] + [InlineData(SessionStage.Unknown, false)] + [InlineData(SessionStage.SessionStarted, false)] + [InlineData(SessionStage.AwaitingHello, false)] + [InlineData(SessionStage.EspAccountSetup, false)] + [InlineData(SessionStage.WhiteGloveCandidate, false)] + [InlineData(SessionStage.WhiteGloveAwaitingUserSignIn, false)] + public void IsTerminal_classifies_stages_correctly(SessionStage stage, bool expected) + { + Assert.Equal(expected, stage.IsTerminal()); + } + + [Theory] + [InlineData(SessionStage.WhiteGloveSealed, true)] + [InlineData(SessionStage.Completed, false)] + [InlineData(SessionStage.Failed, false)] + [InlineData(SessionStage.WhiteGloveCompletedPart2, false)] + [InlineData(SessionStage.WhiteGloveCandidate, false)] + public void IsPauseBeforePart2_only_true_for_WhiteGloveSealed(SessionStage stage, bool expected) + { + Assert.Equal(expected, stage.IsPauseBeforePart2()); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStageExtensions.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStageExtensions.cs new file mode 100644 index 00000000..61db7484 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStageExtensions.cs @@ -0,0 +1,42 @@ +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Classification helpers for . Plan §4.x M4.6.β. + /// + public static class SessionStageExtensions + { + /// + /// true when the stage represents a session-terminal state — the orchestrator + /// must stop, peripheral termination handlers (CleanupService, SummaryDialog, + /// DiagnosticsPackageService) run, and no further signals are expected. + /// + /// is terminal for Part 1 only — the agent + /// exits but the session is paused (Part 2 resumes after reboot). The distinction + /// matters for CleanupService: Part-1 exit must NOT self-destruct; Part-2 exit + /// () and the classic terminals + /// ( / ) do. + /// + /// + public static bool IsTerminal(this SessionStage stage) + { + switch (stage) + { + case SessionStage.Completed: + case SessionStage.Failed: + case SessionStage.WhiteGloveSealed: + case SessionStage.WhiteGloveCompletedPart2: + return true; + default: + return false; + } + } + + /// + /// true when the stage terminates the current agent run but leaves the session + /// paused for a future restart (WhiteGlove Part 1 → post-reboot Part 2 resume). Caller + /// (typically Program.cs) must skip CleanupService.ExecuteSelfDestruct in this case. + /// + public static bool IsPauseBeforePart2(this SessionStage stage) + => stage == SessionStage.WhiteGloveSealed; + } +}