fix(v2): 882fef64 PR1 — Company Portal UI, diagnostics ZIP State/Spool, agent_shutting_down V1 parity

- 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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-25 17:59:00 +02:00
parent 8b7a5f509e
commit b243706629
11 changed files with 553 additions and 101 deletions
@@ -13,6 +13,8 @@
<ItemGroup>
<Reference Include="System.Net.Http" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
</ItemGroup>
<ItemGroup>
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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();
}
}
}
@@ -128,7 +128,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
public IReadOnlyDictionary<string, AppInstallTiming>? 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<string, AppInstallTiming>());
appTimingsAccessor: () => AppTimingsOverride ?? new Dictionary<string, AppInstallTiming>(),
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<double>(data["uptimeMinutes"]);
}
// ============================================================================
@@ -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");
/// <summary>
/// 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"
};
/// <summary>
/// Patterns collected from the agent state folder. Adds completion markers on top of
/// the standard log patterns so files like <c>enrollment-complete.marker</c> and
/// <c>whiteglove-backfill-state.json</c> reach the diagnostics archive.
/// </summary>
private static readonly string[] StateFilePatterns = new[]
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
"*.complete", "*.marker"
};
/// <summary>
/// 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).
/// </summary>
private static readonly string[] RootMarkerPatterns = new[] { "*.complete", "*.marker" };
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
@@ -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<Shared.Models.DiagnosticsLogPath>())
{
// 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<Shared.Models.DiagnosticsLogPath>())
{
// 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();
@@ -72,6 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
private readonly Action<int> _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<int> triggerReboot = null,
TimeSpan? lateEventGracePeriod = null,
TimeSpan? spoolDrainPeriod = null,
Func<IReadOnlyDictionary<string, AppInstallTiming>> appTimingsAccessor = null)
Func<IReadOnlyDictionary<string, AppInstallTiming>> 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;
}
/// <summary>
@@ -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<string, object>
{
{ "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<string, object>
{
{ "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)
@@ -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
@@ -1 +1 @@
326
331
@@ -1 +1 @@
1160
1161
@@ -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)
@@ -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);
});
});
@@ -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
);
}