mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+2
@@ -13,6 +13,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+194
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
-10
@@ -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"]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+132
-47
@@ -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();
|
||||
|
||||
+39
-23
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user