From b243706629d50dd2da90ac69f4e0f2a0c2e21892 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 25 Apr 2026 17:59:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(v2):=20882fef64=20PR1=20=E2=80=94=20Company?= =?UTF-8?q?=20Portal=20UI,=20diagnostics=20ZIP=20State/Spool,=20agent=5Fsh?= =?UTF-8?q?utting=5Fdown=20V1=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DownloadProgress.tsx: exempt app_download_started from <1024B drop so WinGet/Store apps (Company Portal etc.) appear in the UI; filter logic extracted to pure helpers in lib/downloadProgressFilters.ts for vitest coverage. - DiagnosticsPackageService: ZIP now also packages AgentState/ (snapshot, journal, signal-log, ime-tracker-state, enrollment-complete.marker, final-status, whiteglove-backfill-state + .quarantine), AgentSpool/ (pending uploads + cursor), and AgentMarkers/ (top-level whiteglove.complete + clean-exit). BuildArchiveBytes extracted as internal seam so xUnit asserts ZIP contents without HTTP. - EnrollmentTerminationHandler: agent_shutting_down emission moved to top of Handle() so it fires unconditionally — including SelfDestruct=false dev VMs and WhiteGlove Part-1 exits. Data carries reason/outcome/stage/uptimeMinutes/agentVersion. Tests: V2.Core 711 (+12), DecisionCore 220, Functions 736, vitest 179 (+11), solution build 0/0. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...utopilotMonitor.Agent.V2.Core.Tests.csproj | 2 + .../Runtime/DiagnosticsPackageServiceTests.cs | 194 ++++++++++++++++++ .../EnrollmentTerminationHandlerTests.cs | 68 +++++- .../Runtime/DiagnosticsPackageService.cs | 179 +++++++++++----- .../EnrollmentTerminationHandler.cs | 62 +++--- .../AutopilotMonitor.Agent.V2/Program.cs | 3 +- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../components/DownloadProgress.tsx | 29 +-- .../__tests__/downloadProgressFilters.test.ts | 68 ++++++ .../lib/downloadProgressFilters.ts | 45 ++++ 11 files changed, 553 insertions(+), 101 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/DiagnosticsPackageServiceTests.cs create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/downloadProgressFilters.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/downloadProgressFilters.ts diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj index 46dad5b2..fc1fe81e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj @@ -13,6 +13,8 @@ + + diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/DiagnosticsPackageServiceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/DiagnosticsPackageServiceTests.cs new file mode 100644 index 00000000..f5d7060b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/DiagnosticsPackageServiceTests.cs @@ -0,0 +1,194 @@ +#nullable enable +using System.IO; +using System.IO.Compression; +using System.Linq; +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.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime +{ + /// + /// PR1-B: diagnostics archive must include AgentState/, AgentSpool/, and top-level + /// completion markers (whiteglove.complete, clean-exit) — V1 sessions had only AgentLogs/ + /// and ImeLogs/, which left forensics blind to decision-engine state and pending uploads. + /// + public sealed class DiagnosticsPackageServiceTests + { + private static AgentConfiguration Cfg(string sessionId = "S1") => new AgentConfiguration + { + SessionId = sessionId, + TenantId = "T1", + ApiBaseUrl = "http://localhost", + }; + + private sealed class Rig : System.IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public TempDirectory LogsTmp { get; } = new TempDirectory(); + public TempDirectory ImeTmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + + public string DataFolder => Tmp.Path; + public string StateFolder { get; } + public string SpoolFolder { get; } + + public Rig() + { + Logger = new AgentLogger(LogsTmp.Path); + StateFolder = Path.Combine(Tmp.Path, "State"); + SpoolFolder = Path.Combine(Tmp.Path, "Spool"); + Directory.CreateDirectory(StateFolder); + Directory.CreateDirectory(SpoolFolder); + } + + public DiagnosticsPackageService Build() + { + // BackendApiClient is required by the public ctor but BuildArchiveBytes + // never touches it — pass a real instance configured against an unreachable + // host, since BuildArchiveBytes returns before any HTTP traffic happens. + var apiClient = new BackendApiClient("http://localhost", Cfg(), Logger); + return new DiagnosticsPackageService( + Cfg(), + Logger, + apiClient, + agentLogFolderOverride: LogsTmp.Path, + imeLogFolderOverride: ImeTmp.Path, + agentStateFolderOverride: StateFolder, + agentSpoolFolderOverride: SpoolFolder, + agentDataFolderOverride: DataFolder); + } + + public void Dispose() + { + Tmp.Dispose(); + LogsTmp.Dispose(); + ImeTmp.Dispose(); + } + } + + [Fact] + public void BuildArchiveBytes_includes_state_files_under_AgentState_prefix() + { + using var rig = new Rig(); + File.WriteAllText(Path.Combine(rig.StateFolder, "snapshot.json"), "{\"stage\":\"Completed\"}"); + File.WriteAllText(Path.Combine(rig.StateFolder, "journal.jsonl"), "{\"ord\":1}\n"); + File.WriteAllText(Path.Combine(rig.StateFolder, "signal-log.jsonl"), "{\"ord\":1}\n"); + File.WriteAllText(Path.Combine(rig.StateFolder, "ime-tracker-state.json"), "{}"); + File.WriteAllText(Path.Combine(rig.StateFolder, "enrollment-complete.marker"), ""); + File.WriteAllText(Path.Combine(rig.StateFolder, "final-status.json"), "{\"outcome\":\"Succeeded\"}"); + + var bytes = rig.Build().BuildArchiveBytes(enrollmentSucceeded: true); + + var entries = ZipEntryNames(bytes); + Assert.Contains("AgentState/snapshot.json", entries); + Assert.Contains("AgentState/journal.jsonl", entries); + Assert.Contains("AgentState/signal-log.jsonl", entries); + Assert.Contains("AgentState/ime-tracker-state.json", entries); + Assert.Contains("AgentState/enrollment-complete.marker", entries); + Assert.Contains("AgentState/final-status.json", entries); + } + + [Fact] + public void BuildArchiveBytes_includes_spool_files_under_AgentSpool_prefix() + { + using var rig = new Rig(); + File.WriteAllText(Path.Combine(rig.SpoolFolder, "spool.jsonl"), "{\"itemId\":\"a\"}\n"); + File.WriteAllText(Path.Combine(rig.SpoolFolder, "upload-cursor.json"), "{\"lastItemId\":\"a\"}"); + + var bytes = rig.Build().BuildArchiveBytes(enrollmentSucceeded: true); + + var entries = ZipEntryNames(bytes); + Assert.Contains("AgentSpool/spool.jsonl", entries); + Assert.Contains("AgentSpool/upload-cursor.json", entries); + } + + [Fact] + public void BuildArchiveBytes_includes_top_level_markers_under_AgentMarkers_prefix() + { + using var rig = new Rig(); + // Top-level markers in the data folder (NOT under State/). + File.WriteAllText(Path.Combine(rig.DataFolder, "whiteglove.complete"), ""); + File.WriteAllText(Path.Combine(rig.DataFolder, "agent_clean_exit.marker"), ""); + + var bytes = rig.Build().BuildArchiveBytes(enrollmentSucceeded: true); + + var entries = ZipEntryNames(bytes); + Assert.Contains("AgentMarkers/whiteglove.complete", entries); + Assert.Contains("AgentMarkers/agent_clean_exit.marker", entries); + } + + [Fact] + public void BuildArchiveBytes_excludes_top_level_session_id_and_bootstrap_config() + { + // session.id / bootstrap.json / await-enrollment.json live in the same data folder + // but are NOT forensic state — keep them out of the archive. + using var rig = new Rig(); + File.WriteAllText(Path.Combine(rig.DataFolder, "session.id"), "S1"); + File.WriteAllText(Path.Combine(rig.DataFolder, "bootstrap.json"), "{}"); + + var bytes = rig.Build().BuildArchiveBytes(enrollmentSucceeded: true); + + var entries = ZipEntryNames(bytes); + Assert.DoesNotContain(entries, e => e.EndsWith("session.id")); + Assert.DoesNotContain(entries, e => e.EndsWith("bootstrap.json")); + } + + [Fact] + public void BuildArchiveBytes_includes_state_subfolders_when_quarantine_present() + { + using var rig = new Rig(); + var quarantine = Path.Combine(rig.StateFolder, ".quarantine"); + Directory.CreateDirectory(quarantine); + File.WriteAllText(Path.Combine(quarantine, "corrupt.jsonl"), "garbage"); + + var bytes = rig.Build().BuildArchiveBytes(enrollmentSucceeded: false); + + var entries = ZipEntryNames(bytes); + Assert.Contains(entries, e => e.StartsWith("AgentState/.quarantine/") && e.EndsWith("corrupt.jsonl")); + } + + [Fact] + public void BuildArchiveBytes_excludes_spool_subfolders() + { + // Spool may grow auxiliary subfolders later — current archive policy is to keep + // the spool section flat (only what is pending upload right now). + using var rig = new Rig(); + var sub = Path.Combine(rig.SpoolFolder, "archive"); + Directory.CreateDirectory(sub); + File.WriteAllText(Path.Combine(sub, "old.jsonl"), "{}"); + + var bytes = rig.Build().BuildArchiveBytes(enrollmentSucceeded: false); + + var entries = ZipEntryNames(bytes); + Assert.DoesNotContain(entries, e => e.StartsWith("AgentSpool/archive/")); + } + + [Fact] + public void BuildArchiveBytes_handles_missing_state_folder_gracefully() + { + using var rig = new Rig(); + Directory.Delete(rig.StateFolder, recursive: true); + Directory.Delete(rig.SpoolFolder, recursive: true); + + // Should not throw; archive simply has no AgentState/AgentSpool entries. + var bytes = rig.Build().BuildArchiveBytes(enrollmentSucceeded: true); + Assert.NotEmpty(bytes); + + var entries = ZipEntryNames(bytes); + Assert.Contains("sessioninfo.txt", entries); + Assert.DoesNotContain(entries, e => e.StartsWith("AgentState/")); + Assert.DoesNotContain(entries, e => e.StartsWith("AgentSpool/")); + } + + private static string[] ZipEntryNames(byte[] zipBytes) + { + using var ms = new MemoryStream(zipBytes); + using var archive = new ZipArchive(ms, ZipArchiveMode.Read); + return archive.Entries.Select(e => e.FullName).ToArray(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs index d17bffb4..4798231e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs @@ -128,7 +128,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination public IReadOnlyDictionary? AppTimingsOverride { get; set; } - public EnrollmentTerminationHandler Build(AgentConfiguration? config = null) + public EnrollmentTerminationHandler Build(AgentConfiguration? config = null) => + BuildCore(config, agentVersion: null); + + public EnrollmentTerminationHandler BuildWithVersion(string agentVersion) => + BuildCore(config: null, agentVersion: agentVersion); + + private EnrollmentTerminationHandler BuildCore(AgentConfiguration? config, string? agentVersion) { config ??= BuildConfig(); return new EnrollmentTerminationHandler( @@ -159,7 +165,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination // the dedicated V1-parity tests below which opt back in via their own Rig. lateEventGracePeriod: TimeSpan.Zero, spoolDrainPeriod: TimeSpan.Zero, - appTimingsAccessor: () => AppTimingsOverride ?? new Dictionary()); + appTimingsAccessor: () => AppTimingsOverride ?? new Dictionary(), + agentVersion: agentVersion); } public void Dispose() => Tmp.Dispose(); @@ -410,8 +417,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination } [Fact] - public void Handle_skips_agent_shutting_down_when_self_destruct_disabled() + public void Handle_emits_agent_shutting_down_when_self_destruct_disabled() { + // PR1-C / V1 parity: dev/test VMs run with SelfDestruct=false but the agent still + // ends after termination — the timeline must show that. using var rig = new Rig(); rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); var cfg = rig.BuildConfig(selfDestruct: false); @@ -419,25 +428,64 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination rig.Build(cfg).Handle(sender: null!, Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); - // agent_shutting_down is a CleanupService acknowledgement — if cleanup doesn't run, - // the event must not fire either (the agent isn't really shutting down via self-destruct). Assert.Equal(0, rig.CleanupService.Invocations); - Assert.DoesNotContain(Constants.EventTypes.AgentShuttingDown, rig.EmittedEventTypes); + Assert.Contains(Constants.EventTypes.AgentShuttingDown, rig.EmittedEventTypes); + + var data = rig.DataOf(Constants.EventTypes.AgentShuttingDown); + Assert.NotNull(data); + Assert.Equal(EnrollmentTerminationReason.DecisionTerminalStage.ToString(), (string)data!["reason"]); } [Fact] - public void Handle_skips_agent_shutting_down_on_whiteglove_part1_exit() + public void Handle_emits_agent_shutting_down_on_whiteglove_part1_exit() { + // PR1-C: the running agent process ends on Part 1 just like on a normal terminal — + // emit the lifecycle marker so the timeline reflects the handoff. Stage payload + // tells the backend this was the WG sealing branch. 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)); - // WhiteGlove Part-1 exit keeps the agent alive for Part 2 — no self-destruct, so no - // agent_shutting_down acknowledgement either. + // Cleanup still must NOT run on WG Part 1 (the session resumes on next boot). Assert.Equal(0, rig.CleanupService.Invocations); - Assert.DoesNotContain(Constants.EventTypes.AgentShuttingDown, rig.EmittedEventTypes); + Assert.Contains(Constants.EventTypes.AgentShuttingDown, rig.EmittedEventTypes); + + var data = rig.DataOf(Constants.EventTypes.AgentShuttingDown); + Assert.NotNull(data); + Assert.Equal(SessionStage.WhiteGloveSealed.ToString(), (string)data!["stage"]); + } + + [Fact] + public void Handle_propagates_max_lifetime_reason_in_agent_shutting_down_data() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.AwaitingHello }.Build(); + + rig.Build().Handle(sender: null!, + Args(EnrollmentTerminationReason.MaxLifetimeExceeded, EnrollmentTerminationOutcome.Failed, SessionStage.AwaitingHello)); + + var data = rig.DataOf(Constants.EventTypes.AgentShuttingDown); + Assert.NotNull(data); + Assert.Equal(EnrollmentTerminationReason.MaxLifetimeExceeded.ToString(), (string)data!["reason"]); + Assert.Equal(EnrollmentTerminationOutcome.Failed.ToString(), (string)data["outcome"]); + } + + [Fact] + public void Handle_includes_uptime_and_agent_version_in_agent_shutting_down() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + + rig.BuildWithVersion("9.9.9").Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + var data = rig.DataOf(Constants.EventTypes.AgentShuttingDown); + Assert.NotNull(data); + Assert.Equal("9.9.9", (string)data!["agentVersion"]); + Assert.True(data.ContainsKey("uptimeMinutes")); + Assert.IsType(data["uptimeMinutes"]); } // ============================================================================ diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs index 197b3c2b..31aaa502 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs @@ -49,12 +49,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime private readonly BackendApiClient _apiClient; private readonly HttpClient _httpClient; - private static readonly string ImeLogFolder = + private readonly string _imeLogFolder; + private readonly string _agentLogFolder; + private readonly string _agentStateFolder; + private readonly string _agentSpoolFolder; + private readonly string _agentDataFolder; + + private static readonly string DefaultImeLogFolder = Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs"); - private static readonly string AgentLogFolder = + private static readonly string DefaultAgentLogFolder = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); + private static readonly string DefaultAgentStateFolder = + Environment.ExpandEnvironmentVariables(Constants.StateDirectory); + + private static readonly string DefaultAgentSpoolFolder = + Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory); + + private static readonly string DefaultAgentDataFolder = + Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor"); + /// /// File extensions collected from built-in log folders (Agent + IME). /// Covers standard logs, structured data, traces, event logs, and diagnostics archives. @@ -65,12 +80,60 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime "*.etl", "*.evtx", "*.xml", "*.csv", "*.cab" }; + /// + /// Patterns collected from the agent state folder. Adds completion markers on top of + /// the standard log patterns so files like enrollment-complete.marker and + /// whiteglove-backfill-state.json reach the diagnostics archive. + /// + private static readonly string[] StateFilePatterns = new[] + { + "*.log", "*.txt", "*.json", "*.jsonl", + "*.etl", "*.evtx", "*.xml", "*.csv", "*.cab", + "*.complete", "*.marker" + }; + + /// + /// Marker patterns from the top-level data directory. Kept tight on purpose: only + /// completion/exit markers, no session.id / bootstrap.json (those are config, not + /// forensic state). + /// + private static readonly string[] RootMarkerPatterns = new[] { "*.complete", "*.marker" }; + + /// + /// Telemetry spool: only the JSON-shaped files. The spool folder may also contain + /// quarantine subfolders, so subfolders are excluded to keep the archive focused + /// on what is actually pending upload. + /// + private static readonly string[] SpoolFilePatterns = new[] { "*.jsonl", "*.json" }; + public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient) + : this(configuration, logger, apiClient, null, null, null, null, null) + { + } + + // Test seam: allows xUnit fixtures to redirect log/state/spool/data folders to a + // temp dir without touching real %ProgramData% paths. Production callers always go + // through the public ctor. + internal DiagnosticsPackageService( + AgentConfiguration configuration, + AgentLogger logger, + BackendApiClient apiClient, + string agentLogFolderOverride, + string imeLogFolderOverride, + string agentStateFolderOverride, + string agentSpoolFolderOverride, + string agentDataFolderOverride) { _configuration = configuration; _logger = logger; _apiClient = apiClient; _httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + + _agentLogFolder = agentLogFolderOverride ?? DefaultAgentLogFolder; + _imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder; + _agentStateFolder = agentStateFolderOverride ?? DefaultAgentStateFolder; + _agentSpoolFolder = agentSpoolFolderOverride ?? DefaultAgentSpoolFolder; + _agentDataFolder = agentDataFolderOverride ?? DefaultAgentDataFolder; } /// @@ -119,51 +182,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime var suffix = string.IsNullOrEmpty(fileNameSuffix) ? "" : $"-{fileNameSuffix}"; var zipFileName = $"AgentDiagnostics-{_configuration.SessionId}-{timestamp}{suffix}.zip"; - byte[] zipBytes; - using (var ms = new MemoryStream()) - { - using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) - { - // 1. sessioninfo.txt - AddSessionInfo(archive, enrollmentSucceeded); - - // 2. Agent logs - foreach (var pattern in LogFilePatterns) - AddLogFiles(archive, AgentLogFolder, "AgentLogs", pattern); - - // 3. IME logs (all relevant files in the IME log folder) - foreach (var pattern in LogFilePatterns) - AddLogFiles(archive, ImeLogFolder, "ImeLogs", pattern); - - // 4. Configured additional log paths (global + tenant, validated by guards) - foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List()) - { - // Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception - var userProfilePath = UserProfileResolver.ContainsUserProfileToken(entry.Path) - ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; - - if (!DiagnosticsPathGuards.IsDiagnosticsPathAllowed(entry.Path, _configuration.UnrestrictedMode, userProfilePath)) - { - _logger.Warning($"Diagnostics path blocked by guard: {entry.Path}"); - continue; - } - var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path); - if (expandedPath == null) - { - _logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}"); - continue; - } - var folder = Path.GetDirectoryName(expandedPath); - var pattern = Path.GetFileName(expandedPath); - if (string.IsNullOrEmpty(folder)) continue; - if (string.IsNullOrEmpty(pattern) || !pattern.Contains(".")) pattern = "*"; - var zipFolder = $"AdditionalLogs/{Path.GetFileName(folder)}"; - AddLogFiles(archive, folder, zipFolder, pattern, entry.IncludeSubfolders); - } - } - - zipBytes = ms.ToArray(); - } + var zipBytes = BuildArchiveBytes(enrollmentSucceeded); _logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)"); @@ -223,6 +242,72 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime } } + // Builds the diagnostics ZIP body in-memory. Extracted from CreateAndUploadAsync so + // tests can assert archive contents without going through the upload path. + internal virtual byte[] BuildArchiveBytes(bool enrollmentSucceeded) + { + using (var ms = new MemoryStream()) + { + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + // 1. sessioninfo.txt + AddSessionInfo(archive, enrollmentSucceeded); + + // 2. Agent logs + foreach (var pattern in LogFilePatterns) + AddLogFiles(archive, _agentLogFolder, "AgentLogs", pattern); + + // 3. IME logs (all relevant files in the IME log folder) + foreach (var pattern in LogFilePatterns) + AddLogFiles(archive, _imeLogFolder, "ImeLogs", pattern); + + // 4. Agent state: snapshot, journal, signal-log, ime-tracker-state, + // enrollment-complete.marker, final-status.json, whiteglove-backfill-state.json. + // Recursive to include `.quarantine` subfolders if present. + foreach (var pattern in StateFilePatterns) + AddLogFiles(archive, _agentStateFolder, "AgentState", pattern, includeSubfolders: true); + + // 5. Telemetry spool — pending uploads + upload cursor (forensic: what didn't ship?) + foreach (var pattern in SpoolFilePatterns) + AddLogFiles(archive, _agentSpoolFolder, "AgentSpool", pattern, includeSubfolders: false); + + // 6. Top-level markers (whiteglove.complete, clean-exit marker) — Part-1/Part-2 + // bridge + ghost-restart guard. Top-directory only to avoid re-zipping + // State/Spool/Logs/Agent which already have their own sections. + foreach (var pattern in RootMarkerPatterns) + AddLogFiles(archive, _agentDataFolder, "AgentMarkers", pattern, includeSubfolders: false); + + // 7. Configured additional log paths (global + tenant, validated by guards) + foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List()) + { + // Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception + var userProfilePath = UserProfileResolver.ContainsUserProfileToken(entry.Path) + ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; + + if (!DiagnosticsPathGuards.IsDiagnosticsPathAllowed(entry.Path, _configuration.UnrestrictedMode, userProfilePath)) + { + _logger.Warning($"Diagnostics path blocked by guard: {entry.Path}"); + continue; + } + var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path); + if (expandedPath == null) + { + _logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}"); + continue; + } + var folder = Path.GetDirectoryName(expandedPath); + var pattern = Path.GetFileName(expandedPath); + if (string.IsNullOrEmpty(folder)) continue; + if (string.IsNullOrEmpty(pattern) || !pattern.Contains(".")) pattern = "*"; + var zipFolder = $"AdditionalLogs/{Path.GetFileName(folder)}"; + AddLogFiles(archive, folder, zipFolder, pattern, entry.IncludeSubfolders); + } + } + + return ms.ToArray(); + } + } + private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded) { var sb = new StringBuilder(); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs index 2fab91cb..cf77a753 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs @@ -72,6 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination private readonly Action _triggerReboot; private readonly TimeSpan _lateEventGracePeriod; private readonly TimeSpan _spoolDrainPeriod; + private readonly string _agentVersion; private int _handled; @@ -92,7 +93,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination Action triggerReboot = null, TimeSpan? lateEventGracePeriod = null, TimeSpan? spoolDrainPeriod = null, - Func> appTimingsAccessor = null) + Func> appTimingsAccessor = null, + string agentVersion = null) { _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -113,6 +115,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination _triggerReboot = triggerReboot ?? DefaultTriggerReboot; _lateEventGracePeriod = lateEventGracePeriod ?? DefaultLateEventGrace; _spoolDrainPeriod = spoolDrainPeriod ?? DefaultSpoolDrain; + _agentVersion = agentVersion ?? string.Empty; } /// @@ -136,6 +139,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination // AnalyzerManager is optional (null in tests where analyzers are out-of-scope). RunShutdownAnalyzers(args); + // PR1-C / V1 parity — emit agent_shutting_down unconditionally so the timeline + // always sees the agent accept termination. Previously gated on + // SelfDestructOnComplete, which left dev/test VMs (where SelfDestruct=false) + // without the event entirely. Fires for WhiteGlove Part 1 too — that branch + // also ends the running agent process. + EmitAgentShuttingDown(args); + if (state == null) { _logger.Warning("EnrollmentTerminationHandler: current state unavailable — skipping FinalStatus + SummaryDialog."); @@ -553,28 +563,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination return; } - // Plan §6.2 terminate-hygiene — post an explicit agent_shutting_down acknowledgement BEFORE - // the CleanupService tears the agent down. This guarantees the backend sees the agent - // accept the termination (so it can stop re-queuing terminate_session) even when the - // cleanup script races ahead of the final telemetry flush. - EmitEventSafe(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = Constants.EventTypes.AgentShuttingDown, - Severity = EventSeverity.Info, - Source = "EnrollmentTerminationHandler", - Phase = EnrollmentPhase.Unknown, - Message = "Agent accepted termination — running CleanupService.", - Data = new Dictionary - { - { "reason", args.Reason.ToString() }, - { "outcome", args.Outcome.ToString() }, - { "stage", args.StageName ?? string.Empty }, - }, - ImmediateUpload = true, - }); - try { var service = _cleanupServiceFactory(); @@ -587,6 +575,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination } } + // V1-parity acknowledgement that the agent has accepted termination. Carries enough + // context for the backend to correlate the shutdown with its trigger (reason/outcome/ + // stage) and for diagnostics to show how long the agent ran. Always emitted, regardless + // of whether self-destruct cleanup actually runs afterwards. + private void EmitAgentShuttingDown(EnrollmentTerminatedEventArgs args) + { + var uptimeMinutes = Math.Round((DateTime.UtcNow - _agentStartTimeUtc).TotalMinutes, 1); + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = Constants.EventTypes.AgentShuttingDown, + Severity = EventSeverity.Info, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = $"Agent shutting down (reason={args.Reason}, outcome={args.Outcome}).", + Data = new Dictionary + { + { "reason", args.Reason.ToString() }, + { "outcome", args.Outcome.ToString() }, + { "stage", args.StageName ?? string.Empty }, + { "uptimeMinutes", uptimeMinutes }, + { "agentVersion", _agentVersion }, + }, + ImmediateUpload = true, + }); + } + private void TrySaveWhiteGloveComplete() { if (_sessionPersistence == null) diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index aec6c615..0f0a87ca 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -703,7 +703,8 @@ namespace AutopilotMonitor.Agent.V2 sessionPersistence: sessionPersistence, // Plan §5 Fix 4 — per-app timing snapshot for FinalStatusBuilder + // app_tracking_summary emission. Null-safe via the handler's default. - appTimingsAccessor: () => componentFactory.ImeAppTimings); + appTimingsAccessor: () => componentFactory.ImeAppTimings, + agentVersion: GetAgentVersion()); // Single-rail refactor (plan §5.3) — ServerActionDispatcher emits through // the same InformationalEventPost. Constructed inside this hook so diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index d1e85f89..ec6cab01 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -326 +331 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index d94081a6..afe5b3bb 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1160 +1161 diff --git a/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx b/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx index 6afe9ed6..18ba8c96 100644 --- a/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx +++ b/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import { shouldSkipLowBytesTotal, shouldSkipNoActivity } from "@/lib/downloadProgressFilters"; interface DownloadEvent { timestamp: string; @@ -138,24 +139,16 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr || (bytesTotal > 0 && bytesDownloaded >= bytesTotal) || (progressPercent >= 100 && bytesDownloaded === 0 && bytesTotal === 0); - // Skip if bytesTotal is too small (< 1 KB) AND not explicitly completed/failed/skipped - if (bytesTotal > 0 && bytesTotal < 1024 && status !== "completed" && status !== "failed" && !isSkippedEvent) { - continue; - } - - // Skip if no download activity yet (0 bytes downloaded, not completed/failed/skipped) - // This makes downloads appear dynamically as they start - if ( - bytesDownloaded === 0 && - bytesTotal === 0 && - status !== "completed" && - status !== "failed" && - !isDownloadStartEvent && - !isSkippedEvent && - progressPercent < 100 - ) { - continue; - } + const filterInput = { + bytesDownloaded: isNaN(bytesDownloaded) ? 0 : bytesDownloaded, + bytesTotal: isNaN(bytesTotal) ? 0 : bytesTotal, + status, + isDownloadStartEvent, + isSkippedEvent, + progressPercent: isNaN(progressPercent) ? 0 : progressPercent, + }; + if (shouldSkipLowBytesTotal(filterInput)) continue; + if (shouldSkipNoActivity(filterInput)) continue; const existing = downloadMap.get(appName); // Max plausible rate: 250 MB/s (~2 Gbit/s, generous for any real client connection) diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/downloadProgressFilters.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/downloadProgressFilters.test.ts new file mode 100644 index 00000000..07854768 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/downloadProgressFilters.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { shouldSkipLowBytesTotal, shouldSkipNoActivity } from "../downloadProgressFilters"; + +const base = { + bytesDownloaded: 0, + bytesTotal: 0, + status: "", + isDownloadStartEvent: false, + isSkippedEvent: false, + progressPercent: 0, +}; + +describe("shouldSkipLowBytesTotal", () => { + it("keeps app_download_started events with bytesTotal=100 (WinGet/Store apps like Company Portal)", () => { + // V2 emits bytesTotal=100 for WinGet apps because they have no real byte progress. + // Without exempting the start event, such apps never reach the downloads list. + const winGetStart = { ...base, bytesTotal: 100, isDownloadStartEvent: true }; + expect(shouldSkipLowBytesTotal(winGetStart)).toBe(false); + }); + + it("drops follow-up download_progress events with bytesTotal=100 (no start flag)", () => { + // Follow-up events for WinGet apps still get filtered: the entry stays at the + // values captured from app_download_started. This matches V1 semantics. + const winGetProgress = { ...base, bytesTotal: 100, isDownloadStartEvent: false }; + expect(shouldSkipLowBytesTotal(winGetProgress)).toBe(true); + }); + + it("keeps events with bytesTotal >= 1024", () => { + expect(shouldSkipLowBytesTotal({ ...base, bytesTotal: 1024 })).toBe(false); + expect(shouldSkipLowBytesTotal({ ...base, bytesTotal: 50_000_000 })).toBe(false); + }); + + it("keeps explicitly completed events even with tiny bytesTotal", () => { + expect(shouldSkipLowBytesTotal({ ...base, bytesTotal: 100, status: "completed" })).toBe(false); + expect(shouldSkipLowBytesTotal({ ...base, bytesTotal: 100, status: "failed" })).toBe(false); + }); + + it("keeps skipped events even with tiny bytesTotal", () => { + expect(shouldSkipLowBytesTotal({ ...base, bytesTotal: 100, isSkippedEvent: true })).toBe(false); + }); + + it("keeps events with bytesTotal=0 (handled by the no-activity filter instead)", () => { + expect(shouldSkipLowBytesTotal({ ...base, bytesTotal: 0 })).toBe(false); + }); +}); + +describe("shouldSkipNoActivity", () => { + it("drops zero-activity events that are neither start nor skip", () => { + expect(shouldSkipNoActivity(base)).toBe(true); + }); + + it("keeps app_download_started even with zero bytes", () => { + expect(shouldSkipNoActivity({ ...base, isDownloadStartEvent: true })).toBe(false); + }); + + it("keeps completed events with zero bytes", () => { + expect(shouldSkipNoActivity({ ...base, status: "completed" })).toBe(false); + }); + + it("keeps events at 100% progress even with zero bytes", () => { + expect(shouldSkipNoActivity({ ...base, progressPercent: 100 })).toBe(false); + }); + + it("keeps events with any byte activity", () => { + expect(shouldSkipNoActivity({ ...base, bytesDownloaded: 1 })).toBe(false); + expect(shouldSkipNoActivity({ ...base, bytesTotal: 1 })).toBe(false); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/downloadProgressFilters.ts b/src/Web/autopilot-monitor-web/lib/downloadProgressFilters.ts new file mode 100644 index 00000000..546e64ea --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/downloadProgressFilters.ts @@ -0,0 +1,45 @@ +// Pure predicates extracted from `components/DownloadProgress.tsx` so the filter +// behaviour can be unit-tested without a DOM/JSX harness. + +export interface DownloadFilterInput { + bytesDownloaded: number; + bytesTotal: number; + status: string; + isDownloadStartEvent: boolean; + isSkippedEvent: boolean; + progressPercent: number; +} + +// Returns true if the event should be dropped because its declared total size is +// below 1 KB and the event isn't a terminal/skip/start signal. +// +// V2 emits bytesTotal=100 for WinGet/Store apps (e.g. Company Portal) which have +// no DO byte progress. Without exempting `app_download_started`, those apps would +// never appear in the UI — V1 had emitted bytesTotal=0 and slipped through the +// next filter instead. +export function shouldSkipLowBytesTotal(input: DownloadFilterInput): boolean { + const { bytesTotal, status, isSkippedEvent, isDownloadStartEvent } = input; + return ( + bytesTotal > 0 && + bytesTotal < 1024 && + status !== "completed" && + status !== "failed" && + !isSkippedEvent && + !isDownloadStartEvent + ); +} + +// Returns true if the event has no activity (zero bytes, not started, not progressed) +// and should not yet appear as a download row. +export function shouldSkipNoActivity(input: DownloadFilterInput): boolean { + const { bytesDownloaded, bytesTotal, status, isDownloadStartEvent, isSkippedEvent, progressPercent } = input; + return ( + bytesDownloaded === 0 && + bytesTotal === 0 && + status !== "completed" && + status !== "failed" && + !isDownloadStartEvent && + !isSkippedEvent && + progressPercent < 100 + ); +}