mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d88d4a143b | |||
| 913dfa13fe | |||
| 51345718c6 | |||
| 6b92e4e28c | |||
| f392819b97 | |||
| 672570df04 | |||
| 6f7ececa1f | |||
| af7e9d3650 | |||
| b3b122a23c | |||
| 064b670c75 | |||
| e5eb6dc4fb | |||
| 9f46adcdfa | |||
| cd1319a486 | |||
| ca10f36624 | |||
| e537a4b074 | |||
| 762fef4e24 | |||
| bca492ba05 | |||
| e7dd962133 | |||
| 14a67ef5af | |||
| 2371855b77 | |||
| 3a50de83ef | |||
| 24dfd247e9 | |||
| a640039518 | |||
| c3e9011905 | |||
| 8f6926a945 | |||
| 01323fdf6d | |||
| c566ed818b | |||
| 3a40361bb6 | |||
| 1b988d2943 | |||
| e88a0e455c | |||
| 0695f7943c | |||
| 6f8e7d48b2 | |||
| 0248d14738 | |||
| dc59fea6e5 | |||
| 08455cc5f9 | |||
| 87c5bf018c |
+1
-1
Submodule .claude/commands updated: c96af89214...d05399a386
@@ -11,6 +11,7 @@
|
||||
Identity comes from whatever `az login` established: the federated GitHub OIDC principal
|
||||
in CI (azure/login, subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main), the
|
||||
operator's own account for the local build. No secret is read, stored or passed around.
|
||||
Under GitHub Actions the token is masked in the run log before it is returned.
|
||||
|
||||
x-ms-version is MANDATORY here, not decoration: Entra-authorized requests are rejected
|
||||
below 2017-11-09, and Table requests without a SAS must carry the header at all. 2021-08-06
|
||||
@@ -45,6 +46,15 @@ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
|
||||
'on the table -- a missing role shows up later as HTTP 403 AuthorizationPermissionMismatch.')
|
||||
}
|
||||
|
||||
# GitHub masks only secrets.* on its own; a token minted at run time is clear text in the
|
||||
# (public) run log the moment anything prints it -- an uncaptured call of this script, or
|
||||
# Get-Error / Format-List on a failed request, whose TargetObject carries this header.
|
||||
# Write-Host, never Write-Output: the output stream is this script's return value. Only
|
||||
# under Actions -- anywhere else the line would print the token instead of hiding it.
|
||||
if ($env:GITHUB_ACTIONS -eq 'true') {
|
||||
Write-Host "::add-mask::$token"
|
||||
}
|
||||
|
||||
return @{
|
||||
'Authorization' = "Bearer $token"
|
||||
'x-ms-version' = '2021-08-06'
|
||||
|
||||
@@ -108,6 +108,8 @@ jobs:
|
||||
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
||||
# Browsers load the live stats through it, and the build reads it once to bake the
|
||||
# numbers of this moment into the landing page's static HTML (lib/platformStats.ts).
|
||||
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
|
||||
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
|
||||
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
|
||||
@@ -121,6 +123,9 @@ jobs:
|
||||
npm run build
|
||||
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
|
||||
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
|
||||
# Fail-soft like the star count: browsers still load the stats live; only crawlers and
|
||||
# LLM fetchers, which run no JS, see no numbers until the next deploy.
|
||||
grep -q 'data-stats-snapshot=' out/index.html || echo "::warning::Platform stats were not baked into the landing page (reason in the build log) - crawlers and LLM fetchers see no numbers until the next deploy"
|
||||
|
||||
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
|
||||
- name: Package export
|
||||
|
||||
+1
-1
Submodule internal updated: 5ea761ada9...07ea7956ad
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-004",
|
||||
"title": "Windows Update Failed During Enrollment",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -18,10 +18,14 @@
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "wu_install_failed",
|
||||
"source": "event_type",
|
||||
"source": "event_data",
|
||||
"eventType": "windows_update_failed",
|
||||
"dataField": "wuEventId",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -31,6 +35,9 @@
|
||||
"dataField": "updateTitle",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -40,6 +47,9 @@
|
||||
"dataField": "hresult",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -49,6 +59,9 @@
|
||||
"dataField": "hresultSymbol",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
|
||||
+16
-3
@@ -1385,10 +1385,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-DEV-004",
|
||||
"title": "Windows Update Failed During Enrollment",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -1401,10 +1401,14 @@
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "wu_install_failed",
|
||||
"source": "event_type",
|
||||
"source": "event_data",
|
||||
"eventType": "windows_update_failed",
|
||||
"dataField": "wuEventId",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -1414,6 +1418,9 @@
|
||||
"dataField": "updateTitle",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -1423,6 +1430,9 @@
|
||||
"dataField": "hresult",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -1432,6 +1442,9 @@
|
||||
"dataField": "hresultSymbol",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
|
||||
+16
@@ -140,6 +140,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.Equal(1, fired); // the persisted flag suppressed the second emission
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_reconciliation_counters_survive_a_restart()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Tracker.ScriptRuns.CountVerified();
|
||||
h.Tracker.ScriptRuns.CountVerified();
|
||||
h.Tracker.ScriptRuns.CountCorrected();
|
||||
h.Tracker.SaveStateForTest();
|
||||
|
||||
h.Restart();
|
||||
|
||||
var health = h.Tracker.GetHealthSnapshot();
|
||||
Assert.Equal(2, health.PlatformOutputVerified);
|
||||
Assert.Equal(1, health.PlatformOutputCorrected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_time_does_not_count_against_the_line_budget()
|
||||
{
|
||||
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// B-96m: IME re-runs a failed platform script on later check-ins, so AgentExecutor.log can hold several runs of one
|
||||
/// policy before the tracker has read IME's result of the first — always on the agent's first pass (the agent starts
|
||||
/// mid-enrollment and reads all of AgentExecutor.log before IntuneManagementExtension.log), after a restart, and
|
||||
/// whenever a result line surfaces late. With one slot per policy the second executor start reused the first run's
|
||||
/// slot and its end block overwrote the first one: the first IME result went out with the LAST run's exit code and
|
||||
/// output (session eff1b413: the bootstrap script "Failed (exit 0)" with the stdout of its successful retry), every
|
||||
/// later result without an end block. These tests pin one run per executor start, each IME result paired with the
|
||||
/// run of its policy that started last before it, each run carrying its own end block.
|
||||
/// </summary>
|
||||
public sealed partial class ImeLogTrackerOverwriteRewindTests
|
||||
{
|
||||
private const string RetryPolicyId = "4c2d8e1a-7b3f-4a9e-8d21-6f0b5c3e9a74";
|
||||
private const string RetryUser = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
private static string ImeEntryAt(string time, string message)
|
||||
=> $"<![LOG[{message}]LOG]!><time=\"{time}\" date=\"9-10-2026\" component=\"IntuneManagementExtension\" context=\"\" type=\"1\" thread=\"12\" file=\"\">\r\n";
|
||||
|
||||
/// <summary>One executor run's start block: banner, the platform argument line, paths, launch.</summary>
|
||||
private static string RunStart(string time, string pid, string policyId = RetryPolicyId, string userId = RetryUser)
|
||||
{
|
||||
var path = $@"{ImeDir}\Policies\Scripts\{userId}_{policyId}.ps1";
|
||||
var results = $@"{ImeDir}\Policies\Results\{userId}_{policyId}";
|
||||
return ExecutorBanner(time)
|
||||
+ EntryAt(time, $"Adding argument powershell with value {path} to the named argument list.")
|
||||
+ EntryAt(time, "Powershell option gets invoked")
|
||||
+ EntryAt(time, path)
|
||||
+ EntryAt(time, results + ".output")
|
||||
+ EntryAt(time, results + ".error")
|
||||
+ EntryAt(time, results + ".timeout")
|
||||
+ ExecutorLaunch(time, path, pid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One executor run's end block in AgentExecutor's order. "error from script =" logs the stderr buffer before the
|
||||
/// output readers finished (<paramref name="stderrSnapshot"/>); "write output done" logs the final texts, the
|
||||
/// error part being exactly the error file IME judges.
|
||||
/// </summary>
|
||||
private static string RunEnd(string time, int exitCode, string stdout, string stderr, string? stderrSnapshot = null)
|
||||
=> EntryAt(time, $"Powershell exit code is {exitCode}")
|
||||
+ EntryAt(time, $"length of out={stdout.Length}")
|
||||
+ EntryAt(time, $"length of error={stderr.Length}")
|
||||
+ EntryAt(time, "error from script =" + (stderrSnapshot ?? stderr))
|
||||
+ EntryAt(time, exitCode == 0 ? "Powershell script is successfully executed." : "Powershell script is failed to execute")
|
||||
+ EntryAt(time, $"write output done. output = {stdout}, error = {stderr}")
|
||||
+ EntryAt(time, "Revert Wow64FsRedirection")
|
||||
+ EntryAt(time, "Agent executor completed.");
|
||||
|
||||
private static string ImeRunStart(string time, string policyId = RetryPolicyId, string userId = RetryUser)
|
||||
=> ImeEntryAt(time, $@"Script file {ImeDir}\Policies\Scripts\{userId}_{policyId}.ps1 is generated.");
|
||||
|
||||
private static string ImeRunResult(string time, string result, string policyId = RetryPolicyId, string userId = RetryUser)
|
||||
=> ImeEntryAt(time, $"[PowerShell] User Id = {userId}, Policy id = {policyId}, policy result = {result}");
|
||||
|
||||
private static void AppendImeLog(Harness h, string text)
|
||||
=> File.AppendAllText(Path.Combine(Path.GetDirectoryName(h.LogPath)!, "IntuneManagementExtension.log"), text, new UTF8Encoding(false));
|
||||
|
||||
/// <summary>Result | exit | stdout | stderr — readable in an assertion failure; "-" no exit code, "~" never read.</summary>
|
||||
private static string Shape(ScriptExecutionState s)
|
||||
=> $"{s.Result}|{(s.ExitCode.HasValue ? s.ExitCode.Value.ToString() : "-")}|{s.Stdout ?? "~"}|{s.Stderr ?? "~"}";
|
||||
|
||||
/// <summary>
|
||||
/// Two poll passes as the agent runs them (read the files, then the flushes), the second past the result hold:
|
||||
/// a result missing its end block goes out by then.
|
||||
/// </summary>
|
||||
private static async Task PassAndSettle(Harness h)
|
||||
{
|
||||
await h.Tracker.RunPollPassAsync(default);
|
||||
h.Now = h.Now.AddSeconds(6);
|
||||
await h.Tracker.RunPollPassAsync(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task First_pass_pairs_each_ime_result_with_the_end_block_of_its_own_run()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
// IME's first run and two retries on later check-ins, all written before the agent started.
|
||||
h.Append(RunStart("12:00:10.0000000", "1001") + RunEnd("12:00:12.0000000", 1, "", "Run A failed")
|
||||
+ RunStart("12:10:10.0000000", "1002") + RunEnd("12:10:12.0000000", 1, "Run B output", "")
|
||||
+ RunStart("12:20:10.0000000", "1003") + RunEnd("12:20:12.0000000", 0, "Run C output", ""));
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed")
|
||||
+ ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:12.1000000", "Success")
|
||||
+ ImeRunStart("12:20:08.0000000") + ImeRunResult("12:20:12.1000000", "Success"));
|
||||
|
||||
await PassAndSettle(h);
|
||||
|
||||
Assert.Equal(new[] { "Failed|1||Run A failed", "Success|1|Run B output|", "Success|0|Run C output|" },
|
||||
h.Completed.Select(Shape).ToArray());
|
||||
Assert.All(h.Completed, s => Assert.Equal("ime_policy_result", s.ResultSource));
|
||||
Assert.True(h.Completed[0].StartedAtUtc < h.Completed[1].StartedAtUtc && h.Completed[1].StartedAtUtc < h.Completed[2].StartedAtUtc);
|
||||
Assert.Equal(3, h.Completed.Select(s => s.RunId).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retry_of_the_bootstrap_script_never_lends_its_success_output_to_the_failed_first_attempt()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(RunStart("12:00:10.0000000", "2001") + RunEnd("12:00:20.0000000", 1, "attempt 1: download failed", "Invoke-WebRequest : The remote name could not be resolved")
|
||||
+ RunStart("12:10:10.0000000", "2002") + RunEnd("12:10:20.0000000", 0, "===== Bootstrap Completed Successfully =====", ""));
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:20.1000000", "Failed")
|
||||
+ ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:20.1000000", "Success"));
|
||||
|
||||
await PassAndSettle(h);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
"Failed|1|attempt 1: download failed|Invoke-WebRequest : The remote name could not be resolved",
|
||||
"Success|0|===== Bootstrap Completed Successfully =====|",
|
||||
}, h.Completed.Select(Shape).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_killed_at_the_timeout_reports_failed_without_the_next_runs_end_block()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
// IME killed the first executor after 1800 s: its start block is all AgentExecutor.log holds of it.
|
||||
h.Append(RunStart("12:00:10.0000000", "3001")
|
||||
+ RunStart("12:40:10.0000000", "3002") + RunEnd("12:40:12.0000000", 0, "Run B output", ""));
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:30:10.5000000", "Failed")
|
||||
+ ImeRunStart("12:40:08.0000000") + ImeRunResult("12:40:12.1000000", "Success"));
|
||||
|
||||
await PassAndSettle(h);
|
||||
|
||||
Assert.Equal(new[] { "Failed|-|~|~", "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_whose_result_never_surfaced_falls_back_on_imes_rule_before_the_next_run()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(RunStart("12:00:10.0000000", "4001") + RunEnd("12:00:12.0000000", 0, "Run A output", "Run A wrote an error")
|
||||
+ RunStart("12:10:10.0000000", "4002") + RunEnd("12:10:12.0000000", 0, "Run B output", ""));
|
||||
// Run A's result line was lost; run B's is there.
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:12.1000000", "Success"));
|
||||
|
||||
await PassAndSettle(h);
|
||||
|
||||
Assert.Equal(new[] { "Failed|0|Run A output|Run A wrote an error", "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
|
||||
Assert.Equal(new[] { "agentexecutor_fallback", "ime_policy_result" }, h.Completed.Select(s => s.ResultSource).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_in_flight_survives_the_emit_of_the_run_before_it()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(RunStart("12:00:10.0000000", "5001") + RunEnd("12:00:12.0000000", 0, "Run A output", "")
|
||||
+ RunStart("12:10:10.0000000", "5002"));
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Success") + ImeRunStart("12:10:08.0000000"));
|
||||
await PassAndSettle(h);
|
||||
h.Now = h.Now.AddSeconds(30);
|
||||
await h.Pass();
|
||||
|
||||
// Run B ends minutes later.
|
||||
h.Append(RunEnd("12:10:40.0000000", 0, "Run B output", ""));
|
||||
AppendImeLog(h, ImeRunResult("12:10:40.1000000", "Success"));
|
||||
await PassAndSettle(h);
|
||||
|
||||
Assert.Equal(new[] { "Success|0|Run A output|", "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shutdown_between_the_two_logs_flushes_each_run_with_its_own_end_block()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(RunStart("12:00:10.0000000", "6001") + RunEnd("12:00:12.0000000", 1, "", "Run A failed")
|
||||
+ RunStart("12:10:10.0000000", "6002") + RunEnd("12:10:12.0000000", 0, "Run B output", ""));
|
||||
// The pass is cancelled after AgentExecutor.log; the shutdown flush follows.
|
||||
await h.Pass();
|
||||
h.Tracker.FlushPendingPlatformScriptResults(h.Now, force: true);
|
||||
|
||||
h.Restart();
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed")
|
||||
+ ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:12.1000000", "Success"));
|
||||
await PassAndSettle(h);
|
||||
|
||||
// Each run went out with its own block and IME's verdict on it; the results read after the restart are
|
||||
// the same runs and change nothing.
|
||||
Assert.Equal(new[] { "Failed|1||Run A failed", "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
|
||||
Assert.All(h.Completed, s => Assert.Equal("agentexecutor_fallback", s.ResultSource));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task End_block_line_recovered_after_the_next_run_started_stays_with_its_own_run()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(RunStart("12:00:10.0000000", "7001"));
|
||||
var exitLineAt = h.Length;
|
||||
h.Append(RunEnd("12:00:12.0000000", 1, "", "Run A failed")
|
||||
+ RunStart("12:10:10.0000000", "7002") + RunEnd("12:10:12.0000000", 0, "Run B output", ""));
|
||||
await h.Pass();
|
||||
|
||||
// A stale writer rewrites run A's exit line in place: the ledger check re-reads it behind run B's start.
|
||||
h.Overwrite(exitLineAt, EntryAt("12:00:12.0000000", "Powershell exit code is 7"));
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddSeconds(2);
|
||||
await h.Pass();
|
||||
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed")
|
||||
+ ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:12.1000000", "Success"));
|
||||
await PassAndSettle(h);
|
||||
|
||||
Assert.Equal(new[] { "Failed|7||Run A failed", "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_without_result_or_end_block_is_dropped_once_a_later_run_has_its_result()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(RunStart("12:00:10.0000000", "9001")
|
||||
+ RunStart("12:10:10.0000000", "9002") + RunEnd("12:10:12.0000000", 0, "Run B output", ""));
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:12.1000000", "Success"));
|
||||
|
||||
await PassAndSettle(h);
|
||||
|
||||
Assert.Equal(new[] { "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
|
||||
Assert.Equal(0, h.Tracker.ParkedPlatformScriptCountForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reading_the_same_executor_start_line_again_never_parks_its_run()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
var line = $@"Adding argument powershell with value {ImeDir}\Policies\Scripts\{RetryUser}_{RetryPolicyId}.ps1 to the named argument list.";
|
||||
var at = T0.AddMinutes(-30);
|
||||
|
||||
h.Tracker.ProcessLogMessageForTest(line, at, "AgentExecutor.log");
|
||||
h.Tracker.ProcessLogMessageForTest(line, at, "AgentExecutor.log");
|
||||
Assert.Equal(0, h.Tracker.ParkedPlatformScriptCountForTest);
|
||||
|
||||
h.Tracker.ProcessLogMessageForTest(line, at.AddMinutes(10), "AgentExecutor.log");
|
||||
Assert.Equal(1, h.Tracker.ParkedPlatformScriptCountForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_without_end_block_keeps_waiting_for_its_result_across_a_shutdown()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(RunStart("12:00:10.0000000", "9101") + RunStart("12:40:10.0000000", "9102"));
|
||||
await h.Pass();
|
||||
h.Tracker.FlushPendingPlatformScriptResults(h.Now, force: true);
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
h.Restart();
|
||||
Assert.Equal(1, h.Tracker.ParkedPlatformScriptCountForTest);
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:30:10.5000000", "Failed"));
|
||||
await PassAndSettle(h);
|
||||
|
||||
Assert.Equal(new[] { "Failed|-|~|~" }, h.Completed.Select(Shape).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Earlier_run_emitted_while_a_later_one_runs_never_takes_the_later_runs_register_entry()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(RunStart("12:00:10.0000000", "9201") + RunEnd("12:00:12.0000000", 1, "", "Run A failed")
|
||||
+ RunStart("12:10:10.0000000", "9202"));
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed") + ImeRunStart("12:10:08.0000000"));
|
||||
await PassAndSettle(h);
|
||||
Assert.Single(h.Completed);
|
||||
|
||||
// A save of this key is the latest run's, and that one is still running.
|
||||
h.Tracker.ScriptRuns.NotePassCompleted(h.Now);
|
||||
Assert.Equal(PlatformRunClaim.Wait, h.Tracker.ScriptRuns.TryClaim(RetryUser, RetryPolicyId, h.Now.AddSeconds(-10), out _));
|
||||
|
||||
h.Append(RunEnd("12:10:40.0000000", 0, "Run B output", ""));
|
||||
AppendImeLog(h, ImeRunResult("12:10:40.1000000", "Success"));
|
||||
await PassAndSettle(h);
|
||||
h.Tracker.ScriptRuns.NotePassCompleted(h.Now);
|
||||
|
||||
Assert.Equal(PlatformRunClaim.Claimed, h.Tracker.ScriptRuns.TryClaim(RetryUser, RetryPolicyId, h.Now.AddSeconds(-10), out var run));
|
||||
Assert.Equal(h.Completed[1].RunId, run!.RunId);
|
||||
Assert.Equal("Run B output", run.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_of_another_user_keeps_its_own_register_entry_when_it_waited_for_its_result()
|
||||
{
|
||||
const string otherUser = "2b7e5c91-3d4a-4f80-b6e2-9a1c7d3f5e08";
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(RunStart("12:00:10.0000000", "9301") + RunEnd("12:00:12.0000000", 1, "", "Run A failed")
|
||||
+ RunStart("12:10:10.0000000", "9302", userId: otherUser));
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed")
|
||||
+ ImeRunStart("12:10:08.0000000", userId: otherUser));
|
||||
await PassAndSettle(h);
|
||||
h.Tracker.ScriptRuns.NotePassCompleted(h.Now);
|
||||
|
||||
Assert.Equal(PlatformRunClaim.Claimed, h.Tracker.ScriptRuns.TryClaim(RetryUser, RetryPolicyId, h.Now.AddSeconds(-10), out var run));
|
||||
Assert.Equal(h.Completed[0].RunId, run!.RunId);
|
||||
Assert.Equal(PlatformRunClaim.Wait, h.Tracker.ScriptRuns.TryClaim(otherUser, RetryPolicyId, h.Now.AddSeconds(-10), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_line_read_again_stays_a_late_line_after_an_earlier_run_of_another_user_went_out()
|
||||
{
|
||||
const string otherUser = "2b7e5c91-3d4a-4f80-b6e2-9a1c7d3f5e08";
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(RunStart("12:00:10.0000000", "9401") + RunEnd("12:00:12.0000000", 0, "Run A output", "")
|
||||
+ RunStart("12:10:10.0000000", "9402", userId: otherUser) + RunEnd("12:10:12.0000000", 0, "Run B output", ""));
|
||||
// Run A's result surfaces after run B's (its line was hidden by a concurrent writer).
|
||||
AppendImeLog(h, ImeRunResult("12:10:12.1000000", "Success", userId: otherUser) + ImeRunResult("12:00:12.1000000", "Success"));
|
||||
await PassAndSettle(h);
|
||||
Assert.Equal(new[] { "Success|0|Run B output|", "Success|0|Run A output|" }, h.Completed.Select(Shape).ToArray());
|
||||
|
||||
// Run B's executor start line read again (a rewind): a late line of the emitted run B, never a new run.
|
||||
var started = 0;
|
||||
h.Tracker.OnScriptStarted = _ => started++;
|
||||
h.Tracker.ProcessLogMessageForTest(
|
||||
$@"Adding argument powershell with value {ImeDir}\Policies\Scripts\{otherUser}_{RetryPolicyId}.ps1 to the named argument list.",
|
||||
h.Completed[0].StartedAtUtc, "AgentExecutor.log");
|
||||
Assert.Equal(0, started);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Final_stderr_of_the_end_block_replaces_the_early_snapshot_even_without_stdout()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
// The snapshot was taken before the error reader finished; IME judged the full error file.
|
||||
h.Append(RunStart("12:00:10.0000000", "8001") + RunEnd("12:00:12.0000000", 0, "", "Remove-AppxPackage : Cannot process argument", stderrSnapshot: ""));
|
||||
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed"));
|
||||
|
||||
await PassAndSettle(h);
|
||||
|
||||
Assert.Equal(new[] { "Failed|0||Remove-AppxPackage : Cannot process argument" }, h.Completed.Select(Shape).ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Session 756970cf: IME launched a Win32 detection executor and a platform-script executor 15 ms apart, and both
|
||||
/// opened AgentExecutor.log at the same end-of-file offset. The platform executor's shorter start block overwrote
|
||||
/// the detection executor's, whose identity line the tracker never saw; the detection's end block (exit 1 and its
|
||||
/// stdout), written at its own position behind its own start block, then sat behind the PLATFORM start block, and
|
||||
/// "the nearest preceding marker owns the line" handed another script's output and exit code to the platform
|
||||
/// script. These tests pin the writer boundary: an entry that is not exactly one CMTrace record is where one
|
||||
/// writer's bytes end and another's begin, and nothing behind it belongs to the invocation in front of it.
|
||||
/// </summary>
|
||||
public sealed partial class ImeLogTrackerOverwriteRewindTests
|
||||
{
|
||||
private const string RaceUserId = "00000000-0000-0000-0000-000000000000";
|
||||
private const string RacePlatformId = "1d6f0a52-3c4e-4b7a-9e21-5f8c7d2b9a10";
|
||||
private const string RaceDetectionAppId = "8b2e4c71-6a9d-4f03-b5e8-2c7a1d9f4e36";
|
||||
private const string RaceFillerAppId = "5e1c2b7d-0a4f-4c8e-9d36-7b2f1e8a4c50";
|
||||
private const string ImeDir = @"C:\Program Files (x86)\Microsoft Intune Management Extension";
|
||||
private const string DetectionStdout = "VERBOSE: === Sample Suite Detection Started ===\r\nWARNING: === DETECTION FAILED: Sample Suite is not installed ===";
|
||||
private const string PlatformStdout = "=== bootstrap loader started ===\r\nInstaller finished (exit code 0).";
|
||||
|
||||
private static string EntryAt(string time, string message)
|
||||
=> $"<![LOG[{message}]LOG]!><time=\"{time}\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">\r\n";
|
||||
|
||||
/// <summary>The four lines every AgentExecutor invocation starts with — identical length for every executor.</summary>
|
||||
private static string ExecutorBanner(string time)
|
||||
=> EntryAt(time, "ExecutorLog AgentExecutor gets invoked")
|
||||
+ EntryAt(time, "Creating command line parser, name delimiter is - and value separator is .")
|
||||
+ EntryAt(time, "Getting Ordered Parameters")
|
||||
+ EntryAt(time, "Parsing Ordered Parameters.");
|
||||
|
||||
private static string ExecutorLaunch(string time, string scriptPath, string pid)
|
||||
=> EntryAt(time, "Prepare to run Powershell Script ..")
|
||||
+ EntryAt(time, $"cmd line for running powershell is -NoProfile -executionPolicy bypass -file \"{scriptPath}\" ")
|
||||
+ EntryAt(time, "runAs32BitOn64 = False, so Disable Wow64FsRedirection")
|
||||
+ EntryAt(time, @"PowerShell path is C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe")
|
||||
+ EntryAt(time, $"[Executor] created powershell with process id {pid}");
|
||||
|
||||
private static string PlatformStartBlock(string time, string policyId, string pid)
|
||||
{
|
||||
var path = $@"{ImeDir}\Policies\Scripts\{RaceUserId}_{policyId}.ps1";
|
||||
var results = $@"{ImeDir}\Policies\Results\{RaceUserId}_{policyId}";
|
||||
return ExecutorBanner(time)
|
||||
+ EntryAt(time, $"Adding argument powershell with value {path} to the named argument list.")
|
||||
+ EntryAt(time, "Powershell option gets invoked")
|
||||
+ EntryAt(time, path)
|
||||
+ EntryAt(time, results + ".output")
|
||||
+ EntryAt(time, results + ".error")
|
||||
+ EntryAt(time, results + ".timeout")
|
||||
+ ExecutorLaunch(time, path, pid);
|
||||
}
|
||||
|
||||
private static string DetectionStartBlock(string time, string appId, string pid)
|
||||
{
|
||||
var path = $@"{ImeDir}\Content\DetectionScripts\{appId}_2.ps1";
|
||||
return ExecutorBanner(time)
|
||||
+ EntryAt(time, $"Adding argument powershellDetection with value {path} to the named argument list.")
|
||||
+ EntryAt(time, "PowershellDetection option gets invoked")
|
||||
+ EntryAt(time, path)
|
||||
+ EntryAt(time, path + "quotedResultFilePath.txt")
|
||||
+ EntryAt(time, path + "quotedErrorFilePath.txt")
|
||||
+ EntryAt(time, path + "quotedTimeoutFilePath.txt")
|
||||
+ EntryAt(time, path + "quotedExitCodeFilePath.txt")
|
||||
+ ExecutorLaunch(time, path, pid);
|
||||
}
|
||||
|
||||
private static string ExecutorEndBlock(string time, int exitCode, string stdout)
|
||||
=> EntryAt(time, $"Powershell exit code is {exitCode}")
|
||||
+ EntryAt(time, $"length of out={stdout.Length}")
|
||||
+ EntryAt(time, "length of error=0")
|
||||
+ EntryAt(time, "error from script =")
|
||||
+ EntryAt(time, exitCode == 0 ? "Powershell script is successfully executed." : "Powershell script is failed to execute")
|
||||
+ EntryAt(time, $"write output done. output = {stdout}, error = ")
|
||||
+ EntryAt(time, "Revert Wow64FsRedirection")
|
||||
+ EntryAt(time, "Agent executor completed.");
|
||||
|
||||
private static string PlatformResultLine(string policyId)
|
||||
=> $"[PowerShell] User Id = {RaceUserId}, Policy id = {policyId}, policy result = Success";
|
||||
|
||||
/// <summary>The file offsets at which the entries of an ASCII <paramref name="block"/> start when it is written at <paramref name="at"/>.</summary>
|
||||
private static HashSet<long> EntryStarts(string block, long at)
|
||||
{
|
||||
Assert.True(block.All(c => c < 128), "offset arithmetic assumes ASCII test content");
|
||||
var starts = new HashSet<long>();
|
||||
for (var i = block.IndexOf("<![LOG[", StringComparison.Ordinal); i >= 0; i = block.IndexOf("<![LOG[", i + 1, StringComparison.Ordinal))
|
||||
starts.Add(at + i);
|
||||
return starts;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Executor_that_opened_at_the_same_offset_never_lends_its_end_block_to_the_platform_script()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
|
||||
// An earlier, finished detection run: the race starts mid-file like in the field log.
|
||||
h.Append(DetectionStartBlock("12:00:00.0000000", RaceFillerAppId, "1548") + ExecutorEndBlock("12:00:00.5000000", 0, "detected"));
|
||||
await h.Pass();
|
||||
var x = h.Length;
|
||||
|
||||
// Detection executor C and platform executor D open the file at the same end x. C writes first; the tracker
|
||||
// reads C's banner, then D writes its own start block over C's.
|
||||
var cStart = DetectionStartBlock("12:00:01.0000000", RaceDetectionAppId, "6416");
|
||||
var dStart = PlatformStartBlock("12:00:01.2000000", RacePlatformId, "2524");
|
||||
var cHead = cStart.Substring(0, cStart.IndexOf("<![LOG[Getting Ordered Parameters", StringComparison.Ordinal));
|
||||
h.Append(cHead);
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
h.Append(cStart.Substring(cHead.Length));
|
||||
h.Overwrite(x, dStart);
|
||||
|
||||
// The field geometry: D's start block is shorter and ends inside one of C's lines, so the tail of that line
|
||||
// sits between D's start block and C's remaining bytes.
|
||||
Assert.True(dStart.Length < cStart.Length);
|
||||
Assert.DoesNotContain(x + dStart.Length, EntryStarts(cStart, x));
|
||||
h.Now = h.Now.AddMilliseconds(150);
|
||||
await h.Pass();
|
||||
Assert.True(h.Health.OverwriteRewinds >= 1);
|
||||
|
||||
// C ends: its end block lands at its own position, right behind its own (overwritten) start block.
|
||||
Assert.Equal(x + cStart.Length, h.Length);
|
||||
var cEnd = ExecutorEndBlock("12:00:07.0000000", 1, DetectionStdout);
|
||||
h.Append(cEnd);
|
||||
h.Now = h.Now.AddSeconds(6);
|
||||
await h.Pass();
|
||||
|
||||
// IME's result for D reaches the tracker before D's own end block: the completion must wait for it.
|
||||
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(RacePlatformId), sourceFileName: "IntuneManagementExtension.log");
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
// D ends: its end block lands at ITS position, over the fragment and C's bytes; C's last line survives.
|
||||
var dEnd = ExecutorEndBlock("12:00:09.5000000", 0, PlatformStdout);
|
||||
Assert.True(dEnd.Length < cStart.Length - dStart.Length + cEnd.Length);
|
||||
h.Overwrite(x + dStart.Length, dEnd);
|
||||
h.Now = h.Now.AddMilliseconds(130);
|
||||
await h.Pass();
|
||||
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(RacePlatformId, script.PolicyId);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Equal(PlatformStdout.Replace("\r\n", "\n"), script.Stdout);
|
||||
Assert.DoesNotContain(h.Completed, s => (s.Stdout ?? string.Empty).Contains("Detection"));
|
||||
|
||||
// The run is known to the registry reconciliation under the user id of its own lines (D-316).
|
||||
Assert.Equal(RaceUserId, script.UserId);
|
||||
Assert.False(string.IsNullOrEmpty(script.RunId));
|
||||
h.Tracker.ScriptRuns.NotePassCompleted(DateTime.MaxValue.AddDays(-1));
|
||||
Assert.Equal(PlatformRunClaim.Claimed, h.Tracker.ScriptRuns.TryClaim(RaceUserId, RacePlatformId, h.Now, out var run));
|
||||
Assert.Equal(script.RunId, run!.RunId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The mirror image: a foreign executor whose stream position lies inside the platform start block (it opened
|
||||
/// at the same end with a shorter start block) writes its end block there. Its first line merges with the head of
|
||||
/// a platform line — cut inside the message or inside the trailer, which the parser would otherwise read up to
|
||||
/// the foreign trailer — and its output line follows without any marker in between.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(15)]
|
||||
[InlineData(-30)]
|
||||
public async Task Foreign_end_block_merged_into_the_platform_start_block_never_reaches_the_platform_script(int cut)
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(DetectionStartBlock("12:00:00.0000000", RaceFillerAppId, "1548") + ExecutorEndBlock("12:00:00.5000000", 0, "detected"));
|
||||
await h.Pass();
|
||||
var x = h.Length;
|
||||
|
||||
var dStart = PlatformStartBlock("12:00:01.0000000", RacePlatformId, "2524");
|
||||
h.Append(dStart);
|
||||
await h.Pass();
|
||||
|
||||
// The foreign end block starts inside the line after D's argument line.
|
||||
var lineAfterArgument = x + dStart.IndexOf("<![LOG[Powershell option gets invoked", StringComparison.Ordinal);
|
||||
var lineLength = EntryAt("12:00:01.0000000", "Powershell option gets invoked").Length;
|
||||
var at = lineAfterArgument + (cut > 0 ? cut : lineLength + cut);
|
||||
var foreignEnd = ExecutorEndBlock("12:00:03.0000000", 1, "foreign output");
|
||||
Assert.True(at + foreignEnd.Length < x + dStart.Length, "the foreign block must end inside D's start block");
|
||||
h.Overwrite(at, foreignEnd);
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(RacePlatformId), sourceFileName: "IntuneManagementExtension.log");
|
||||
h.Now = h.Now.Add(ImeLogTracker.PlatformScriptEndBlockGrace);
|
||||
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
|
||||
|
||||
// No owner is provable behind the cut: the completion goes out without end-block data, never with the foreign one.
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Null(script.ExitCode);
|
||||
Assert.Null(script.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Entry_read_unchanged_after_a_rewind_keeps_its_platform_marker()
|
||||
{
|
||||
const string platformId = "2b7d9e41-8c3a-4f60-a5d2-9e1b7c4f3a08";
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
|
||||
// Detection W waits for its script; detection X runs completely behind it; platform F starts behind X.
|
||||
var wStart = DetectionStartBlock("12:00:00.0000000", RaceFillerAppId, "1111");
|
||||
var xRun = DetectionStartBlock("12:00:00.2000000", RaceDetectionAppId, "2222") + ExecutorEndBlock("12:00:00.4000000", 0, "x detected");
|
||||
h.Append(wStart + xRun + PlatformStartBlock("12:00:01.0000000", platformId, "3333"));
|
||||
await h.Pass();
|
||||
|
||||
// W ends at its own position, over the head of X's run; F's start block keeps its bytes and offset.
|
||||
var wEnd = ExecutorEndBlock("12:00:02.0000000", 0, "w detected");
|
||||
Assert.True(wEnd.Length < xRun.Length);
|
||||
h.Overwrite(wStart.Length, wEnd);
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
|
||||
// F's end block lands behind its start block: still F's, because F's marker survived with its bytes.
|
||||
h.Append(ExecutorEndBlock("12:00:05.0000000", 7, "Hello from F"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(platformId), sourceFileName: "IntuneManagementExtension.log");
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(platformId, script.PolicyId);
|
||||
Assert.Equal(7, script.ExitCode);
|
||||
Assert.Equal("Hello from F", script.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writer_boundary_read_unchanged_after_a_rewind_still_closes_the_platform_invocation()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
|
||||
// Detection Q waits for its script while detection R runs completely behind it.
|
||||
var qStart = DetectionStartBlock("12:00:00.0000000", RaceFillerAppId, "1111");
|
||||
var rRun = DetectionStartBlock("12:00:00.2000000", "6c3f8a20-1d5e-4b97-8f4a-0e2d6b9c7a13", "2222") + ExecutorEndBlock("12:00:00.4000000", 0, "r detected");
|
||||
h.Append(qStart + rRun);
|
||||
await h.Pass();
|
||||
var x = h.Length;
|
||||
|
||||
// The race of the field case behind them: the boundary closes D.
|
||||
var cStart = DetectionStartBlock("12:00:01.0000000", RaceDetectionAppId, "6416");
|
||||
var dStart = PlatformStartBlock("12:00:01.2000000", RacePlatformId, "2524");
|
||||
h.Append(cStart);
|
||||
h.Overwrite(x, dStart);
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
// Q ends at its own position, over R's run: the rewind starts in front of the race, whose bytes read unchanged.
|
||||
var qEnd = ExecutorEndBlock("12:00:02.0000000", 0, "q detected");
|
||||
Assert.True(qEnd.Length < rRun.Length);
|
||||
h.Overwrite(qStart.Length, qEnd);
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
// C's end block behind the boundary stays unowned; D's own end block completes D.
|
||||
var cEnd = ExecutorEndBlock("12:00:07.0000000", 1, DetectionStdout);
|
||||
h.Append(cEnd);
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(RacePlatformId), sourceFileName: "IntuneManagementExtension.log");
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
h.Overwrite(x + dStart.Length, ExecutorEndBlock("12:00:09.5000000", 0, PlatformStdout));
|
||||
h.Now = h.Now.AddMilliseconds(130);
|
||||
await h.Pass();
|
||||
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Equal(PlatformStdout.Replace("\r\n", "\n"), script.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Raw_text_in_the_executor_log_is_never_matched_and_closes_the_platform_invocation()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(PlatformStartBlock("12:00:00.0000000", RacePlatformId, "2524"));
|
||||
await h.Pass();
|
||||
|
||||
// The tail of a cut line that happens to read like an exit line, then a foreign end block.
|
||||
h.Append("Powershell exit code is 5\r\n" + ExecutorEndBlock("12:00:02.0000000", 6, "foreign output"));
|
||||
// A fragment right at the bookmark stops the first pass for a check; the second one reads it.
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(RacePlatformId), sourceFileName: "IntuneManagementExtension.log");
|
||||
h.Now = h.Now.Add(ImeLogTracker.PlatformScriptEndBlockGrace);
|
||||
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Null(script.ExitCode);
|
||||
Assert.Null(script.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fragment_in_the_ime_log_does_not_close_the_platform_run_context()
|
||||
{
|
||||
using var h = new Harness("IntuneManagementExtension.log", ScriptPatterns());
|
||||
// In the IME log the service writes the platform marker and the context line itself; the fragment in
|
||||
// between is the tail of an executor's telemetry line and separates nothing.
|
||||
h.Append(Entry(@"Script file C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\" + RaceUserId + "_" + RacePlatformId + ".ps1 is generated.", "17")
|
||||
+ "ing mananger...]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">\r\n"
|
||||
+ Entry("Launch powershell executor in user session", "17"));
|
||||
await h.Pass();
|
||||
|
||||
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(RacePlatformId));
|
||||
h.Now = h.Now.Add(ImeLogTracker.PlatformScriptEndBlockGrace);
|
||||
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal("User", script.RunContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cut_multi_line_output_adds_one_boundary_not_one_per_line()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(PlatformStartBlock("12:00:00.0000000", RacePlatformId, "2524"));
|
||||
await h.Pass();
|
||||
var markersBefore = h.Tracker.InvocationMarkerCountForTest("AgentExecutor.log");
|
||||
|
||||
var lines = string.Concat(Enumerable.Range(1, 600).Select(i => $"orphaned output line {i}\r\n"));
|
||||
h.Append(lines);
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(markersBefore + 1, h.Tracker.InvocationMarkerCountForTest("AgentExecutor.log"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<![LOG[Powershell exit code is 0]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", true)]
|
||||
[InlineData("<![LOG[write output done. output = line one\nline two, error = ]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", true)]
|
||||
[InlineData("\uFEFF<![LOG[ExecutorLog AgentExecutor gets invoked]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", true)]
|
||||
[InlineData("t code is 3]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("<![LOG[Powe<![LOG[Powershell exit code is 1]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", false)]
|
||||
[InlineData("<![LOG[Powershell option gets invoked]LOG]!><time=\"12:00:00.0000000\" da<![LOG[Powershell exit code is 1]LOG]!><time=\"12:00:01.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", false)]
|
||||
[InlineData("<![LOG[write output done. output = line one\nline<![LOG[Agent executor completed.]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", false)]
|
||||
public void Single_cmtrace_record_is_told_apart_from_the_cut_of_two_writers(string text, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeLogTracker.IsSingleCmTraceRecord(text));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AgentExecutor.log", true)]
|
||||
[InlineData("AgentExecutor-20261003-233348.log", true)]
|
||||
[InlineData("IntuneManagementExtension.log", false)]
|
||||
[InlineData("IntuneManagementExtension-20261003-233348.log", false)]
|
||||
[InlineData("HealthScripts.log", false)]
|
||||
[InlineData("AppWorkload.log", false)]
|
||||
public void Writer_boundaries_apply_to_the_executor_log_live_and_rotated(string fileName, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeLogTracker.IsWriterBoundaryLogFile(fileName));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -25,7 +25,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
/// contract: unchanged bytes never rewind, changed bytes are processed exactly once, and the
|
||||
/// executor lines a rewind uncovers belong to the invocation that owns their file position.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerOverwriteRewindTests
|
||||
public sealed partial class ImeLogTrackerOverwriteRewindTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 10, 19, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
private static readonly string[] ScriptPatternIds =
|
||||
{
|
||||
"PS-AGENT-INVOCATION", "PS-AGENT-ARG", "PS-AGENT-SCRIPT-START", "PS-AGENT-EXITCODE",
|
||||
"PS-AGENT-OUTPUT", "PS-AGENT-COMPLETED", "PS-SCRIPT-GENERATED", "PS-SCRIPT-CONTEXT", "PS-SCRIPT-RESULT",
|
||||
"PS-AGENT-OUTPUT", "PS-AGENT-ERROR", "PS-AGENT-COMPLETED", "PS-SCRIPT-GENERATED", "PS-SCRIPT-CONTEXT", "PS-SCRIPT-RESULT",
|
||||
};
|
||||
|
||||
/// <summary>The shipped pattern JSON — the same source combine.js embeds — so the contract is the real one.</summary>
|
||||
|
||||
+26
-1
@@ -83,8 +83,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.Single(emitted);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// IME's verdict is Success exactly when the error file is empty, whatever the exit code. Judged by the exit
|
||||
// code, session 67ae751c showed exit 0 with the script's own stderr as Success, and 46a41a0a an exit 1 without
|
||||
// stderr as Failed, a run IME reported as Success one cycle earlier.
|
||||
[InlineData(0, "Invoke-WebRequest : The remote name could not be resolved", "Failed")]
|
||||
[InlineData(1, "", "Success")]
|
||||
[InlineData(1, " \n", "Success")]
|
||||
[InlineData(0, "", "Success")]
|
||||
[InlineData(1, null, "Failed")]
|
||||
[InlineData(0, null, "Success")]
|
||||
public void Fallback_judges_like_ime_by_the_stderr_it_read(int exitCode, string? stderr, string expected)
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var tracker = BuildTracker(tmp, out var emitted);
|
||||
var observedAt = new DateTime(2026, 6, 19, 12, 59, 4, DateTimeKind.Utc);
|
||||
|
||||
tracker.SeedPendingPlatformScriptForTesting("0d2294bf", exitCode: exitCode, exitObservedAtUtc: observedAt, stderr: stderr);
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
|
||||
var script = Assert.Single(emitted);
|
||||
Assert.Equal(expected, script.Result);
|
||||
Assert.Equal(exitCode, script.ExitCode);
|
||||
Assert.Equal("agentexecutor_fallback", script.ResultSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nonzero_exit_code_emits_failed_result()
|
||||
public void Nonzero_exit_code_without_a_stderr_line_emits_failed_result()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var tracker = BuildTracker(tmp, out var emitted);
|
||||
|
||||
+27
@@ -108,6 +108,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
|
||||
var tracker = BuildTracker(tmp, out var emitted);
|
||||
tracker.LoadStateForTest(); // must not throw
|
||||
Assert.Equal(0, tracker.ParkedPlatformScriptCountForTest);
|
||||
|
||||
// Degrades to pre-fix behavior: no markers restored, normal emission works (the
|
||||
// result waits for its end block until the shutdown flush).
|
||||
@@ -116,6 +117,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.Single(emitted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pending_slot_from_a_state_file_without_executor_start_loads_and_completes()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var started = new DateTime(2026, 10, 4, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// A pending slot written by an agent before runs carried their executor start and run id.
|
||||
var persistence = new ImeTrackerStatePersistence(tmp.Path, new AgentLogger(tmp.Path, AgentLogLevel.Info));
|
||||
persistence.Save(new ImeTrackerStateData
|
||||
{
|
||||
PendingPlatformScripts = new List<ScriptExecutionState>
|
||||
{
|
||||
new ScriptExecutionState { PolicyId = "policyF", ScriptType = "platform", StartedAtUtc = started, ExitCode = 0, ExitObservedAtUtc = started.AddSeconds(5), Stdout = "done", Stderr = "" },
|
||||
},
|
||||
});
|
||||
|
||||
var tracker = BuildTracker(tmp, out var emitted);
|
||||
tracker.LoadStateForTest();
|
||||
tracker.CompletePlatformScriptFromImeResultForTesting("policyF", "Success", started.AddSeconds(6));
|
||||
|
||||
var script = Assert.Single(emitted);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.False(string.IsNullOrEmpty(script.RunId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stale_result_older_than_current_run_start_is_dropped()
|
||||
{
|
||||
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// CloudExperienceHost navigation (D-310): one agent.log line per page start/stop and per
|
||||
/// update-page event name, and for the OOBE update page itself (<c>OobeNDUP</c>,
|
||||
/// <c>RebootNDUP</c>) the <c>oobe_update_page</c> telemetry plus the registry state at its live
|
||||
/// start and stop. Descriptions follow the Shell-Core manifest templates; a 62407 value
|
||||
/// (URLs with tenant ids) leaves the tracker only as a boolean, an integer or an HRESULT.
|
||||
/// </summary>
|
||||
public sealed class ShellCoreTrackerBreadcrumbTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 2, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string NdupStarted = "CloudExperienceHost Web App Activity started. CXID: 'OobeNDUP'.";
|
||||
private const string EspPageStarted = "CloudExperienceHost Web App Activity started. CXID: 'MDMProgressAfterAADRefactored'.";
|
||||
private const string PageStopped = "CloudExperienceHost Web App Activity stopped. Result: 'success'.";
|
||||
private const string ScanStartedName = "CloudExperienceHost Web App Event 1. Name: 'ExpeditedUpdate_startWUScanStarted'.";
|
||||
private const string NavigateWithTenantUrl =
|
||||
"CloudExperienceHost Web App Event 2. Name: 'Navigate', Value: '{\"httpMethod\":\"GET\",\"url\":\"https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/authorize\"}'.";
|
||||
private const string DownloadSucceededWithValue =
|
||||
"CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded', Value: '{\"url\":\"https://login.microsoftonline.com/x\"}'.";
|
||||
private const string CspGateTrue =
|
||||
"CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_isNDUPAllowedByCSPSucceeded', Value: 'true'.";
|
||||
|
||||
// A French Windows labels the same fields "Résultat", "Nom" and "valeur" and quotes them
|
||||
// in guillemets; the event properties carry the values unchanged in every language.
|
||||
private const string FrenchNdupStarted = "Activité d’application web CloudExperienceHost démarrée. CXID : « OobeNDUP ».";
|
||||
private const string FrenchPageStopped = "Activité d’application web CloudExperienceHost arrêtée. Résultat : « success ».";
|
||||
private const string FrenchCspGate =
|
||||
"Événement d’application web CloudExperienceHost 2. Nom : « ExpeditedUpdate_isNDUPAllowedByCSPSucceeded », valeur : « true ».";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState> _registry = Array.Empty<OobeUpdateRegistrySnapshot.KeyState>();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ShellCoreTracker Build(string? stateDirectory = null) =>
|
||||
new ShellCoreTracker("S1", "T1",
|
||||
new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
helloTracker: null,
|
||||
oobeUpdateRegistryReader: () => _registry,
|
||||
stateDirectory: stateDirectory);
|
||||
|
||||
private string StateDirectory => Path.Combine(_tmp.Path, "state");
|
||||
|
||||
private static void Live(ShellCoreTracker tracker, int eventId, string description, DateTime at, long recordId) =>
|
||||
tracker.ProcessEvent(eventId, description, at, "Microsoft-Windows-Shell-Core", isBackfill: false, recordId: recordId);
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal posted) =>
|
||||
(IReadOnlyDictionary<string, object>)posted.TypedPayload!;
|
||||
|
||||
private static OobeUpdateRegistrySnapshot.KeyState NdupKey(string line) =>
|
||||
new OobeUpdateRegistrySnapshot.KeyState("setupOobeNdup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE\NDUP",
|
||||
exists: true, hasValues: true, line: line);
|
||||
|
||||
// ------------------------------------------------------------- breadcrumbs ----
|
||||
|
||||
[Fact]
|
||||
public void PageStart_NamesTheCxid()
|
||||
{
|
||||
Assert.Equal("CXH page started: OobeNDUP",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageStop_IsAttributedToTheLastStartedPage()
|
||||
{
|
||||
// 62405 names no page — it ends the one started last.
|
||||
Assert.Equal("CXH page stopped: OobeNDUP (result=success)",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: "OobeNDUP"));
|
||||
Assert.Equal("CXH page stopped: ? (result=success)",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePageEventNames_AreLogged_FromBothEventIds()
|
||||
{
|
||||
Assert.Equal("CXH event: ExpeditedUpdate_startWUScanStarted",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, lastPage: null));
|
||||
Assert.Equal("CXH event: ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEvent, DownloadSucceededWithValue, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsafeEventValues_NeverReachTheLog()
|
||||
{
|
||||
var crumb = ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, DownloadSucceededWithValue, lastPage: null);
|
||||
|
||||
Assert.NotNull(crumb);
|
||||
Assert.DoesNotContain("microsoftonline", crumb);
|
||||
Assert.DoesNotContain("url", crumb!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeEventValues_AreLogged()
|
||||
{
|
||||
Assert.Equal("CXH event: ExpeditedUpdate_isNDUPAllowedByCSPSucceeded (value=true)",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEvent, CspGateTrue, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherEventNames_AreNotLogged()
|
||||
{
|
||||
// Ordinary page traffic (Navigate, Visible, ESP progress) stays out of the log.
|
||||
Assert.Null(ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, NavigateWithTenantUrl, lastPage: "OobeNDUP"));
|
||||
Assert.Null(ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, "CommercialOOBE_ESPProgress_Page_Exiting", lastPage: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("CloudExperienceHost Web App Activity started.")]
|
||||
public void PageStartWithoutCxid_IsNoBreadcrumb(string description)
|
||||
{
|
||||
Assert.Null(ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStarted, description, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongTokens_AreBounded()
|
||||
{
|
||||
var longCxid = new string('X', 500);
|
||||
var crumb = ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppStarted, $"CloudExperienceHost Web App Activity started. CXID: '{longCxid}'.", lastPage: null);
|
||||
|
||||
Assert.NotNull(crumb);
|
||||
Assert.True(crumb!.Length < 200, crumb);
|
||||
Assert.EndsWith("…", crumb);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("OobeNDUP", true)]
|
||||
[InlineData("RebootNDUP", true)]
|
||||
[InlineData("EarlyRebootNDUP", true)]
|
||||
[InlineData("ExpeditedUpdate_startWUScanStarted", true)]
|
||||
[InlineData("AADHello", false)]
|
||||
[InlineData("MDMProgressAfterAADRefactored", false)]
|
||||
[InlineData(null, false)]
|
||||
public void UpdatePageTokens_AreRecognized(string? token, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ShellCoreTracker.IsUpdatePageToken(token!));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ update page records ----
|
||||
|
||||
[Fact]
|
||||
public void UpdatePageRecords_ArePageStartsStopsAndNames_OfTheUpdatePageOnly()
|
||||
{
|
||||
var start = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, lastPage: null);
|
||||
Assert.Equal(OobeUpdatePageRecord.PageStarted, start!.CxhEvent);
|
||||
Assert.Equal("OobeNDUP", start.Page);
|
||||
|
||||
var stop = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: "RebootNDUP");
|
||||
Assert.Equal(OobeUpdatePageRecord.PageStopped, stop!.CxhEvent);
|
||||
Assert.Equal("RebootNDUP", stop.Page);
|
||||
Assert.Equal("success", stop.Result);
|
||||
|
||||
var name = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, lastPage: null);
|
||||
Assert.Equal(OobeUpdatePageRecord.EventName, name!.CxhEvent);
|
||||
Assert.Equal("ExpeditedUpdate_startWUScanStarted", name.Name);
|
||||
Assert.Null(name.Value);
|
||||
|
||||
// Other pages and names are no update-page records.
|
||||
Assert.Null(ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, EspPageStarted, lastPage: null));
|
||||
Assert.Null(ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: "MDMProgressAfterAADRefactored"));
|
||||
Assert.Null(ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, NavigateWithTenantUrl, lastPage: "OobeNDUP"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePageRecord_CarriesOnlyASafeValue()
|
||||
{
|
||||
var unsafeValue = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, DownloadSucceededWithValue, lastPage: null);
|
||||
Assert.Null(unsafeValue!.Value);
|
||||
|
||||
var gate = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, CspGateTrue, lastPage: null);
|
||||
Assert.Equal("true", gate!.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("true", "true")]
|
||||
[InlineData("FALSE", "FALSE")]
|
||||
[InlineData("0", "0")]
|
||||
[InlineData("-2147024891", "-2147024891")]
|
||||
[InlineData("0x80240022", "0x80240022")]
|
||||
[InlineData("12345678901", null)] // more than ten digits
|
||||
[InlineData("0x123456789", null)] // more than eight hex digits
|
||||
[InlineData("https://login.microsoftonline.com/x", null)]
|
||||
[InlineData("{\"result\":0}", null)]
|
||||
[InlineData("true and more", null)]
|
||||
[InlineData("", null)]
|
||||
public void SafeValues_AreBooleansIntegersAndHresults(string value, string? expected)
|
||||
{
|
||||
var description = $"CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_x', Value: '{value}'.";
|
||||
|
||||
Assert.Equal(expected, ShellCoreTracker.ExtractSafeValue(description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveUpdatePage_IsReported_WithTheRegistryState_AndRaisesNothing()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
using var tracker = Build();
|
||||
var raised = 0;
|
||||
tracker.EspExited += (_, _) => raised++;
|
||||
tracker.EspFailureDetected += (_, _) => raised++;
|
||||
tracker.FinalizingSetupPhaseTriggered += (_, _) => raised++;
|
||||
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(1), "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1, EnableExpeditedUpdateSyncInstallCompleted=1") };
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(30), "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(new object[] { "page_started", "event_name", "page_stopped" }, pages.Select(p => Data(p)["cxhEvent"]).ToArray());
|
||||
Assert.All(pages, p => Assert.Equal(false, Data(p)["backfill"]));
|
||||
|
||||
var states = ByType(Constants.EventTypes.OobeUpdateState);
|
||||
Assert.Equal(new object[] { "update_page_started", "update_page_stopped" }, states.Select(s => Data(s)["moment"]).ToArray());
|
||||
Assert.All(states, s => Assert.Equal("OobeNDUP", Data(s)["page"]));
|
||||
|
||||
// Telemetry only — no ESP signal, no other event.
|
||||
Assert.Equal(0, raised);
|
||||
Assert.False(tracker.IsEspExitedForTest);
|
||||
Assert.Equal(pages.Count + states.Count, _sink.Posted.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherPages_AreNotReported()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
using var tracker = Build();
|
||||
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStarted, EspPageStarted, At, "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(1), "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_ReportsTheUpdatePage_WithItsEventTimes_ButReadsNoRegistry()
|
||||
{
|
||||
// The registry shows the state now, not at the record's time.
|
||||
var reads = 0;
|
||||
using var tracker = new ShellCoreTracker("S1", "T1",
|
||||
new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
helloTracker: null,
|
||||
oobeUpdateRegistryReader: () => { reads++; return new[] { NdupKey("EnableExpeditedUpdate=1") }; });
|
||||
|
||||
tracker.ReplayBackfillRecords(new[]
|
||||
{
|
||||
(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-4)),
|
||||
(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(-3)),
|
||||
(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(-2)),
|
||||
});
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(3, pages.Count);
|
||||
Assert.All(pages, p => Assert.Equal(true, Data(p)["backfill"]));
|
||||
Assert.Equal(At.AddMinutes(-4), pages[0].OccurredAtUtc);
|
||||
Assert.Equal(0, reads);
|
||||
// Nothing else: no ESP exit or failure was in the window, so no skipped-records trace.
|
||||
Assert.Equal(3, _sink.Posted.Count);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ localization ----
|
||||
|
||||
[Fact]
|
||||
public void LocalizedRecords_AreReadFromTheirProperties()
|
||||
{
|
||||
using var tracker = Build();
|
||||
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStarted, FrenchNdupStarted, At, "Microsoft-Windows-Shell-Core",
|
||||
isBackfill: false, properties: new[] { "OobeNDUP" });
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEvent, FrenchCspGate, At.AddSeconds(5), "Microsoft-Windows-Shell-Core",
|
||||
isBackfill: false, properties: new[] { "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true" });
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStopped, FrenchPageStopped, At.AddMinutes(1), "Microsoft-Windows-Shell-Core",
|
||||
isBackfill: false, properties: new[] { "success" });
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage).Select(Data).ToList();
|
||||
Assert.Equal(new object[] { "page_started", "event_name", "page_stopped" }, pages.Select(d => d["cxhEvent"]).ToArray());
|
||||
Assert.Equal("OobeNDUP", pages[0]["page"]);
|
||||
Assert.Equal("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", pages[1]["name"]);
|
||||
Assert.Equal("true", pages[1]["value"]);
|
||||
Assert.Equal("OobeNDUP", pages[2]["page"]);
|
||||
Assert.Equal("success", pages[2]["result"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalizedMessage_WithoutProperties_DoesNotYieldItsLabelledFields()
|
||||
{
|
||||
// Why the properties are read: the French labels are not the manifest's.
|
||||
Assert.Null(ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppStopped, FrenchPageStopped, properties: null).Primary);
|
||||
Assert.Null(ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppEvent, FrenchCspGate, properties: null).Primary);
|
||||
|
||||
Assert.Equal("success",
|
||||
ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppStopped, FrenchPageStopped, new[] { "success" }).Primary);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_x', Value: 'true'.")]
|
||||
[InlineData("CloudExperienceHost Web App Event 2. Name : « ExpeditedUpdate_x », Value : « true ».")]
|
||||
[InlineData("CloudExperienceHost Web App Event 2. Name\u00A0: «\u202FExpeditedUpdate_x\u202F», Value\u00A0: «\u00A0true\u00A0».")]
|
||||
public void MessageFallback_AcceptsQuotesAndGuillemets(string description)
|
||||
{
|
||||
var fields = ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppEvent, description, properties: null);
|
||||
|
||||
Assert.Equal("ExpeditedUpdate_x", fields.Primary);
|
||||
Assert.Equal("true", fields.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PropertyValue_LeavesTheTrackerOnlyInASafeForm()
|
||||
{
|
||||
var fields = ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppEvent, "",
|
||||
new[] { "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded", "{\"url\":\"https://login.microsoftonline.com/x\"}" });
|
||||
|
||||
var record = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, fields, lastPage: null);
|
||||
Assert.Equal("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded", record!.Name);
|
||||
Assert.Null(record.Value);
|
||||
Assert.DoesNotContain("microsoftonline",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEvent, fields, lastPage: null));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- restart watermark ----
|
||||
|
||||
[Fact]
|
||||
public void RestartBackfill_SkipsUpdatePageRecords_AnEarlierRunReported()
|
||||
{
|
||||
using (var first = Build(StateDirectory))
|
||||
{
|
||||
Live(first, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 500);
|
||||
Live(first, ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(1), recordId: 501);
|
||||
}
|
||||
|
||||
// The restart reads the minutes before it again — plus the stop the first run missed.
|
||||
using var second = Build(StateDirectory);
|
||||
second.LoadPageWatermark();
|
||||
second.ReplayBackfillRecords(new[]
|
||||
{
|
||||
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 500),
|
||||
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(1), recordId: 501),
|
||||
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(2), recordId: 502),
|
||||
});
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage).Select(Data).ToList();
|
||||
Assert.Equal(new object[] { "page_started", "event_name", "page_stopped" }, pages.Select(d => d["cxhEvent"]).ToArray());
|
||||
Assert.Equal(new object[] { false, false, true }, pages.Select(d => d["backfill"]).ToArray());
|
||||
// The skipped start still names the page the stop belongs to.
|
||||
Assert.Equal("OobeNDUP", pages[2]["page"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameRecord_FromTheLiveWatcherAndTheBackfill_IsReportedOnce()
|
||||
{
|
||||
// The live watcher is armed before the backfill runs, so both can deliver a record.
|
||||
using var tracker = Build(StateDirectory);
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 700);
|
||||
tracker.ReplayBackfillRecords(new[]
|
||||
{
|
||||
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 700),
|
||||
});
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_FollowsReportedRecordsOnly()
|
||||
{
|
||||
using var tracker = Build(StateDirectory);
|
||||
for (var i = 0; i <= OobeUpdateTelemetry.MaxPageEventsPerKey; i++)
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddSeconds(i), recordId: 800 + i);
|
||||
|
||||
// The repeat over the per-name cap is claimed for this run, not written down.
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey, ByType(Constants.EventTypes.OobeUpdatePage).Count);
|
||||
var state = JsonConvert.DeserializeObject<EventRecordWatermark.WatermarkState>(
|
||||
File.ReadAllText(Path.Combine(StateDirectory, ShellCoreTracker.PageWatermarkStateFileName)));
|
||||
Assert.Equal(800 + OobeUpdateTelemetry.MaxPageEventsPerKey - 1, state!.LastRecordId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordsWithoutARecordId_AreNotDeduplicated()
|
||||
{
|
||||
using var tracker = Build(StateDirectory);
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: -1);
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(5), recordId: -1);
|
||||
|
||||
Assert.Equal(2, ByType(Constants.EventTypes.OobeUpdatePage).Count);
|
||||
Assert.False(File.Exists(Path.Combine(StateDirectory, ShellCoreTracker.PageWatermarkStateFileName)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentStartAndStop_ReportTheRegistryState_StopOnce()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
var tracker = Build();
|
||||
|
||||
tracker.Start();
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1, ExpeditedUpdateStatus=2") };
|
||||
tracker.Stop();
|
||||
tracker.Stop();
|
||||
tracker.Dispose();
|
||||
|
||||
var states = ByType(Constants.EventTypes.OobeUpdateState);
|
||||
Assert.Equal(new object[] { "agent_start", "agent_stop" }, states.Select(s => Data(s)["moment"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackerThatNeverStarted_ReportsNothingAtStop()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
var tracker = Build();
|
||||
|
||||
tracker.Dispose();
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -84,6 +84,55 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
Assert.False(raised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62404_NGC_in_a_localized_message_is_read_from_the_cxid_property()
|
||||
{
|
||||
// A French Windows quotes the CXID in guillemets — the "'NGC'" substring never matched.
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = MakeTracker(tmp, new VirtualClock(ClockNow));
|
||||
var raised = 0;
|
||||
tracker.HelloWizardStarted += (_, _) => raised++;
|
||||
|
||||
tracker.ProcessEvent(
|
||||
eventId: ShellCoreTracker.EventId_ShellCore_WebAppStarted,
|
||||
description: "Activité d’application web CloudExperienceHost démarrée. CXID : « NGC ».",
|
||||
timestamp: ClockNow,
|
||||
providerName: "Microsoft-Windows-Shell-Core",
|
||||
isBackfill: false,
|
||||
properties: new[] { "NGC" });
|
||||
|
||||
Assert.Equal(1, raised);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AADHello", true)]
|
||||
[InlineData("NGC", true)]
|
||||
[InlineData("NGCPin", false)]
|
||||
[InlineData("OobeNDUP", false)]
|
||||
public void HelloWizardStart_is_decided_by_the_cxid(string cxid, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ShellCoreTracker.IsHelloWizardStart(cxid, description: "CXID: 'AADHello'"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_62404_reads_the_cxid_property()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = MakeTracker(tmp, new VirtualClock(ClockNow));
|
||||
var captured = new List<HelloWizardStartedEventArgs>();
|
||||
tracker.HelloWizardStarted += (_, args) => captured.Add(args);
|
||||
|
||||
var historical = ClockNow.AddMinutes(-3);
|
||||
tracker.ReplayBackfillRecords(new[]
|
||||
{
|
||||
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted,
|
||||
"Activité d’application web CloudExperienceHost démarrée. CXID : « NGC ».", historical,
|
||||
recordId: 42, properties: new[] { "NGC" }),
|
||||
});
|
||||
|
||||
Assert.Equal(historical, Assert.Single(captured).OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_62404_raises_once_with_historical_timestamp()
|
||||
{
|
||||
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using Xunit;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// D-316: IME saves every platform script's result under Policies\<user>\<policy> after reporting the
|
||||
/// batch; the observer checks the tracker's emitted run against it. The field case (session 756970cf): the run
|
||||
/// had been given a Win32 detection script's end block — exit 1 and "Detection Started" output — while IME saved
|
||||
/// the bootstrap output of the platform script itself.
|
||||
/// </summary>
|
||||
public sealed class ImeRegistryScriptResultObserverTests
|
||||
{
|
||||
private const string DeviceUser = "00000000-0000-0000-0000-000000000000";
|
||||
private const string PolicyA = "1d6f0a52-3c4e-4b7a-9e21-5f8c7d2b9a10";
|
||||
private const string PolicyB = "2b7d9e41-8c3a-4f60-a5d2-9e1b7c4f3a08";
|
||||
private const string OwnStdout = "=== bootstrap loader started ===\nInstaller finished (exit code 0).";
|
||||
private const string ForeignStdout = "VERBOSE: === Sample Suite Detection Started ===\nWARNING: === DETECTION FAILED ===";
|
||||
private static readonly DateTime T0 = new DateTime(2026, 10, 3, 20, 59, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── PlatformOutputJudge ─────────────────────────────────────────────────
|
||||
|
||||
private static PlatformScriptRun Run(string? stdout, string? stderr = null, int? exitCode = 0, string result = "Success", string resultSource = "ime_policy_result")
|
||||
=> new PlatformScriptRun { PolicyId = PolicyA, UserId = DeviceUser, RunId = "run-1", Emitted = true, Result = result, ResultSource = resultSource, ExitCode = exitCode, Stdout = stdout, Stderr = stderr };
|
||||
|
||||
private static string Details(string executionMessage)
|
||||
=> "{\"Version\":1,\"SigningCode\":15,\"EncryptionCode\":19,\"ExecutionMsg\":" + System.Text.Json.JsonSerializer.Serialize(executionMessage) + "}";
|
||||
|
||||
private static SavedScriptResult Saved(string? executionMessage, string result = "Success", int errorCode = 0, string user = DeviceUser, string policy = PolicyA, string stamp = "10/3/2026 9:00:00 PM")
|
||||
=> new SavedScriptResult
|
||||
{
|
||||
UserId = user,
|
||||
PolicyId = policy,
|
||||
Result = result,
|
||||
ErrorCode = errorCode,
|
||||
ResultDetails = executionMessage == null ? null : Details(executionMessage),
|
||||
LastUpdatedTimeUtc = stamp,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Field_case_detection_output_on_the_platform_run_is_foreign_and_the_saved_stdout_replaces_it()
|
||||
{
|
||||
var judgement = PlatformOutputJudge.Judge(Run(ForeignStdout, string.Empty, exitCode: 1), Saved(OwnStdout.Replace("\n", "\r\n")));
|
||||
Assert.Equal(PlatformOutputVerdict.Foreign, judgement.Verdict);
|
||||
Assert.Equal("stdout_differs", judgement.Reason);
|
||||
Assert.Equal(OwnStdout, judgement.Stdout);
|
||||
Assert.Equal(string.Empty, judgement.Stderr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matching_output_is_verified_across_line_ending_and_trim_differences()
|
||||
{
|
||||
var judgement = PlatformOutputJudge.Judge(Run(OwnStdout, string.Empty), Saved(" " + OwnStdout.Replace("\n", "\r\n") + "\r\n"));
|
||||
Assert.Equal(PlatformOutputVerdict.Verified, judgement.Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Output_cut_by_the_tracker_is_verified_against_the_longer_saved_text()
|
||||
{
|
||||
var full = string.Concat(Enumerable.Repeat("line of output\n", 1000)).TrimEnd();
|
||||
var emitted = ImeLogTracker.TruncateOutput(full);
|
||||
Assert.EndsWith(ImeLogTracker.OutputTruncationMarker, emitted);
|
||||
Assert.Equal(PlatformOutputVerdict.Verified, PlatformOutputJudge.Judge(Run(emitted), Saved(full)).Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_empty_is_verified_but_stderr_on_a_success_is_another_executors()
|
||||
{
|
||||
Assert.Equal(PlatformOutputVerdict.Verified, PlatformOutputJudge.Judge(Run(string.Empty), Saved(string.Empty)).Verdict);
|
||||
var judgement = PlatformOutputJudge.Judge(Run(string.Empty, "Get-Item : not found"), Saved(string.Empty));
|
||||
Assert.Equal(PlatformOutputVerdict.Foreign, judgement.Verdict);
|
||||
Assert.Equal("stderr_on_success", judgement.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicitly_empty_emitted_output_against_saved_text_is_foreign()
|
||||
{
|
||||
Assert.Equal(PlatformOutputVerdict.Foreign, PlatformOutputJudge.Judge(Run(string.Empty), Saved(OwnStdout)).Verdict);
|
||||
Assert.Equal(PlatformOutputVerdict.Foreign, PlatformOutputJudge.Judge(Run(OwnStdout), Saved(string.Empty)).Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Output_never_read_is_completed_from_the_saved_text_and_the_exit_code_stands()
|
||||
{
|
||||
var judgement = PlatformOutputJudge.Judge(Run(null), Saved(OwnStdout));
|
||||
Assert.Equal(PlatformOutputVerdict.Repaired, judgement.Verdict);
|
||||
Assert.Equal("stdout_missing", judgement.Reason);
|
||||
Assert.Equal(OwnStdout, judgement.Stdout);
|
||||
Assert.Equal(PlatformOutputVerdict.NotComparable, PlatformOutputJudge.Judge(Run(null), Saved(string.Empty)).Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stdout_containing_the_output_error_separator_is_repaired_not_foreign()
|
||||
{
|
||||
// "write output done. output = X, error = " with X = "Copying files, error = 0 errors\nDone": the
|
||||
// lazy split leaves "Copying files" as stdout and the rest as stderr.
|
||||
var judgement = PlatformOutputJudge.Judge(
|
||||
Run("Copying files", "0 errors\nDone, error ="),
|
||||
Saved("Copying files, error = 0 errors\r\nDone"));
|
||||
Assert.Equal(PlatformOutputVerdict.Repaired, judgement.Verdict);
|
||||
Assert.Equal("stdout_split", judgement.Reason);
|
||||
Assert.Equal("Copying files, error = 0 errors\nDone", judgement.Stdout);
|
||||
Assert.Equal(string.Empty, judgement.Stderr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_prefix_without_the_split_explanation_is_foreign()
|
||||
{
|
||||
Assert.Equal(PlatformOutputVerdict.Foreign, PlatformOutputJudge.Judge(Run("Detected"), Saved("Detected and repaired")).Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_with_stderr_is_judged_on_stderr()
|
||||
{
|
||||
Assert.Equal(PlatformOutputVerdict.Verified,
|
||||
PlatformOutputJudge.Judge(Run("partial", "Access denied", 1, "Failed"), Saved("Access denied\r\n", "Failed", 99)).Verdict);
|
||||
|
||||
var missing = PlatformOutputJudge.Judge(Run(null, null, 1, "Failed"), Saved("Access denied", "Failed", 99));
|
||||
Assert.Equal(PlatformOutputVerdict.Repaired, missing.Verdict);
|
||||
Assert.Null(missing.Stdout);
|
||||
Assert.Equal("Access denied", missing.Stderr);
|
||||
|
||||
var foreign = PlatformOutputJudge.Judge(Run("Detected", string.Empty, 0, "Failed"), Saved("Access denied", "Failed", 99));
|
||||
Assert.Equal(PlatformOutputVerdict.Foreign, foreign.Verdict);
|
||||
Assert.Equal(string.Empty, foreign.Stdout);
|
||||
Assert.Equal("Access denied", foreign.Stderr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Results_without_saved_output_or_from_another_run_are_not_compared()
|
||||
{
|
||||
Assert.Equal("no_saved_output", PlatformOutputJudge.Judge(Run(OwnStdout, null, 1, "Failed"), Saved("1", "Failed", 1)).Reason);
|
||||
var hashMismatch = new SavedScriptResult { UserId = DeviceUser, PolicyId = PolicyA, Result = "Failed", ErrorCode = 2, ResultDetails = "Hash mismatch" };
|
||||
Assert.Equal("no_execution_message", PlatformOutputJudge.Judge(Run(OwnStdout, null, 1, "Failed"), hashMismatch).Reason);
|
||||
Assert.Equal("result_differs", PlatformOutputJudge.Judge(Run(OwnStdout), Saved("Access denied", "Failed", 99)).Reason);
|
||||
// A result derived from the exit code (fallback) is not IME's — the saved one is.
|
||||
Assert.Equal(PlatformOutputVerdict.Verified,
|
||||
PlatformOutputJudge.Judge(Run(OwnStdout, null, 1, "Failed", "agentexecutor_fallback"), Saved(OwnStdout)).Verdict);
|
||||
}
|
||||
|
||||
// ── PlatformScriptRunRegister ───────────────────────────────────────────
|
||||
|
||||
private static ScriptExecutionState Emitted(string policyId, string? stdout, int? exitCode = 0, string? userId = DeviceUser, string runId = "run-1")
|
||||
=> new ScriptExecutionState { PolicyId = policyId, ScriptType = "platform", UserId = userId, RunId = runId, Result = "Success", ResultSource = "ime_policy_result", ExitCode = exitCode, Stdout = stdout };
|
||||
|
||||
[Fact]
|
||||
public void A_save_is_paired_only_after_the_tracker_read_past_it_and_the_run_was_emitted()
|
||||
{
|
||||
var runs = new PlatformScriptRunRegister();
|
||||
runs.NoteStarted(DeviceUser, PolicyA, T0);
|
||||
var seen = T0.AddSeconds(30);
|
||||
|
||||
Assert.Equal(PlatformRunClaim.Wait, runs.TryClaim(DeviceUser, PolicyA, seen, out _));
|
||||
runs.NotePassCompleted(seen.AddSeconds(1));
|
||||
Assert.Equal(PlatformRunClaim.Wait, runs.TryClaim(DeviceUser, PolicyA, seen, out _)); // pass started too early
|
||||
runs.NotePassCompleted(seen + PlatformScriptRunRegister.TrackerCatchUp);
|
||||
Assert.Equal(PlatformRunClaim.Wait, runs.TryClaim(DeviceUser, PolicyA, seen, out _)); // run not emitted yet
|
||||
|
||||
runs.NoteEmitted(Emitted(PolicyA, OwnStdout), T0.AddSeconds(40));
|
||||
Assert.Equal(PlatformRunClaim.Claimed, runs.TryClaim(DeviceUser, PolicyA, seen, out var run));
|
||||
Assert.Equal("run-1", run!.RunId);
|
||||
Assert.Equal(OwnStdout, run.Stdout);
|
||||
Assert.Equal(PlatformRunClaim.None, runs.TryClaim(DeviceUser, PolicyA, seen, out _)); // never twice
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_start_replaces_the_latest_run_of_its_key_and_other_users_stay_apart()
|
||||
{
|
||||
var runs = new PlatformScriptRunRegister();
|
||||
runs.NoteStarted(DeviceUser, PolicyA, T0);
|
||||
runs.NoteEmitted(Emitted(PolicyA, "first run"), T0.AddSeconds(5));
|
||||
runs.NoteStarted(DeviceUser, PolicyA, T0.AddMinutes(30));
|
||||
runs.NoteEmitted(Emitted(PolicyA, "second run", runId: "run-2"), T0.AddMinutes(30).AddSeconds(5));
|
||||
const string user = "6c3f8a20-1d5e-4b97-8f4a-0e2d6b9c7a13";
|
||||
runs.NoteStarted(user, PolicyA, T0.AddMinutes(31));
|
||||
runs.NotePassCompleted(T0.AddHours(1));
|
||||
|
||||
Assert.Equal(PlatformRunClaim.Claimed, runs.TryClaim(DeviceUser, PolicyA, T0.AddMinutes(31), out var run));
|
||||
Assert.Equal("second run", run!.Stdout);
|
||||
Assert.Equal(PlatformRunClaim.Wait, runs.TryClaim(user, PolicyA, T0.AddMinutes(32), out _));
|
||||
Assert.Equal(PlatformRunClaim.None, runs.TryClaim(DeviceUser, PolicyB, T0.AddMinutes(32), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_run_without_a_readable_user_id_is_claimed_by_its_policy()
|
||||
{
|
||||
var runs = new PlatformScriptRunRegister();
|
||||
runs.NoteStarted(null, PolicyA, T0);
|
||||
runs.NoteEmitted(Emitted(PolicyA, OwnStdout, userId: null), T0.AddSeconds(5));
|
||||
runs.NotePassCompleted(T0.AddMinutes(5));
|
||||
Assert.Equal(PlatformRunClaim.Claimed, runs.TryClaim(DeviceUser, PolicyA, T0.AddMinutes(1), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_keeps_the_newest_runs_and_its_counters_report_each_change_once()
|
||||
{
|
||||
var runs = new PlatformScriptRunRegister();
|
||||
for (var i = 0; i <= PlatformScriptRunRegister.MaxRuns; i++)
|
||||
runs.NoteStarted(DeviceUser, Guid.NewGuid().ToString("D"), T0.AddSeconds(i));
|
||||
runs.NoteStarted(DeviceUser, PolicyA, T0.AddHours(1));
|
||||
runs.NoteEmitted(Emitted(PolicyA, OwnStdout), T0.AddHours(1));
|
||||
runs.NotePassCompleted(T0.AddHours(2));
|
||||
Assert.Equal(PlatformRunClaim.Claimed, runs.TryClaim(DeviceUser, PolicyA, T0.AddHours(1), out _));
|
||||
|
||||
Assert.False(runs.ConsumeCountersChanged());
|
||||
runs.CountVerified();
|
||||
runs.CountCorrected();
|
||||
Assert.True(runs.ConsumeCountersChanged());
|
||||
Assert.False(runs.ConsumeCountersChanged());
|
||||
Assert.Equal(1, runs.Verified);
|
||||
runs.RestoreCounters(7, 3);
|
||||
Assert.Equal(7, runs.Verified);
|
||||
Assert.Equal(3, runs.Corrected);
|
||||
}
|
||||
|
||||
// ── observer flow ───────────────────────────────────────────────────────
|
||||
|
||||
private sealed class Fixture
|
||||
{
|
||||
public readonly FakeSignalIngressSink Sink = new FakeSignalIngressSink();
|
||||
public readonly VirtualClock Clock = new VirtualClock(T0);
|
||||
public readonly PlatformScriptRunRegister Runs = new PlatformScriptRunRegister();
|
||||
public Dictionary<string, SavedScriptResult> Registry = new Dictionary<string, SavedScriptResult>(StringComparer.OrdinalIgnoreCase);
|
||||
public readonly ImeRegistryScriptResultObserver Observer;
|
||||
|
||||
public Fixture()
|
||||
{
|
||||
Observer = new ImeRegistryScriptResultObserver(Runs, new InformationalEventPost(Sink, Clock), null, Clock,
|
||||
() => new Dictionary<string, SavedScriptResult>(Registry, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public void Save(SavedScriptResult saved) => Registry[ImeRegistryScriptResultObserver.KeyOf(saved.UserId, saved.PolicyId)] = saved;
|
||||
|
||||
/// <summary>The tracker emitted a run, then kept polling past the save.</summary>
|
||||
public void EmitRun(string policyId, string? stdout, int? exitCode, string runId)
|
||||
{
|
||||
Runs.NoteStarted(DeviceUser, policyId, Clock.UtcNow);
|
||||
Runs.NoteEmitted(Emitted(policyId, stdout, exitCode, runId: runId), Clock.UtcNow);
|
||||
}
|
||||
|
||||
public void TrackerCaughtUp() => Runs.NotePassCompleted(Clock.UtcNow);
|
||||
|
||||
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> Corrections()
|
||||
=> Sink.Posted.Where(p => p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == SharedEventTypes.ScriptOutputReconciliation).ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Field_case_end_to_end_the_save_corrects_the_run_with_a_warning_naming_it()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.Save(Saved("an older run of another policy", policy: PolicyB, stamp: "10/2/2026 8:00:00 AM"));
|
||||
f.Observer.Tick("baseline");
|
||||
|
||||
f.EmitRun(PolicyA, ForeignStdout, 1, "run-a");
|
||||
f.Save(Saved(OwnStdout));
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||
f.Observer.Tick("registry_change");
|
||||
Assert.Empty(f.Corrections()); // the tracker has not read past the save yet
|
||||
|
||||
f.Clock.Advance(PlatformScriptRunRegister.TrackerCatchUp);
|
||||
f.TrackerCaughtUp();
|
||||
f.Observer.Tick("periodic");
|
||||
|
||||
var correction = Assert.Single(f.Corrections());
|
||||
var data = correction.Payload!;
|
||||
Assert.Equal(PolicyA, data["policyId"]);
|
||||
Assert.Equal("platform", data["scriptType"]);
|
||||
Assert.Equal("run-a", data["runId"]);
|
||||
Assert.Equal("foreign", data["outcome"]);
|
||||
Assert.Equal("stdout_differs", data["reason"]);
|
||||
Assert.Equal("Success", data["result"]);
|
||||
Assert.Equal(OwnStdout, data["stdout"]);
|
||||
Assert.Equal(string.Empty, data["stderr"]);
|
||||
Assert.Equal("1", data["previousExitCode"]);
|
||||
Assert.Equal("0", data["registryErrorCode"]);
|
||||
Assert.Equal("Warning", data[SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", data[SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.DoesNotContain("exitCode", data.Keys);
|
||||
Assert.Equal(1, f.Runs.Corrected);
|
||||
Assert.Equal(0, f.Runs.Verified);
|
||||
|
||||
// The same save read again changes nothing.
|
||||
f.Clock.Advance(TimeSpan.FromMinutes(1));
|
||||
f.TrackerCaughtUp();
|
||||
f.Observer.Tick("periodic");
|
||||
Assert.Single(f.Corrections());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Confirmed_output_is_counted_and_emits_nothing()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.Observer.Tick("baseline");
|
||||
f.EmitRun(PolicyA, OwnStdout, 0, "run-a");
|
||||
f.Save(Saved(OwnStdout));
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||
f.Observer.Tick("registry_change");
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(3));
|
||||
f.TrackerCaughtUp();
|
||||
f.Observer.Tick("periodic");
|
||||
|
||||
Assert.Empty(f.Corrections());
|
||||
Assert.Equal(1, f.Runs.Verified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Completed_output_is_an_info_event_that_keeps_the_exit_code()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.Observer.Tick("baseline");
|
||||
f.EmitRun(PolicyA, null, 0, "run-a");
|
||||
f.Save(Saved(OwnStdout));
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||
f.Observer.Tick("registry_change");
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(3));
|
||||
f.TrackerCaughtUp();
|
||||
f.Observer.Tick("periodic");
|
||||
|
||||
var data = Assert.Single(f.Corrections()).Payload!;
|
||||
Assert.Equal("repaired", data["outcome"]);
|
||||
Assert.Equal("Info", data[SignalPayloadKeys.Severity]);
|
||||
Assert.DoesNotContain("previousExitCode", data.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_save_seen_before_its_run_was_emitted_waits_for_the_emit()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.Observer.Tick("baseline");
|
||||
f.Runs.NoteStarted(DeviceUser, PolicyA, f.Clock.UtcNow); // result held for its end block
|
||||
f.Save(Saved(OwnStdout));
|
||||
f.Observer.Tick("registry_change");
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(3));
|
||||
f.TrackerCaughtUp();
|
||||
f.Observer.Tick("periodic");
|
||||
Assert.Equal(0, f.Runs.Verified + f.Runs.Corrected);
|
||||
|
||||
f.Runs.NoteEmitted(Emitted(PolicyA, ForeignStdout, 1, runId: "run-a"), f.Clock.UtcNow);
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(60));
|
||||
f.Observer.Tick("periodic");
|
||||
Assert.Equal("foreign", Assert.Single(f.Corrections()).Payload!["outcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_save_no_run_claims_within_the_expiry_is_dropped()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.Observer.Tick("baseline");
|
||||
f.Save(Saved(OwnStdout));
|
||||
f.Observer.Tick("registry_change");
|
||||
f.Clock.Advance(ImeRegistryScriptResultObserver.PendingExpiry);
|
||||
f.TrackerCaughtUp();
|
||||
f.Runs.NoteStarted(DeviceUser, PolicyA, f.Clock.UtcNow);
|
||||
f.Observer.Tick("periodic"); // waits for the emit — and expires
|
||||
|
||||
f.Runs.NoteEmitted(Emitted(PolicyA, ForeignStdout, 1, runId: "run-a"), f.Clock.UtcNow);
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(60));
|
||||
f.TrackerCaughtUp();
|
||||
f.Observer.Tick("periodic");
|
||||
Assert.Empty(f.Corrections());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saves_from_before_the_agent_started_are_the_silent_baseline()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.EmitRun(PolicyA, ForeignStdout, 1, "run-a");
|
||||
f.Save(Saved(OwnStdout));
|
||||
f.TrackerCaughtUp();
|
||||
f.Observer.Tick("baseline");
|
||||
f.Clock.Advance(TimeSpan.FromMinutes(1));
|
||||
f.TrackerCaughtUp();
|
||||
f.Observer.Tick("periodic");
|
||||
|
||||
Assert.Empty(f.Corrections());
|
||||
Assert.Equal(0, f.Runs.Verified + f.Runs.Corrected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Corrections_beyond_the_cap_are_counted_but_not_emitted()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.Observer.Tick("baseline");
|
||||
var policies = Enumerable.Range(0, ImeRegistryScriptResultObserver.MaxCorrectionsPerSession + 3).Select(_ => Guid.NewGuid().ToString("D")).ToList();
|
||||
foreach (var policy in policies)
|
||||
{
|
||||
f.EmitRun(policy, ForeignStdout, 1, "run-" + policy);
|
||||
f.Save(Saved(OwnStdout, policy: policy));
|
||||
}
|
||||
f.Observer.Tick("registry_change");
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(3));
|
||||
f.TrackerCaughtUp();
|
||||
f.Observer.Tick("periodic");
|
||||
|
||||
Assert.Equal(ImeRegistryScriptResultObserver.MaxCorrectionsPerSession, f.Corrections().Count);
|
||||
Assert.Equal(policies.Count, f.Runs.Corrected);
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// The OOBE update registry snapshot (D-310, S4/S5): one readable, bounded line per key, for
|
||||
/// agent.log and the <c>oobe_update_state</c> event. The registry read itself is a thin wrapper.
|
||||
/// </summary>
|
||||
public sealed class OobeUpdateRegistrySnapshotTests
|
||||
{
|
||||
private static KeyValuePair<string, object> Entry(string name, object value) => new KeyValuePair<string, object>(name, value);
|
||||
|
||||
[Fact]
|
||||
public void Values_AreFormattedByType()
|
||||
{
|
||||
Assert.Equal("1", OobeUpdateRegistrySnapshot.FormatValue(1));
|
||||
Assert.Equal("5000000000", OobeUpdateRegistrySnapshot.FormatValue(5000000000L));
|
||||
Assert.Equal("Succeeded", OobeUpdateRegistrySnapshot.FormatValue("Succeeded"));
|
||||
Assert.Equal("a|b", OobeUpdateRegistrySnapshot.FormatValue(new[] { "a", "b" }));
|
||||
Assert.Equal("(binary 3 bytes)", OobeUpdateRegistrySnapshot.FormatValue(new byte[] { 1, 2, 3 }));
|
||||
Assert.Equal("(null)", OobeUpdateRegistrySnapshot.FormatValue(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongValues_AreTruncated()
|
||||
{
|
||||
var formatted = OobeUpdateRegistrySnapshot.FormatValue(new string('x', 500));
|
||||
Assert.Equal(OobeUpdateRegistrySnapshot.MaxValueLength + 1, formatted.Length);
|
||||
Assert.EndsWith("…", formatted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entries_AreOrderedByName()
|
||||
{
|
||||
var line = OobeUpdateRegistrySnapshot.FormatEntries(new[]
|
||||
{
|
||||
Entry("ExpeditedUpdateStatus", 2),
|
||||
Entry("EnableExpeditedUpdate", 1),
|
||||
Entry("", "default"),
|
||||
}, oobeOnly: false);
|
||||
|
||||
Assert.Equal("(default)=default, EnableExpeditedUpdate=1, ExpeditedUpdateStatus=2", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyManagerSystemKey_KeepsOnlyOobeValues()
|
||||
{
|
||||
// That key holds every System policy of the device; only the OOBE ones matter here.
|
||||
var line = OobeUpdateRegistrySnapshot.FormatEntries(new[]
|
||||
{
|
||||
Entry("AllowTelemetry", 1),
|
||||
Entry("AllowOOBEUpdates", 1),
|
||||
Entry("AllowOOBEUpdates_ProviderSet", 1),
|
||||
}, oobeOnly: true);
|
||||
|
||||
Assert.Equal("AllowOOBEUpdates=1, AllowOOBEUpdates_ProviderSet=1", line);
|
||||
Assert.Equal("(no OOBE values)", OobeUpdateRegistrySnapshot.FormatEntries(new[] { Entry("AllowTelemetry", 1) }, oobeOnly: true));
|
||||
Assert.Equal("(no values)", OobeUpdateRegistrySnapshot.FormatEntries(new KeyValuePair<string, object>[0], oobeOnly: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyEntries_AreCapped_AndSaySo()
|
||||
{
|
||||
var entries = Enumerable.Range(0, OobeUpdateRegistrySnapshot.MaxEntries + 5)
|
||||
.Select(i => Entry($"Value{i:000}", i));
|
||||
|
||||
var line = OobeUpdateRegistrySnapshot.FormatEntries(entries, oobeOnly: false);
|
||||
|
||||
Assert.EndsWith("… (+5 more)", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_CountsOnlyOobeValues_OfThePolicyManagerSystemKey()
|
||||
{
|
||||
var policy = OobeUpdateRegistrySnapshot.Describe("policyManagerSystem", OobeUpdateRegistrySnapshot.PolicyManagerSystemKey,
|
||||
new[] { Entry("AllowTelemetry", 1) }, new string[0]);
|
||||
Assert.True(policy.Exists);
|
||||
Assert.False(policy.HasValues);
|
||||
|
||||
var oobe = OobeUpdateRegistrySnapshot.Describe("policyManagerSystem", OobeUpdateRegistrySnapshot.PolicyManagerSystemKey,
|
||||
new[] { Entry("AllowTelemetry", 1), Entry("AllowOOBEUpdates", 1) }, new string[0]);
|
||||
Assert.True(oobe.HasValues);
|
||||
Assert.Equal("AllowOOBEUpdates=1", oobe.Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_SubkeysAlone_AreNoValues()
|
||||
{
|
||||
var state = OobeUpdateRegistrySnapshot.Describe("ndup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\NDUP",
|
||||
new KeyValuePair<string, object>[0], new[] { "Updates" });
|
||||
|
||||
Assert.False(state.HasValues);
|
||||
Assert.Equal("(no values) | subkeys: Updates", state.Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keys_HaveDistinctAliases()
|
||||
{
|
||||
// The aliases are the data keys of oobe_update_state.
|
||||
Assert.Equal(OobeUpdateRegistrySnapshot.Keys.Length,
|
||||
OobeUpdateRegistrySnapshot.Keys.Select(k => k.Alias).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_ReturnsEveryKey_FromTheRealRegistry()
|
||||
{
|
||||
var states = OobeUpdateRegistrySnapshot.Read();
|
||||
|
||||
Assert.Equal(OobeUpdateRegistrySnapshot.Keys.Select(k => k.Path), states.Select(s => s.Path));
|
||||
Assert.All(states.Where(s => !s.Exists), s => Assert.False(s.HasValues));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_ToleratesMissingLoggerOrStates()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
OobeUpdateRegistrySnapshot.Log(new AgentLogger(tmp.Path, AgentLogLevel.Info), "test", OobeUpdateRegistrySnapshot.Read());
|
||||
OobeUpdateRegistrySnapshot.Log(null!, "test", OobeUpdateRegistrySnapshot.Read());
|
||||
OobeUpdateRegistrySnapshot.Log(new AgentLogger(tmp.Path, AgentLogLevel.Info), "test", null!);
|
||||
}
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Measurement telemetry of the OOBE update page (D-310): <c>oobe_update_page</c> per
|
||||
/// update-page record and <c>oobe_update_state</c> for the registry behind it — Debug, bounded
|
||||
/// per run, the state only when a key holds values and the content changed.
|
||||
/// </summary>
|
||||
public sealed class OobeUpdateTelemetryTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 2, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState> _registry = Array.Empty<OobeUpdateRegistrySnapshot.KeyState>();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private OobeUpdateTelemetry Build(Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>>? reader = null) =>
|
||||
new OobeUpdateTelemetry("sess-oobe", "tenant-oobe",
|
||||
new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
reader ?? (() => _registry));
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal posted) =>
|
||||
(IReadOnlyDictionary<string, object>)posted.TypedPayload!;
|
||||
|
||||
private static OobeUpdateRegistrySnapshot.KeyState Key(string alias, string? line) =>
|
||||
line == null
|
||||
? new OobeUpdateRegistrySnapshot.KeyState(alias, $@"SOFTWARE\{alias}", exists: false, hasValues: false, line: null)
|
||||
: new OobeUpdateRegistrySnapshot.KeyState(alias, $@"SOFTWARE\{alias}", exists: true, hasValues: true, line: line);
|
||||
|
||||
// ------------------------------------------------------------- oobe_update_page ----
|
||||
|
||||
[Fact]
|
||||
public void Page_IsReported_WithItsEventTime_AndFields()
|
||||
{
|
||||
Build().ReportPage(
|
||||
new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "OobeNDUP", result: "success"),
|
||||
At.AddMinutes(-3), isBackfill: true);
|
||||
|
||||
var page = Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage));
|
||||
Assert.Equal(At.AddMinutes(-3), page.OccurredAtUtc);
|
||||
Assert.Equal("Debug", page.Payload![SignalPayloadKeys.Severity]);
|
||||
var data = Data(page);
|
||||
Assert.Equal("page_stopped", data["cxhEvent"]);
|
||||
Assert.Equal("OobeNDUP", data["page"]);
|
||||
Assert.Equal("success", data["result"]);
|
||||
Assert.Equal(true, data["backfill"]);
|
||||
Assert.Equal(1, data["occurrence"]);
|
||||
Assert.False(data.ContainsKey("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventName_CarriesItsSafeValue()
|
||||
{
|
||||
Build().ReportPage(
|
||||
new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62407, name: "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", value: "true"),
|
||||
At, isBackfill: false);
|
||||
|
||||
var data = Data(Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage)));
|
||||
Assert.Equal("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", data["name"]);
|
||||
Assert.Equal("true", data["value"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SamePageOrName_IsReportedAtMostThreeTimes()
|
||||
{
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_progress"), At.AddSeconds(i), isBackfill: false);
|
||||
}
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "OobeNDUP", result: "success"), At.AddMinutes(1), isBackfill: false);
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey + 1, pages.Count);
|
||||
Assert.Equal(new object[] { 1, 2, 3 }, pages.Take(3).Select(p => Data(p)["occurrence"]).ToArray());
|
||||
// The repeats do not crowd out the page end.
|
||||
Assert.Equal("page_stopped", Data(pages.Last())["cxhEvent"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageEvents_AreCappedPerRun()
|
||||
{
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxPageEventsPerRun + 20; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false);
|
||||
}
|
||||
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun, ByType(Constants.EventTypes.OobeUpdatePage).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageStartAndStop_AreReported_AfterTheNameBudgetIsSpent()
|
||||
{
|
||||
// The time attribution reads the update's span from the page start and stop.
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxPageEventsPerRun; i++)
|
||||
{
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false));
|
||||
}
|
||||
|
||||
Assert.False(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_late"), At, isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, 62404, page: "RebootNDUP"), At.AddMinutes(1), isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "RebootNDUP", result: "success"), At.AddMinutes(2), isBackfill: false));
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun + 2, pages.Count);
|
||||
Assert.Equal(new object[] { "page_started", "page_stopped" },
|
||||
pages.Skip(OobeUpdateTelemetry.MaxPageEventsPerRun).Select(p => Data(p)["cxhEvent"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SamePageStart_IsReportedAtMostThreeTimes()
|
||||
{
|
||||
// Outside the run budget, but a page that restarts in a loop still cannot flood.
|
||||
var telemetry = Build();
|
||||
var reported = Enumerable.Range(0, 10)
|
||||
.Count(i => telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, 62404, page: "OobeNDUP"), At.AddMinutes(i), isBackfill: false));
|
||||
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey, reported);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ oobe_update_state ----
|
||||
|
||||
[Fact]
|
||||
public void State_WithoutValues_IsNotReported()
|
||||
{
|
||||
_registry = new[] { Key("setupOobeNdup", null), Key("policiesOobe", null) };
|
||||
|
||||
Build().ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.OobeUpdateState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_ReportsOnlyKeysWithValues_UnderTheirAlias()
|
||||
{
|
||||
_registry = new[]
|
||||
{
|
||||
Key("setupOobeNdup", "EnableExpeditedUpdate=1, EnableExpeditedUpdateSyncDownloadCompleted=1"),
|
||||
Key("ndup", null),
|
||||
new OobeUpdateRegistrySnapshot.KeyState("policyManagerSystem", "x", exists: true, hasValues: false, line: "(no OOBE values)"),
|
||||
Key("espSetupPolicy", "InstallQualityUpdates=1"),
|
||||
};
|
||||
|
||||
Build().ReportState(OobeUpdateTelemetry.Moments.UpdatePageStopped, "OobeNDUP");
|
||||
|
||||
var state = Assert.Single(ByType(Constants.EventTypes.OobeUpdateState));
|
||||
Assert.Equal("Debug", state.Payload![SignalPayloadKeys.Severity]);
|
||||
var data = Data(state);
|
||||
Assert.Equal("update_page_stopped", data["moment"]);
|
||||
Assert.Equal("OobeNDUP", data["page"]);
|
||||
Assert.Equal(2, data["keysWithValues"]);
|
||||
Assert.Equal("EnableExpeditedUpdate=1, EnableExpeditedUpdateSyncDownloadCompleted=1", data["setupOobeNdup"]);
|
||||
Assert.Equal("InstallQualityUpdates=1", data["espSetupPolicy"]);
|
||||
Assert.False(data.ContainsKey("ndup"));
|
||||
Assert.False(data.ContainsKey("policyManagerSystem"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnchangedState_IsReportedOnce_AChangeAgain()
|
||||
{
|
||||
var telemetry = Build();
|
||||
_registry = new[] { Key("espSetupPolicy", "InstallQualityUpdates=1") };
|
||||
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.UpdatePageStarted, "OobeNDUP");
|
||||
_registry = new[] { Key("espSetupPolicy", "InstallQualityUpdates=1"), Key("setupOobeNdup", "EnableExpeditedUpdate=1") };
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.UpdatePageStopped, "OobeNDUP");
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.AgentStop);
|
||||
|
||||
var states = ByType(Constants.EventTypes.OobeUpdateState);
|
||||
Assert.Equal(new object[] { "agent_start", "update_page_stopped" }, states.Select(s => Data(s)["moment"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateEvents_AreCappedPerRun()
|
||||
{
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxStateEventsPerRun + 4; i++)
|
||||
{
|
||||
_registry = new[] { Key("setupOobeNdup", $"ExpeditedUpdateStatus={i}") };
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.UpdatePageStarted, "OobeNDUP");
|
||||
}
|
||||
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxStateEventsPerRun, ByType(Constants.EventTypes.OobeUpdateState).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongLines_AreTruncated()
|
||||
{
|
||||
_registry = new[] { Key("ndupUpdates", new string('x', 3000)) };
|
||||
|
||||
Build().ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
|
||||
var line = (string)Data(Assert.Single(ByType(Constants.EventTypes.OobeUpdateState)))["ndupUpdates"];
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxStateLineLength + 1, line.Length);
|
||||
Assert.EndsWith("…", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailingRegistryRead_ReportsNothing_AndDoesNotThrow()
|
||||
{
|
||||
Build(() => throw new UnauthorizedAccessException("denied")).ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// ServicingTracker (D-310) — the CBS servicing steps of OS updates from the Setup log. The
|
||||
/// sequence and XML shapes are those Windows 11 writes for a cumulative update installed by
|
||||
/// <c>UpdateAgentLCU</c>: initiate Absent → Staged, Staged reached, initiate Staged → Installed,
|
||||
/// restart required, Installed reached after the restart.
|
||||
/// </summary>
|
||||
public sealed class ServicingTrackerTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 25, 18, 52, 37, DateTimeKind.Utc);
|
||||
private const string Kb = "KB5099999";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly UpdateActivityTally _tally = new UpdateActivityTally();
|
||||
private readonly List<(string Step, string Update, DateTime At)> _osActivity = new List<(string, string, DateTime)>();
|
||||
private readonly ServicingTracker _tracker;
|
||||
private long _nextRecordId = 1200;
|
||||
|
||||
public ServicingTrackerTests()
|
||||
{
|
||||
_tracker = NewTracker(stateDirectory: null);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ServicingTracker NewTracker(string? stateDirectory) =>
|
||||
new ServicingTracker(
|
||||
sessionId: "sess-cbs",
|
||||
tenantId: "tenant-cbs",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(T0)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: stateDirectory,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => _osActivity.Add((step, update, at)));
|
||||
|
||||
private void Process(
|
||||
int eventId,
|
||||
DateTime at,
|
||||
string? initialState = null,
|
||||
string? targetState = "Installed",
|
||||
string? errorCode = null,
|
||||
string? client = "UpdateAgentLCU",
|
||||
string? package = Kb,
|
||||
long? recordId = null,
|
||||
ServicingTracker? tracker = null,
|
||||
bool isBackfill = false)
|
||||
{
|
||||
(tracker ?? _tracker).ProcessEvent(
|
||||
eventId: eventId,
|
||||
recordId: recordId ?? _nextRecordId++,
|
||||
timeCreatedUtc: at,
|
||||
package: package,
|
||||
initialState: initialState,
|
||||
targetState: targetState,
|
||||
errorCode: errorCode,
|
||||
client: client,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> Servicing() =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == Constants.EventTypes.WindowsUpdateServicing).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
[Fact]
|
||||
public void CumulativeUpdateSequence_ListsEveryStep_AndReportsTheEngineSteps()
|
||||
{
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0, initialState: "Absent", targetState: "Staged");
|
||||
Process(ServicingTracker.EventId_StateReached, T0.AddMinutes(4), targetState: "Staged", errorCode: "0x0");
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0.AddMinutes(29), initialState: "Staged", targetState: "Installed");
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0.AddMinutes(33), targetState: "Installed", errorCode: "0x0");
|
||||
// Written after the restart.
|
||||
Process(ServicingTracker.EventId_StateReached, T0.AddMinutes(41), targetState: "Installed", errorCode: "0x0");
|
||||
|
||||
var rows = Servicing();
|
||||
Assert.Equal(
|
||||
new[] { "initiating", "state_reached", "initiating", "reboot_required", "state_reached" },
|
||||
rows.Select(s => (string)Data(s)["step"]).ToArray());
|
||||
Assert.All(rows, s => Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(rows, s => Assert.Equal("ServicingWatcher", s.Payload![SignalPayloadKeys.Source]));
|
||||
Assert.All(rows, s => Assert.Equal(Kb, Data(s)["package"]));
|
||||
Assert.All(rows, s => Assert.Equal("UpdateAgentLCU", Data(s)["client"]));
|
||||
// ErrorCode 0x0 is success, not a decoded HRESULT.
|
||||
Assert.All(rows, s => Assert.False(Data(s).ContainsKey("hresult")));
|
||||
Assert.Equal("Absent", Data(rows[0])["initialState"]);
|
||||
Assert.Equal("Staged", Data(rows[0])["targetState"]);
|
||||
Assert.Equal(T0.AddMinutes(33), rows[3].OccurredAtUtc);
|
||||
Assert.Contains("needs a restart", rows[3].Payload![SignalPayloadKeys.Message]);
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
OsUpdateSteps.StagingStarted, OsUpdateSteps.Staged, OsUpdateSteps.InstallStarted,
|
||||
OsUpdateSteps.RebootRequired, OsUpdateSteps.Installed,
|
||||
},
|
||||
_osActivity.Select(a => a.Step).ToArray());
|
||||
Assert.All(_osActivity, a => Assert.Equal(Kb, a.Update));
|
||||
Assert.Equal(T0.AddMinutes(29), _osActivity[2].At);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CbsTask")]
|
||||
[InlineData("DISM Package Manager Provider")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void NonOsClients_AreCountedNotListed(string? client)
|
||||
{
|
||||
// CBS maintenance writes hundreds of these in seconds; language packs and features on
|
||||
// demand installed by apps are not the OOBE update either.
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0, initialState: "Staged", targetState: "Installed",
|
||||
client: client, package: "Microsoft-Windows-Telnet-Client-FOD-en-US");
|
||||
Process(ServicingTracker.EventId_StateReached, T0, targetState: "Installed", errorCode: "0x0",
|
||||
client: client, package: "Microsoft-Windows-Telnet-Client-FOD-en-US");
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
Assert.Empty(_osActivity);
|
||||
var counts = _tally.Snapshot().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
Assert.Equal(1, counts["servicing_other_1"]);
|
||||
Assert.Equal(1, counts["servicing_other_2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsUpdateAgentClient_IsAnOsClient()
|
||||
{
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, client: "WindowsUpdateAgent", errorCode: "0x0");
|
||||
|
||||
Assert.Single(Servicing());
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, Assert.Single(_osActivity).Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeFailed_EmitsWarning_WithDecodedHResult()
|
||||
{
|
||||
Process(ServicingTracker.EventId_ChangeFailed, T0, targetState: "Installed", errorCode: "0x800f0922");
|
||||
|
||||
var s = Assert.Single(Servicing());
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
var data = Data(s);
|
||||
Assert.Equal("failed", data["step"]);
|
||||
Assert.Equal("0x800F0922", data["hresult"]);
|
||||
Assert.Equal("CBS_E_INSTALLERS_FAILED", data["hresultSymbol"]);
|
||||
Assert.Contains("FAILED", s.Payload![SignalPayloadKeys.Message]);
|
||||
Assert.Equal(OsUpdateSteps.Failed, Assert.Single(_osActivity).Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartiallyInstalled_IsAFailure_AndSaysSo()
|
||||
{
|
||||
Process(ServicingTracker.EventId_PartiallyInstalled, T0, targetState: "Installed", errorCode: "0x800f0922");
|
||||
|
||||
var s = Assert.Single(Servicing());
|
||||
Assert.Equal("failed", Data(s)["step"]);
|
||||
Assert.Contains("partially installed", s.Payload![SignalPayloadKeys.Message]);
|
||||
Assert.Equal(OsUpdateSteps.Failed, Assert.Single(_osActivity).Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeTowardsAnotherState_IsListed_ButIsNoEngineStep()
|
||||
{
|
||||
// Removing a superseded package is servicing, not progress of the update install.
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0, initialState: "Installed", targetState: "Absent");
|
||||
|
||||
Assert.Single(Servicing());
|
||||
Assert.Empty(_osActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledRecord_KeepsItsEventTime()
|
||||
{
|
||||
var earlier = T0.AddMinutes(-20);
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, earlier, initialState: "Absent", targetState: "Staged", isBackfill: true);
|
||||
|
||||
var s = Assert.Single(Servicing());
|
||||
Assert.Equal(earlier, s.OccurredAtUtc);
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
Assert.Equal(earlier, Assert.Single(_osActivity).At);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_SkipsEmittedRecordsAfterRestart()
|
||||
{
|
||||
var first = NewTracker(_tmp.Path);
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205, tracker: first);
|
||||
|
||||
var second = NewTracker(_tmp.Path);
|
||||
second.LoadWatermark();
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205, tracker: second, isBackfill: true);
|
||||
Process(ServicingTracker.EventId_StateReached, T0.AddMinutes(8), errorCode: "0x0", recordId: 1207, tracker: second);
|
||||
|
||||
Assert.Equal(new[] { "reboot_required", "state_reached" }, Servicing().Select(s => (string)Data(s)["step"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameRecordDeliveredTwice_IsListedOnce()
|
||||
{
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205);
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205);
|
||||
|
||||
Assert.Single(Servicing());
|
||||
Assert.Single(_osActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseUserData_ReadsBothCbsPayloadShapes()
|
||||
{
|
||||
const string initiate =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='Microsoft-Windows-Servicing' Guid='{bd12f3b8-fc40-4a61-a307-b7a013a069c1}'/>" +
|
||||
"<EventID>1</EventID><Version>0</Version><Level>0</Level><Task>1</Task><Opcode>0</Opcode><Keywords>0x8000000000000000</Keywords>" +
|
||||
"<TimeCreated SystemTime='2026-09-25T19:21:42.4486782Z'/><EventRecordID>1203</EventRecordID><Channel>Setup</Channel><Computer>DESKTOP-TEST</Computer>" +
|
||||
"<Security UserID='S-1-5-18'/></System><UserData><CbsPackageInitiateChanges xmlns='http://manifests.microsoft.com/win/2004/08/windows/setup_provider'>" +
|
||||
"<PackageIdentifier>KB5099999</PackageIdentifier><InitialPackageState>5064</InitialPackageState><InitialPackageStateTextized>Staged</InitialPackageStateTextized>" +
|
||||
"<IntendedPackageState>5112</IntendedPackageState><IntendedPackageStateTextized>Installed</IntendedPackageStateTextized><Client>UpdateAgentLCU</Client>" +
|
||||
"</CbsPackageInitiateChanges></UserData></Event>";
|
||||
const string changeState =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='Microsoft-Windows-Servicing' Guid='{bd12f3b8-fc40-4a61-a307-b7a013a069c1}'/>" +
|
||||
"<EventID>4</EventID><TimeCreated SystemTime='2026-09-25T19:26:07.1381462Z'/><EventRecordID>1205</EventRecordID><Channel>Setup</Channel></System>" +
|
||||
"<UserData><CbsPackageChangeState xmlns='http://manifests.microsoft.com/win/2004/08/windows/setup_provider'><PackageIdentifier>KB5099999</PackageIdentifier>" +
|
||||
"<IntendedPackageState>5112</IntendedPackageState><IntendedPackageStateTextized>Installed</IntendedPackageStateTextized><ErrorCode>0x0</ErrorCode>" +
|
||||
"<Client>UpdateAgentLCU</Client></CbsPackageChangeState></UserData></Event>";
|
||||
|
||||
var a = ServicingTracker.ParseUserData(initiate);
|
||||
Assert.Equal("KB5099999", a["PackageIdentifier"]);
|
||||
Assert.Equal("Staged", a["InitialPackageStateTextized"]);
|
||||
Assert.Equal("Installed", a["IntendedPackageStateTextized"]);
|
||||
Assert.Equal("UpdateAgentLCU", a["client"]); // case-insensitive
|
||||
Assert.False(a.ContainsKey("ErrorCode"));
|
||||
|
||||
var b = ServicingTracker.ParseUserData(changeState);
|
||||
Assert.Equal("Installed", b["IntendedPackageStateTextized"]);
|
||||
Assert.Equal("0x0", b["ErrorCode"]);
|
||||
Assert.False(b.ContainsKey("InitialPackageStateTextized"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseUserData_MalformedOrWithoutUserData_ReturnsEmpty_NeverThrows()
|
||||
{
|
||||
Assert.Empty(ServicingTracker.ParseUserData(null));
|
||||
Assert.Empty(ServicingTracker.ParseUserData(""));
|
||||
Assert.Empty(ServicingTracker.ParseUserData("<not-xml"));
|
||||
Assert.Empty(ServicingTracker.ParseUserData("<Event><System><EventID>1</EventID></System></Event>"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsOnlyAfterTheEngineSignalWasHandedOver()
|
||||
{
|
||||
// "Reboot required" is written right before the update's restart: its engine signal
|
||||
// must be on its way before the watermark tells the next run's backfill to skip it.
|
||||
var path = System.IO.Path.Combine(_tmp.Path, ServicingTracker.WatermarkStateFileName);
|
||||
bool? watermarkOnDiskAtSignal = null;
|
||||
var tracker = new ServicingTracker(
|
||||
sessionId: "sess-cbs",
|
||||
tenantId: "tenant-cbs",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(T0)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: _tmp.Path,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => watermarkOnDiskAtSignal = System.IO.File.Exists(path));
|
||||
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", tracker: tracker);
|
||||
|
||||
Assert.False(watermarkOnDiskAtSignal);
|
||||
Assert.True(System.IO.File.Exists(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#nullable enable
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// WindowsUpdateClassifier (D-310) — decides which Windows Update client events are listed one
|
||||
/// by one (OS updates) and which are only counted (Store, Defender, everything else). The
|
||||
/// titles are the shapes the WU client writes on Windows 11 GA and Insider builds.
|
||||
/// </summary>
|
||||
public sealed class WindowsUpdateClassifierTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)")]
|
||||
[InlineData("2026-09 Cumulative Update Preview for Windows 11, version 24H2 for x64-based Systems (KB5099998)")]
|
||||
[InlineData("2026-09 Dynamic Update for Windows 11, version 25H2 for x64-based Systems (KB5099997)")]
|
||||
[InlineData("2026-09 Servicing Stack Update for Windows 11, version 25H2 for x64-based Systems (KB5099996)")]
|
||||
[InlineData("Windows 11 Insider Preview Quality Update (26220.9568)")]
|
||||
[InlineData("Windows 11 Insider Preview Feature Update (26220.9022)")]
|
||||
[InlineData("Windows 11, version 25H2 Upgrade")]
|
||||
[InlineData("2026-09 .NET Framework Security Update (KB5099995)")]
|
||||
[InlineData("2026-08 .NET Framework Preview Update (KB5099994)")]
|
||||
[InlineData("Windows Malicious Software Removal Tool x64 - v5.145 (KB890830)")]
|
||||
public void WindowsAndDotNetFrameworkUpdates_AreOs(string title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Os, WindowsUpdateClassifier.Classify(title, serviceGuid: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.459.505.0) - Current Channel (Broad)")]
|
||||
[InlineData("Update for Microsoft Defender Antivirus antimalware platform - KB4052623 (Version 4.18.26080.4) - Current Channel (Staged)")]
|
||||
// Names "Windows" and "Update" — Defender must still win.
|
||||
[InlineData("Windows 10/11 - Update for Microsoft Defender for Endpoint - KB5005292 (Version 10.8838.26060.15013) - Current Channel (Staged)")]
|
||||
public void DefenderUpdates_AreDefender(string title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Defender, WindowsUpdateClassifier.Classify(title, serviceGuid: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("9NSTH9KHZDLQ-Microsoft.UI.Xaml.2.8")]
|
||||
[InlineData("9NBLGGH4NNS1-Microsoft.DesktopAppInstaller")]
|
||||
// Upper-case "WINDOWS" inside the package name must not make it an OS update.
|
||||
[InlineData("9WZDNCRFHVN5-MICROSOFT.WINDOWSCALCULATOR")]
|
||||
[InlineData("9WZDNCRFHWKN-MICROSOFT.WINDOWSSOUNDRECORDER")]
|
||||
[InlineData("ApplicationSet-9WZDNCRFJ3Q2-Microsoft.BingWeather")]
|
||||
public void StoreProductTitles_AreStore(string title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Store, WindowsUpdateClassifier.Classify(title, serviceGuid: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{855e8a7c-ecb4-4ca3-b045-1dfa50104289}")]
|
||||
[InlineData("{855E8A7C-ECB4-4CA3-B045-1DFA50104289}")]
|
||||
[InlineData("855e8a7c-ecb4-4ca3-b045-1dfa50104289")]
|
||||
public void StoreServiceGuid_IsStore_WhateverTheTitle(string serviceGuid)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Store,
|
||||
WindowsUpdateClassifier.Classify("2026-09 Cumulative Update for Windows 11 (KB5099999)", serviceGuid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherServiceGuid_LeavesTheTitleInCharge()
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Os,
|
||||
WindowsUpdateClassifier.Classify("2026-09 Cumulative Update for Windows 11 (KB5099999)", "{7971f918-a847-4430-9279-4a52d1efe18d}"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Intel net Driver Update (24.50.0.4)")]
|
||||
[InlineData("PowerShell v7.6.6 (x64)")]
|
||||
[InlineData("Remote help 5.2.1040.0")]
|
||||
[InlineData("Microsoft Edge-Stable Channel Version 140 Update for x64 based Edge-Chromium (Build 140.0.3485.54)")]
|
||||
[InlineData("Microsoft .NET 8.0.20 Security Update for x64 Client (KB5099993)")]
|
||||
[InlineData("Windows Feature Experience Pack 1000.26100.234.0")]
|
||||
[InlineData("KB5099999")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void EverythingElse_IsOther(string? title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Other, WindowsUpdateClassifier.Classify(title!, serviceGuid: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
+409
-61
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
@@ -9,55 +10,81 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// WindowsUpdateTracker — surfaces quality/cumulative update activity during OOBE from the
|
||||
/// WindowsUpdateClient/Operational channel. Drives the primitive <see cref="WindowsUpdateTracker.ProcessEvent"/>
|
||||
/// WindowsUpdateClient provider: install events from the System log, scan/download events from
|
||||
/// the Operational channel (D-310). Drives the primitive <see cref="WindowsUpdateTracker.ProcessEvent"/>
|
||||
/// test seam (no real EventRecord, which is abstract + Windows-only), mirroring the
|
||||
/// ModernDeploymentTracker test pattern.
|
||||
/// ModernDeploymentTracker test pattern. Update titles are the shapes the provider writes.
|
||||
/// </summary>
|
||||
public sealed class WindowsUpdateTrackerTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 7, 8, 10, 15, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string Lcu = "2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)";
|
||||
private const string StoreApp = "9WZDNCRFHVN5-MICROSOFT.WINDOWSCALCULATOR";
|
||||
private const string DefenderIntelligence = "Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.459.505.0) - Current Channel (Broad)";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink;
|
||||
private readonly WindowsUpdateTracker _tracker;
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly UpdateActivityTally _tally = new UpdateActivityTally();
|
||||
private readonly List<(string Step, string Update, DateTime At)> _osActivity = new List<(string, string, DateTime)>();
|
||||
private readonly WindowsUpdateTracker _system;
|
||||
private readonly WindowsUpdateTracker _operational;
|
||||
private long _nextRecordId = 1;
|
||||
|
||||
public WindowsUpdateTrackerTests()
|
||||
{
|
||||
_sink = new FakeSignalIngressSink();
|
||||
var post = new InformationalEventPost(_sink, new VirtualClock(At));
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_tracker = new WindowsUpdateTracker(
|
||||
sessionId: "sess-wu",
|
||||
tenantId: "tenant-wu",
|
||||
post: post,
|
||||
logger: logger,
|
||||
backfillEnabled: false,
|
||||
stateDirectory: null); // in-memory dedup for most tests
|
||||
// In-memory dedup for most tests.
|
||||
_system = NewTracker(WindowsUpdateTracker.SystemChannel, stateDirectory: null);
|
||||
_operational = NewTracker(channel: null, stateDirectory: null);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private void Emit(int eventId, string? errorCode = null, string? updateTitle = "Cumulative Update KB5099999", long? recordId = null)
|
||||
private WindowsUpdateTracker NewTracker(string? channel, string? stateDirectory) =>
|
||||
new WindowsUpdateTracker(
|
||||
sessionId: "sess-wu",
|
||||
tenantId: "tenant-wu",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: stateDirectory,
|
||||
channel: channel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => _osActivity.Add((step, update, at)));
|
||||
|
||||
private void Emit(
|
||||
WindowsUpdateTracker tracker,
|
||||
int eventId,
|
||||
string? updateTitle = Lcu,
|
||||
string? errorCode = null,
|
||||
long? recordId = null,
|
||||
string? serviceGuid = null,
|
||||
string? updateCount = null,
|
||||
DateTime? at = null,
|
||||
bool isBackfill = false)
|
||||
{
|
||||
_tracker.ProcessEvent(
|
||||
tracker.ProcessEvent(
|
||||
eventId: eventId,
|
||||
level: 4,
|
||||
recordId: recordId ?? _nextRecordId++,
|
||||
timeCreatedUtc: At,
|
||||
timeCreatedUtc: at ?? At,
|
||||
updateTitle: updateTitle,
|
||||
updateGuid: "{8b1c8726-1111-2222-3333-444455556666}",
|
||||
updateRevisionNumber: "200",
|
||||
errorCode: errorCode,
|
||||
formattedDescription: null,
|
||||
isBackfill: false);
|
||||
isBackfill: isBackfill,
|
||||
serviceGuid: serviceGuid,
|
||||
updateCount: updateCount);
|
||||
}
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
@@ -70,10 +97,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
private int Counted(string key) =>
|
||||
_tally.Snapshot().Where(kv => kv.Key == key).Select(kv => kv.Value).SingleOrDefault();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install events (System log)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void FailureEvent_EmitsWindowsUpdateFailed_WithDecodedHResult_AndImmediateUpload()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x80240022");
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x80240022");
|
||||
|
||||
var failed = ByType(Constants.EventTypes.WindowsUpdateFailed);
|
||||
var s = Assert.Single(failed);
|
||||
@@ -83,37 +117,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var data = Data(s);
|
||||
Assert.Equal("0x80240022", data["hresult"]);
|
||||
Assert.Equal("WU_E_ALL_UPDATES_FAILED", data["hresultSymbol"]);
|
||||
Assert.Equal("Cumulative Update KB5099999", data["updateTitle"]);
|
||||
Assert.Equal(Lcu, data["updateTitle"]);
|
||||
Assert.Equal(20, data["wuEventId"]);
|
||||
Assert.Equal("system", data["wuChannel"]);
|
||||
Assert.Equal("install", data["wuPhase"]);
|
||||
Assert.Equal(WindowsUpdateClassifier.Os, data["updateClass"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessEvent_EmitsWindowsUpdateSucceeded_InfoSeverity()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess);
|
||||
|
||||
var ok = ByType(Constants.EventTypes.WindowsUpdateSucceeded);
|
||||
var s = Assert.Single(ok);
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal("installed", Data(s)["wuPhase"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartedAndDownloadEvents_EmitDebugStarted()
|
||||
public void StartedAndDownloadEvents_EmitInfoStarted_WithTheirPhase()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallStarted);
|
||||
Emit(WindowsUpdateTracker.EventId_DownloadStarted);
|
||||
// Info, not Debug: with only OS updates listed they are the visible progress of a
|
||||
// long OOBE update.
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallStarted);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_DownloadStarted);
|
||||
|
||||
var started = ByType(Constants.EventTypes.WindowsUpdateStarted);
|
||||
Assert.Equal(2, started.Count);
|
||||
Assert.All(started, s => Assert.Equal("Debug", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(started, s => Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.Equal(new[] { "install", "download" }, started.Select(s => (string)Data(s)["wuPhase"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameRecordId_ProcessedTwice_EmitsOnce()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
}
|
||||
@@ -125,8 +166,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// with a higher RecordId can be processed first. An older, never-emitted backfill event
|
||||
// (lower RecordId) must NOT be suppressed as "already processed" — these early pre-agent
|
||||
// OOBE updates are the whole point of the feature. A high-water-mark dedup dropped them.
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 100); // live, high RecordId
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 50); // backfill, older, never seen
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 100); // live, high RecordId
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 50); // backfill, older, never seen
|
||||
|
||||
Assert.Equal(2, ByType(Constants.EventTypes.WindowsUpdateSucceeded).Count);
|
||||
}
|
||||
@@ -138,53 +179,212 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// time, not at agent start (the ctor's default UtcNow). InformationalEventPost forwards
|
||||
// EnrollmentEvent.Timestamp as the signal's OccurredAtUtc, which drives the timeline entry.
|
||||
var eventTime = new DateTime(2026, 7, 8, 7, 5, 0, DateTimeKind.Utc); // well before `At`
|
||||
_tracker.ProcessEvent(
|
||||
eventId: WindowsUpdateTracker.EventId_InstallFailure,
|
||||
level: 4,
|
||||
recordId: 7,
|
||||
timeCreatedUtc: eventTime,
|
||||
updateTitle: "KB5099999",
|
||||
updateGuid: null,
|
||||
updateRevisionNumber: null,
|
||||
errorCode: "0x80240022",
|
||||
formattedDescription: null,
|
||||
isBackfill: true);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x80240022",
|
||||
recordId: 7, at: eventTime, isBackfill: true);
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateFailed));
|
||||
Assert.Equal(eventTime, s.OccurredAtUtc);
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeRecordId_IsNeverDeduped()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
|
||||
Assert.Equal(2, ByType(Constants.EventTypes.WindowsUpdateSucceeded).Count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan/download events (Operational channel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsAcrossTrackerInstances()
|
||||
public void Downloaded_EmitsInfoStarted_WithDownloadedPhase()
|
||||
{
|
||||
var post = new InformationalEventPost(_sink, new VirtualClock(At));
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_Downloaded);
|
||||
|
||||
var first = new WindowsUpdateTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
|
||||
first.ProcessEvent(WindowsUpdateTracker.EventId_InstallSuccess, 4, recordId: 500, timeCreatedUtc: At,
|
||||
updateTitle: "KB1", updateGuid: null, updateRevisionNumber: null, errorCode: null,
|
||||
formattedDescription: null, isBackfill: false);
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("downloaded", Data(s)["wuPhase"]);
|
||||
Assert.Equal("operational", Data(s)["wuChannel"]);
|
||||
}
|
||||
|
||||
// A fresh tracker (simulating an agent restart) must load the watermark and skip the
|
||||
// already-emitted record when the OOBE backfill re-reads it.
|
||||
var second = new WindowsUpdateTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
second.ProcessEvent(WindowsUpdateTracker.EventId_InstallSuccess, 4, recordId: 500, timeCreatedUtc: At,
|
||||
updateTitle: "KB1", updateGuid: null, updateRevisionNumber: null, errorCode: null,
|
||||
formattedDescription: null, isBackfill: true);
|
||||
[Fact]
|
||||
public void DownloadFailure_OfAnOsUpdate_EmitsWarning_WithDownloadPhase()
|
||||
{
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_DownloadFailed, errorCode: "0x80248007");
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateFailed));
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
var data = Data(s);
|
||||
Assert.Equal("download", data["wuPhase"]);
|
||||
Assert.Equal("0x80248007", data["hresult"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFound_EmitsOnlyChangedCounts_AndCountsTheRepeats()
|
||||
{
|
||||
foreach (var count in new[] { "3", "3", "5", "5", "0" })
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFound, updateTitle: null, updateCount: count);
|
||||
|
||||
var found = ByType(Constants.EventTypes.WindowsUpdateStarted);
|
||||
Assert.Equal(new[] { "3", "5", "0" }, found.Select(s => (string)Data(s)["updateCount"]).ToArray());
|
||||
Assert.All(found, s => Assert.Equal("Debug", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(found, s => Assert.Equal("scan", Data(s)["wuPhase"]));
|
||||
Assert.All(found, s => Assert.False(Data(s).ContainsKey("updateClass")));
|
||||
Assert.Contains("scan found 3 update(s)", found[0].Payload![SignalPayloadKeys.Message]);
|
||||
Assert.Equal(2, Counted("scan_found_repeat"));
|
||||
Assert.Empty(_osActivity); // a scan result is not an update step
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFound_IsCappedPerRun()
|
||||
{
|
||||
for (var i = 0; i < WindowsUpdateTracker.MaxScanFoundEmissions + 2; i++)
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFound, updateTitle: null, updateCount: i.ToString());
|
||||
|
||||
Assert.Equal(WindowsUpdateTracker.MaxScanFoundEmissions, ByType(Constants.EventTypes.WindowsUpdateStarted).Count);
|
||||
Assert.Equal(2, Counted("scan_found_repeat"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFailed_EmitsEachErrorCodeOnce()
|
||||
{
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x8024401c");
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x8024401c");
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x80072EE2");
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x8024401C");
|
||||
|
||||
var failed = ByType(Constants.EventTypes.WindowsUpdateFailed);
|
||||
Assert.Equal(2, failed.Count);
|
||||
Assert.All(failed, s => Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(failed, s => Assert.Equal("scan", Data(s)["wuPhase"]));
|
||||
Assert.Equal(2, Counted("scan_failed_repeat"));
|
||||
Assert.Empty(_osActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFailed_IsCappedPerRun()
|
||||
{
|
||||
for (var i = 0; i < WindowsUpdateTracker.MaxScanFailedEmissions + 2; i++)
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: $"0x8024000{i}");
|
||||
|
||||
Assert.Equal(WindowsUpdateTracker.MaxScanFailedEmissions, ByType(Constants.EventTypes.WindowsUpdateFailed).Count);
|
||||
Assert.Equal(2, Counted("scan_failed_repeat"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Classification — only OS updates are listed, the rest is counted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(StoreApp, null, WindowsUpdateTracker.EventId_InstallSuccess, "store_19")]
|
||||
[InlineData("Some App", "{855e8a7c-ecb4-4ca3-b045-1dfa50104289}", WindowsUpdateTracker.EventId_Downloaded, "store_41")]
|
||||
[InlineData(DefenderIntelligence, null, WindowsUpdateTracker.EventId_InstallStarted, "defender_43")]
|
||||
[InlineData("Intel net Driver Update (24.50.0.4)", null, WindowsUpdateTracker.EventId_DownloadStarted, "other_44")]
|
||||
// A real download failure names no update at all; it must not reach ANALYZE-DEV-004 as
|
||||
// a failed OS update.
|
||||
[InlineData("", null, WindowsUpdateTracker.EventId_DownloadFailed, "other_31")]
|
||||
[InlineData("Some App", "{855e8a7c-ecb4-4ca3-b045-1dfa50104289}", WindowsUpdateTracker.EventId_InstallFailure, "store_20")]
|
||||
public void NonOsUpdates_AreCountedNotEmitted(string title, string? serviceGuid, int eventId, string expectedKey)
|
||||
{
|
||||
var tracker = WindowsUpdateTracker.IsSystemChannelEventId(eventId) ? _system : _operational;
|
||||
Emit(tracker, eventId, updateTitle: title, serviceGuid: serviceGuid, errorCode: "0x80248007");
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
Assert.Empty(_osActivity);
|
||||
Assert.Equal(1, Counted(expectedKey));
|
||||
// The census still sees the channel working.
|
||||
Assert.Equal(1, tracker.EmittedThisRun);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountedRecord_DeliveredTwice_IsCountedOnce()
|
||||
{
|
||||
// Live watcher and backfill overlap: the seen-set dedups counted records too.
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, updateTitle: StoreApp, recordId: 7);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, updateTitle: StoreApp, recordId: 7);
|
||||
|
||||
Assert.Equal(1, Counted("store_19"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSteps_AreReportedToTheEngineCallback()
|
||||
{
|
||||
var t0 = At;
|
||||
Emit(_system, WindowsUpdateTracker.EventId_DownloadStarted, at: t0);
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_Downloaded, at: t0.AddMinutes(4));
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallStarted, at: t0.AddMinutes(5));
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, at: t0.AddMinutes(31));
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x800F0922", at: t0.AddMinutes(32));
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_DownloadFailed, errorCode: "0x80248007", at: t0.AddMinutes(33));
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
OsUpdateSteps.DownloadStarted, OsUpdateSteps.Downloaded, OsUpdateSteps.InstallStarted,
|
||||
OsUpdateSteps.Installed, OsUpdateSteps.Failed, OsUpdateSteps.Failed,
|
||||
},
|
||||
_osActivity.Select(a => a.Step).ToArray());
|
||||
Assert.All(_osActivity, a => Assert.Equal(Lcu, a.Update));
|
||||
Assert.Equal(t0.AddMinutes(31), _osActivity[3].At);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowingCallback_NeverCostsTheEvent()
|
||||
{
|
||||
var tracker = new WindowsUpdateTracker(
|
||||
"s", "t", new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
channel: WindowsUpdateTracker.SystemChannel,
|
||||
onOsUpdateActivity: (step, update, at) => throw new InvalidOperationException("boom"));
|
||||
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallSuccess);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channels and XPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DefaultEventIds_FollowTheProviderManifest()
|
||||
{
|
||||
Assert.Equal(WindowsUpdateTracker.SystemChannel, _system.WatchedChannel);
|
||||
Assert.Equal(new[] { 19, 20, 43, 44 }, _system.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
Assert.Equal(WindowsUpdateTracker.Channel, _operational.WatchedChannel);
|
||||
Assert.Equal(new[] { 25, 26, 31, 41 }, _operational.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(16, true)]
|
||||
[InlineData(19, true)]
|
||||
[InlineData(20, true)]
|
||||
[InlineData(24, true)]
|
||||
[InlineData(27, true)]
|
||||
[InlineData(28, true)]
|
||||
[InlineData(32, true)]
|
||||
[InlineData(33, true)]
|
||||
[InlineData(43, true)]
|
||||
[InlineData(44, true)]
|
||||
[InlineData(212, true)]
|
||||
[InlineData(218, true)]
|
||||
[InlineData(25, false)]
|
||||
[InlineData(26, false)]
|
||||
[InlineData(31, false)]
|
||||
[InlineData(41, false)]
|
||||
[InlineData(15, false)]
|
||||
[InlineData(219, false)]
|
||||
public void IsSystemChannelEventId_MatchesTheManifest(int eventId, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, WindowsUpdateTracker.IsSystemChannelEventId(eventId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildXPath_ContainsAllTargetedIds_Ordered()
|
||||
{
|
||||
@@ -192,6 +392,113 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Equal("*[System[(EventID=19 or EventID=20 or EventID=43 or EventID=44)]]", xpath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildXPath_SharedSystemLog_FiltersTheProvider_AndTheLookback()
|
||||
{
|
||||
// Verified against a real System log: the provider clause keeps the other providers'
|
||||
// EventIDs 19/20/43/44 out of the shared channel.
|
||||
var ids = new HashSet<int> { 44, 19, 20, 43 };
|
||||
|
||||
Assert.Equal(
|
||||
"*[System[Provider[@Name='Microsoft-Windows-WindowsUpdateClient'] and (EventID=19 or EventID=20 or EventID=43 or EventID=44)]]",
|
||||
WindowsUpdateTracker.BuildXPath(ids, WindowsUpdateTracker.ProviderName));
|
||||
Assert.Equal(
|
||||
"*[System[Provider[@Name='Microsoft-Windows-WindowsUpdateClient'] and (EventID=19 or EventID=20 or EventID=43 or EventID=44) and TimeCreated[timediff(@SystemTime) <= 3600000]]]",
|
||||
WindowsUpdateTracker.BuildXPath(ids, WindowsUpdateTracker.ProviderName, lookbackMs: 3600000));
|
||||
Assert.Equal(
|
||||
"*[System[(EventID=25 or EventID=26) and TimeCreated[timediff(@SystemTime) <= 60000]]]",
|
||||
WindowsUpdateTracker.BuildXPath(new HashSet<int> { 26, 25 }, providerName: null, lookbackMs: 60000));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watermark
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsAcrossTrackerInstances()
|
||||
{
|
||||
var first = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
Emit(first, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500);
|
||||
|
||||
// A fresh tracker (simulating an agent restart) must load the watermark and skip the
|
||||
// already-emitted record when the OOBE backfill re-reads it.
|
||||
var second = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
Emit(second, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500, isBackfill: true);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_IsPerChannel()
|
||||
{
|
||||
// RecordIds are per channel: System record 500 and Operational record 500 are two
|
||||
// different events and must not dedup each other — before or after a restart.
|
||||
Emit(NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path), WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500);
|
||||
Emit(NewTracker(null, _tmp.Path), WindowsUpdateTracker.EventId_Downloaded, recordId: 500);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
Assert.Equal(500L, ReadWatermark(WindowsUpdateTracker.SystemWatermarkStateFileName));
|
||||
Assert.Equal(500L, ReadWatermark(WindowsUpdateTracker.WatermarkStateFileName));
|
||||
|
||||
var system = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
var operational = NewTracker(null, _tmp.Path);
|
||||
system.LoadWatermark();
|
||||
operational.LoadWatermark();
|
||||
Emit(system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500, isBackfill: true);
|
||||
Emit(operational, WindowsUpdateTracker.EventId_Downloaded, recordId: 500, isBackfill: true);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountedRecords_WriteNoStateFile()
|
||||
{
|
||||
// A Store/Defender burst must not cost a state-file write per record; only emitted
|
||||
// records move the persisted watermark.
|
||||
var tracker = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallSuccess, updateTitle: StoreApp, recordId: 600);
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallStarted, updateTitle: DefenderIntelligence, recordId: 601);
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(_tmp.Path, WindowsUpdateTracker.SystemWatermarkStateFileName)));
|
||||
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallStarted, recordId: 602);
|
||||
Assert.Equal(602L, ReadWatermark(WindowsUpdateTracker.SystemWatermarkStateFileName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsOnlyAfterTheEngineSignalWasHandedOver()
|
||||
{
|
||||
// A restart's backfill skips a persisted record, so the record's engine signal must be
|
||||
// on its way before the watermark reaches the disk — never the other way round.
|
||||
var path = Path.Combine(_tmp.Path, WindowsUpdateTracker.SystemWatermarkStateFileName);
|
||||
bool? watermarkOnDiskAtSignal = null;
|
||||
var tracker = new WindowsUpdateTracker(
|
||||
sessionId: "sess-wu",
|
||||
tenantId: "tenant-wu",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: _tmp.Path,
|
||||
channel: WindowsUpdateTracker.SystemChannel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => watermarkOnDiskAtSignal = File.Exists(path));
|
||||
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallStarted, recordId: 700);
|
||||
|
||||
Assert.False(watermarkOnDiskAtSignal);
|
||||
Assert.Equal(700L, ReadWatermark(WindowsUpdateTracker.SystemWatermarkStateFileName));
|
||||
}
|
||||
|
||||
private long ReadWatermark(string fileName) =>
|
||||
JObject.Parse(File.ReadAllText(Path.Combine(_tmp.Path, fileName)))["LastRecordId"]!.Value<long>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing and HRESULT decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ParseEventData_ExtractsNamedFields_NamespaceAgnostic()
|
||||
{
|
||||
@@ -212,6 +519,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.True(data.ContainsKey("UPDATETITLE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEventData_ReadsTheScanFields()
|
||||
{
|
||||
// Event 26 (scan found N) and 25 (scan failed) carry no update title.
|
||||
const string xml =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'>" +
|
||||
"<System><EventID>26</EventID></System>" +
|
||||
"<EventData>" +
|
||||
"<Data Name='updateCount'>3</Data>" +
|
||||
"<Data Name='serviceGuid'>{8b24b027-1dee-babb-9a95-3517dfb9c552}</Data>" +
|
||||
"</EventData></Event>";
|
||||
|
||||
var data = WindowsUpdateTracker.ParseEventData(xml);
|
||||
Assert.Equal("3", data["updateCount"]);
|
||||
Assert.Equal("{8b24b027-1dee-babb-9a95-3517dfb9c552}", data["serviceGuid"]);
|
||||
Assert.False(data.ContainsKey("updateTitle"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEventData_MalformedXml_ReturnsEmpty_NeverThrows()
|
||||
{
|
||||
@@ -303,10 +628,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var data = Data(s);
|
||||
Assert.Equal("21=1,25=3", data["wuClientCensus"]);
|
||||
Assert.Equal("200=2", data["updateOrchestratorCensus"]);
|
||||
Assert.Equal("19,20,43,44", data["targetedEventIds"]);
|
||||
Assert.Equal("25,26,31,41", data["targetedEventIds"]);
|
||||
Assert.False(data.ContainsKey("censusTruncated"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_ListsTheTargetedIdsOfEveryChannel()
|
||||
{
|
||||
// The host runs the census on the Operational instance; the evidence must still say
|
||||
// the System-log install IDs were watched too.
|
||||
var tracker = BuildCensusTracker(censusEnabled: true, osBuildChanged: true);
|
||||
|
||||
tracker.EmitChannelCensusIfBlind(otherChannelsEmitted: 0, otherTargetedEventIds: new[] { 44, 19, 20, 43 });
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateChannelCensus));
|
||||
Assert.Equal("19,20,25,26,31,41,43,44", Data(s)["targetedEventIds"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_BuildUnchanged_EmitsNothing()
|
||||
{
|
||||
@@ -320,17 +658,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
[Fact]
|
||||
public void Census_TargetedEventsWereEmittedThisRun_EmitsNothing()
|
||||
{
|
||||
// The watcher captured the update through its normal path — no blind spot to report.
|
||||
// The watcher captured activity through its normal path — no blind spot to report.
|
||||
// Any class counts: a counted Store update proves the channel is read as well.
|
||||
var tracker = BuildCensusTracker(censusEnabled: true, osBuildChanged: true);
|
||||
tracker.ProcessEvent(WindowsUpdateTracker.EventId_InstallSuccess, 4, recordId: 9, timeCreatedUtc: At,
|
||||
updateTitle: "KB1", updateGuid: null, updateRevisionNumber: null, errorCode: null,
|
||||
formattedDescription: null, isBackfill: false);
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_Downloaded, updateTitle: StoreApp, recordId: 9);
|
||||
|
||||
tracker.EmitChannelCensusIfBlind();
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.WindowsUpdateChannelCensus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_AnotherChannelEmitted_EmitsNothing()
|
||||
{
|
||||
// The System-log watcher saw the install — the update was not missed.
|
||||
var tracker = BuildCensusTracker(censusEnabled: true, osBuildChanged: true);
|
||||
|
||||
tracker.EmitChannelCensusIfBlind(otherChannelsEmitted: 1);
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.WindowsUpdateChannelCensus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_DisabledByConfig_EmitsNothing()
|
||||
{
|
||||
|
||||
+56
@@ -153,6 +153,62 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.Equal(0, callCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopped_waiting_fires_callback_once_with_the_stage_still_non_terminal()
|
||||
{
|
||||
// D-310: the engine stopped waiting for the user without a verdict. The stage stays
|
||||
// non-terminal, yet the agent must end the session — the callback carries the state.
|
||||
using var rig = new Rig();
|
||||
DecisionState? captured = null;
|
||||
var callCount = 0;
|
||||
var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"),
|
||||
onTerminal: state =>
|
||||
{
|
||||
Interlocked.Increment(ref callCount);
|
||||
captured = state;
|
||||
});
|
||||
|
||||
var builder = new DecisionStateBuilder(sut.CurrentState)
|
||||
{
|
||||
Stage = SessionStage.EspAccountSetup,
|
||||
StepIndex = sut.CurrentState.StepIndex + 1,
|
||||
StoppedWaitingReason = new SignalFact<string>(StoppedWaitingReasons.OobeUpdateNoSignIn, 1),
|
||||
};
|
||||
var stopped = builder.Build();
|
||||
var step = new DecisionStep(stopped, new DecisionTransition(
|
||||
stepIndex: stopped.StepIndex, sessionTraceOrdinal: 1, signalOrdinalRef: 1, occurredAtUtc: At,
|
||||
trigger: "DeadlineFired:advisory_completion:OobeUpdateSignInWaitExpired",
|
||||
fromStage: SessionStage.SessionStarted, toStage: SessionStage.EspAccountSetup,
|
||||
taken: true, deadEndReason: null, reducerVersion: "2.0.0.0"), Array.Empty<DecisionEffect>());
|
||||
sut.ApplyStep(step, Sig(1, At));
|
||||
|
||||
// A follow-up step must not fire it again.
|
||||
sut.ApplyStep(StepToStage(sut.CurrentState, SessionStage.EspAccountSetup, signalOrdinal: 2, at: At.AddSeconds(1)),
|
||||
Sig(2, At.AddSeconds(1)));
|
||||
|
||||
Assert.Equal(1, callCount);
|
||||
Assert.Equal(SessionStage.EspAccountSetup, captured!.Stage);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, captured.StoppedWaitingReason!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopped_waiting_on_load_does_not_refire_the_callback()
|
||||
{
|
||||
// The orchestrator ends a recovered stopped-waiting session itself at start.
|
||||
using var rig = new Rig();
|
||||
var callCount = 0;
|
||||
var recovered = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1"))
|
||||
{
|
||||
Stage = SessionStage.EspAccountSetup,
|
||||
StoppedWaitingReason = new SignalFact<string>(StoppedWaitingReasons.OobeUpdateNoSignIn, 7),
|
||||
}.Build();
|
||||
|
||||
var sut = rig.Build(recovered, onTerminal: _ => Interlocked.Increment(ref callCount));
|
||||
sut.ApplyStep(StepToStage(sut.CurrentState, SessionStage.EspAccountSetup, signalOrdinal: 8, at: At), Sig(8, At));
|
||||
|
||||
Assert.Equal(0, callCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_callback_null_is_supported()
|
||||
{
|
||||
|
||||
@@ -174,6 +174,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
sut.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovered_stopped_waiting_state_ends_the_session_at_start()
|
||||
{
|
||||
// D-310: a crash between "the engine stopped waiting" and the termination must not
|
||||
// leave the session running — the restart ends it the same way, with the engine's reason.
|
||||
using var rig = new Rig();
|
||||
Directory.CreateDirectory(rig.StateDir);
|
||||
|
||||
var stoppedState = DecisionState.CreateInitial("S1", "T1")
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.EspAccountSetup)
|
||||
.WithStepIndex(9)
|
||||
.Build()
|
||||
.ToBuilder();
|
||||
stoppedState.StoppedWaitingReason = new SignalFact<string>(StoppedWaitingReasons.OobeUpdateNoSignIn, 42);
|
||||
new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json")).Save(stoppedState.Build());
|
||||
|
||||
var sut = rig.Build();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
EnrollmentTerminatedEventArgs? captured = null;
|
||||
sut.Terminated += (_, e) => { captured = e; fired.Set(); };
|
||||
|
||||
sut.Start();
|
||||
Assert.True(fired.Wait(5000), "Terminated did not fire for the recovered stopped-waiting state.");
|
||||
|
||||
Assert.Equal(EnrollmentTerminationReason.StoppedWaiting, captured!.Reason);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.TimedOut, captured.Outcome);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, captured.StopReason);
|
||||
Assert.Equal(SessionStage.EspAccountSetup.ToString(), captured.StageName);
|
||||
Assert.Contains("60 minutes", captured.Details);
|
||||
|
||||
sut.Stop();
|
||||
}
|
||||
|
||||
// ================================================================= Sonderfall 2: Segment-Quarantine
|
||||
|
||||
[Fact]
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// WindowsUpdateWatcherHost (D-310) — routes every configured WU EventID to the channel the
|
||||
/// provider manifest writes it to, reports counted activity once, and feeds Windows/.NET update
|
||||
/// steps to the decision engine unless <c>OobeUpdatePhaseMode</c> is Off. The event-log
|
||||
/// watchers themselves are not armed here; the trackers' ProcessEvent seams drive the host.
|
||||
/// </summary>
|
||||
public sealed class WindowsUpdateWatcherHostTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 1, 9, 30, 0, DateTimeKind.Utc);
|
||||
private const string Lcu = "2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private long _nextRecordId = 1;
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private WindowsUpdateWatcherHost Build(bool osUpdateSignalsEnabled, bool servicingWatcherEnabled = true) =>
|
||||
new WindowsUpdateWatcherHost(
|
||||
sessionId: "sess-wuhost",
|
||||
tenantId: "tenant-wuhost",
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
ingress: _sink,
|
||||
clock: new VirtualClock(At),
|
||||
targetedEventIds: new[] { 19, 20, 43, 44 },
|
||||
backfillLookbackMinutes: 0,
|
||||
stateDirectory: _tmp.Path,
|
||||
operationalEventIds: new[] { 25, 26, 31, 41 },
|
||||
servicingWatcherEnabled: servicingWatcherEnabled,
|
||||
osUpdateSignalsEnabled: osUpdateSignalsEnabled);
|
||||
|
||||
private void Wu(WindowsUpdateTracker tracker, int eventId, string title, DateTime at) =>
|
||||
tracker.ProcessEvent(eventId, 4, _nextRecordId++, at, title, updateGuid: null, updateRevisionNumber: null,
|
||||
errorCode: null, formattedDescription: null, isBackfill: false);
|
||||
|
||||
private void Cbs(ServicingTracker tracker, int eventId, string client, DateTime at) =>
|
||||
tracker.ProcessEvent(eventId, _nextRecordId++, at, "KB5099999", initialState: null, targetState: "Installed",
|
||||
errorCode: "0x0", client: client, isBackfill: false);
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> OsUpdateActivity() =>
|
||||
_sink.Posted.Where(p => p.Kind == DecisionSignalKind.OsUpdateActivity).ToList();
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
// ---------------------------------------------------------------- routing ----
|
||||
|
||||
[Fact]
|
||||
public void RouteEventIds_Defaults_SplitByManifest()
|
||||
{
|
||||
var (system, operational) = WindowsUpdateWatcherHost.RouteEventIds(new[] { 19, 20, 43, 44 }, new[] { 25, 26, 31, 41 });
|
||||
|
||||
Assert.Equal(new[] { 19, 20, 43, 44 }, system);
|
||||
Assert.Equal(new[] { 25, 26, 31, 41 }, operational);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteEventIds_MisplacedIds_EndUpOnTheirRealChannel()
|
||||
{
|
||||
// An old config that lists 41 under the targeted IDs, or 20 under the operational
|
||||
// ones, must still watch the channel that carries them.
|
||||
var (system, operational) = WindowsUpdateWatcherHost.RouteEventIds(new[] { 41, 19 }, new[] { 20, 41 });
|
||||
|
||||
Assert.Equal(new[] { 19, 20 }, system);
|
||||
Assert.Equal(new[] { 41 }, operational);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteEventIds_NothingConfigured_WatchesNothing()
|
||||
{
|
||||
var (system, operational) = WindowsUpdateWatcherHost.RouteEventIds(null, Array.Empty<int>());
|
||||
|
||||
Assert.Empty(system);
|
||||
Assert.Empty(operational);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_BuildsOneTrackerPerChannel_AndTheServicingTracker()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
|
||||
Assert.Equal(WindowsUpdateTracker.SystemChannel, host.SystemTrackerForTest!.WatchedChannel);
|
||||
Assert.Equal(new[] { 19, 20, 43, 44 }, host.SystemTrackerForTest.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
Assert.Equal(WindowsUpdateTracker.Channel, host.OperationalTrackerForTest!.WatchedChannel);
|
||||
Assert.Equal(new[] { 25, 26, 31, 41 }, host.OperationalTrackerForTest.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
Assert.NotNull(host.ServicingTrackerForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServicingWatcherDisabled_BuildsNoServicingTracker()
|
||||
{
|
||||
Assert.Null(Build(osUpdateSignalsEnabled: false, servicingWatcherEnabled: false).ServicingTrackerForTest);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- engine signal behind flag ----
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSignalsDisabled_ListsTheUpdate_ButPostsNoEngineSignal()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallStarted, Lcu, At);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_RebootRequired, "UpdateAgentLCU", At.AddMinutes(20));
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateServicing));
|
||||
Assert.Empty(OsUpdateActivity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSignalsEnabled_PostsOsUpdateActivity_FromBothSources()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: true);
|
||||
var wuAt = DateTime.SpecifyKind(At, DateTimeKind.Unspecified);
|
||||
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallStarted, Lcu, wuAt);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_RebootRequired, "UpdateAgentLCU", At.AddMinutes(20));
|
||||
|
||||
var signals = OsUpdateActivity();
|
||||
Assert.Equal(2, signals.Count);
|
||||
|
||||
var wu = signals[0];
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, wu.Payload![OsUpdatePayloadKeys.Step]);
|
||||
Assert.Equal("wu", wu.Payload![OsUpdatePayloadKeys.Source]);
|
||||
Assert.Equal(Lcu, wu.Payload![OsUpdatePayloadKeys.Update]);
|
||||
Assert.Equal("WindowsUpdateWatcher", wu.SourceOrigin);
|
||||
Assert.Equal(EvidenceKind.Raw, wu.Evidence.Kind);
|
||||
Assert.Equal("os-update-activity:wu", wu.Evidence.Identifier);
|
||||
Assert.Equal(At, wu.OccurredAtUtc);
|
||||
Assert.Equal(DateTimeKind.Utc, wu.OccurredAtUtc.Kind);
|
||||
Assert.Equal(1, wu.KindSchemaVersion);
|
||||
|
||||
var cbs = signals[1];
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, cbs.Payload![OsUpdatePayloadKeys.Step]);
|
||||
Assert.Equal("servicing", cbs.Payload![OsUpdatePayloadKeys.Source]);
|
||||
Assert.Equal("KB5099999", cbs.Payload![OsUpdatePayloadKeys.Update]);
|
||||
Assert.Equal("ServicingWatcher", cbs.SourceOrigin);
|
||||
Assert.Equal("os-update-activity:servicing", cbs.Evidence.Identifier);
|
||||
Assert.Equal(At.AddMinutes(20), cbs.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSignalsEnabled_NonOsActivity_NeverReachesTheEngine()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: true);
|
||||
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, "9NSTH9KHZDLQ-Microsoft.UI.Xaml.2.8", At);
|
||||
Wu(host.OperationalTrackerForTest!, WindowsUpdateTracker.EventId_Downloaded,
|
||||
"Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.459.505.0) - Current Channel (Broad)", At);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_StateReached, "CbsTask", At);
|
||||
|
||||
Assert.Empty(OsUpdateActivity());
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- activity summary ----
|
||||
|
||||
[Fact]
|
||||
public void ActivitySummary_IsEmittedOnce_WhenTheHostStops()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, "9NSTH9KHZDLQ-Microsoft.UI.Xaml.2.8", At);
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, "9NBLGGH4NNS1-Microsoft.DesktopAppInstaller", At);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_InitiatingChanges, "CbsTask", At);
|
||||
|
||||
// The termination path stops the host explicitly and disposes it afterwards.
|
||||
host.Stop();
|
||||
host.Dispose();
|
||||
|
||||
var summary = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateActivitySummary));
|
||||
Assert.Equal("Debug", summary.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Contains("3 event(s)", summary.Payload![SignalPayloadKeys.Message]);
|
||||
var data = (IReadOnlyDictionary<string, object>)summary.TypedPayload!;
|
||||
Assert.Equal(2, data["store_19"]);
|
||||
Assert.Equal(1, data["servicing_other_1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivitySummary_NothingCounted_EmitsNothing()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, Lcu, At);
|
||||
|
||||
host.Stop();
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.WindowsUpdateActivitySummary));
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -102,6 +102,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Equal(expected, sink.Posted[0].Payload![SignalPayloadKeys.HelloWaitTimeoutSeconds]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "Shadow")] // no config value — the default mode
|
||||
[InlineData("Shadow", "Shadow")]
|
||||
[InlineData("active", "Active")] // case-insensitive
|
||||
[InlineData("Off", "Off")]
|
||||
[InlineData("Enforce", "Shadow")] // unknown — changes no decision, still measures
|
||||
public void Oobe_update_phase_mode_is_stamped_at_every_start_normalized(string? configured, string expected)
|
||||
{
|
||||
// D-310: stamped every time, so a later Off/Shadow can take Active away mid-session.
|
||||
using var tmp = new TempDirectory();
|
||||
var sink = new FakeSignalIngressSink();
|
||||
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(
|
||||
sink, HelloWaitTimeout.DefaultSeconds, NewLogger(tmp.Path), configured);
|
||||
|
||||
Assert.Equal(expected, sink.Posted[0].Payload![SignalPayloadKeys.OobeUpdatePhaseMode]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Swallows_sink_exceptions()
|
||||
{
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
/// <summary>
|
||||
/// D-316: every emitted platform-script completion carries a run id, and the tracker's run register holds what
|
||||
/// was emitted for it under the IME user id — what the registry reconciliation pairs IME's saved result with.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerAdapterPlatformRunTests
|
||||
{
|
||||
private static readonly DateTime ClockNow = new DateTime(2026, 10, 3, 21, 0, 0, DateTimeKind.Utc);
|
||||
private const string DeviceUser = "00000000-0000-0000-0000-000000000000";
|
||||
private const string PolicyId = "1d6f0a52-3c4e-4b7a-9e21-5f8c7d2b9a10";
|
||||
|
||||
[Fact]
|
||||
public void Platform_completion_carries_its_run_id_and_the_register_holds_the_emitted_run()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddSeconds(-10);
|
||||
f.Tracker.HandlePlatformScriptStarted(PolicyId, "ime", DeviceUser);
|
||||
f.Tracker.RecordPlatformScriptExitCodeForTesting(PolicyId, 0, ClockNow.AddSeconds(-1));
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow;
|
||||
f.Tracker.CompletePlatformScriptFromImeResultForTesting(PolicyId, "Success", ClockNow);
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.True(Guid.TryParse(info.Payload!["runId"], out _));
|
||||
|
||||
f.Tracker.ScriptRuns.NotePassCompleted(ClockNow.AddMinutes(1));
|
||||
Assert.Equal(PlatformRunClaim.Claimed, f.Tracker.ScriptRuns.TryClaim(DeviceUser, PolicyId, ClockNow.AddSeconds(5), out var run));
|
||||
Assert.Equal(info.Payload["runId"], run!.RunId);
|
||||
Assert.Equal(0, run.ExitCode);
|
||||
Assert.Equal("Success", run.Result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[PowerShell] User Id = 00000000-0000-0000-0000-000000000000, Policy id = d94468af-6ebe-4e94-9901-2a62d33ea6c1, policy result = Success", "00000000-0000-0000-0000-000000000000")]
|
||||
[InlineData(@"Adding argument powershell with value C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\6C3F8A20-1D5E-4B97-8F4A-0E2D6B9C7A13_d94468af-6ebe-4e94-9901-2a62d33ea6c1.ps1", "6c3f8a20-1d5e-4b97-8f4a-0e2d6b9c7a13")]
|
||||
[InlineData(@"Script file C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\00000000-0000-0000-0000-000000000000_d94468af-6ebe-4e94-9901-2a62d33ea6c1.ps1 is generated", "00000000-0000-0000-0000-000000000000")]
|
||||
[InlineData(@"Adding argument powershellDetection with value C:\Program Files (x86)\Microsoft Intune Management Extension\Content\DetectionScripts\8b2e4c71-6a9d-4f03-b5e8-2c7a1d9f4e36_2.ps1", null)]
|
||||
[InlineData("[PowerShell] User Id = not-a-guid, Policy id = d94468af", null)]
|
||||
[InlineData("", null)]
|
||||
public void Ime_user_id_is_read_from_the_result_line_and_the_script_path(string text, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeLogTracker.ImeUserIdFrom(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -933,6 +933,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed.ToString(), (string)data["outcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_propagates_stopped_waiting_reason_and_cause_in_agent_shutting_down_data()
|
||||
{
|
||||
// D-310: the engine stopped waiting for the user (no sign-in after the OOBE update).
|
||||
// The backend classifies this shutdown honestly — it keys on the reason tag.
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.EspAccountSetup }.Build();
|
||||
|
||||
rig.Build().Handle(sender: null!, new EnrollmentTerminatedEventArgs(
|
||||
EnrollmentTerminationReason.StoppedWaiting,
|
||||
EnrollmentTerminationOutcome.TimedOut,
|
||||
SessionStage.EspAccountSetup.ToString(),
|
||||
EndUtc,
|
||||
details: StoppedWaitingReasons.Describe(StoppedWaitingReasons.OobeUpdateNoSignIn),
|
||||
stopReason: StoppedWaitingReasons.OobeUpdateNoSignIn));
|
||||
|
||||
var data = rig.DataOf(Constants.EventTypes.AgentShuttingDown);
|
||||
Assert.NotNull(data);
|
||||
Assert.Equal("stopped_waiting", (string)data!["reason"]);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, (string)data["stopReason"]);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.TimedOut.ToString(), (string)data["outcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_omits_stop_reason_for_other_shutdowns()
|
||||
{
|
||||
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.TimedOut, SessionStage.AwaitingHello));
|
||||
|
||||
var data = rig.DataOf(Constants.EventTypes.AgentShuttingDown);
|
||||
Assert.NotNull(data);
|
||||
Assert.False(data!.ContainsKey("stopReason"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_includes_uptime_and_agent_version_in_agent_shutting_down()
|
||||
{
|
||||
|
||||
+297
-62
@@ -271,17 +271,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
else
|
||||
{
|
||||
HandlePlatformScriptStarted(id, source);
|
||||
HandlePlatformScriptStarted(id, source, ImeUserIdFrom(match.Value));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The IME user id on a platform-script line — <c>User Id = {id}</c> on the result line,
|
||||
/// <c>…\Policies\Scripts\{id}_{policy}.ps1</c> on the start lines — or null. Read from the
|
||||
/// matched text, not from a capture group, so it works with every pattern pack the agent may
|
||||
/// be served.
|
||||
/// </summary>
|
||||
internal static string ImeUserIdFrom(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return null;
|
||||
const string resultTag = "User Id = ";
|
||||
const string pathTag = @"\Policies\Scripts\";
|
||||
var i = text.IndexOf(resultTag, StringComparison.OrdinalIgnoreCase);
|
||||
if (i >= 0) return GuidAt(text, i + resultTag.Length);
|
||||
i = text.IndexOf(pathTag, StringComparison.OrdinalIgnoreCase);
|
||||
return i >= 0 ? GuidAt(text, i + pathTag.Length) : null;
|
||||
}
|
||||
|
||||
private static string GuidAt(string text, int start)
|
||||
{
|
||||
if (start + 36 > text.Length) return null;
|
||||
var candidate = text.Substring(start, 36);
|
||||
return Guid.TryParse(candidate, out _) ? candidate.ToLowerInvariant() : null;
|
||||
}
|
||||
|
||||
// Platform script: AgentExecutor.log entries create/enrich, IME log entries also create.
|
||||
// Internal so the stale-slot hardening is directly testable (the public path arrives via
|
||||
// a regex Match).
|
||||
internal void HandlePlatformScriptStarted(string id, string source = null)
|
||||
internal void HandlePlatformScriptStarted(string id, string source = null, string userId = null)
|
||||
{
|
||||
var currentStartTs = LastMatchedLogTimestamp ?? UtcNowProvider();
|
||||
var currentStartProvenance = CaptureLastMatchedProvenance();
|
||||
var fromExecutor = string.Equals(source, "agentexecutor", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// A start line older than the result this policy already emitted is the late start
|
||||
// line of THAT run: its AgentExecutor block surfaced after the IME result — the two
|
||||
@@ -294,7 +319,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// never tie; a low-resolution clock in a test may).
|
||||
if (_platformScriptResultEmitted.TryGetValue(id, out var emittedResultTs) && currentStartTs < emittedResultTs)
|
||||
{
|
||||
RecordInvocationMarker(id, isClose: false);
|
||||
RecordInvocationMarker(id, isClose: false, EmittedRunId);
|
||||
NoteInvocationActivity();
|
||||
_logger.Debug($"ImeLogTracker: platform script start line for {id} (source: {source ?? "ime"}) predates its emitted result — late line of that run, not a new one");
|
||||
return;
|
||||
@@ -302,7 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
if (_pendingPlatformScripts.TryGetValue(id, out var pendingSlot))
|
||||
{
|
||||
if (pendingSlot.StartedAtUtc.HasValue && currentStartTs - pendingSlot.StartedAtUtc.Value > TimeSpan.FromHours(24))
|
||||
if (pendingSlot.StartedAtUtc.HasValue && currentStartTs - pendingSlot.StartedAtUtc.Value > StalePlatformRunAge)
|
||||
{
|
||||
// Stale-slot hardening (session eaf3d8c4): a replayed start line from a previous
|
||||
// enrollment whose matching result never appeared (script was mid-run when that
|
||||
@@ -319,6 +344,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Emit what the held run has; its end block did not surface in time.
|
||||
EmitPlatformScriptResult(pendingSlot, "the next run of this policy started");
|
||||
}
|
||||
else if (fromExecutor && LastMatchedLogTimestamp.HasValue && pendingSlot.Result == null
|
||||
&& pendingSlot.ExecutorStartedAtUtc.HasValue && pendingSlot.ExecutorStartedAtUtc.Value < currentStartTs)
|
||||
{
|
||||
// Every executor process writes its start line once: a later one is the next run
|
||||
// (IME re-runs a failed script on a later check-in), whose end block must not
|
||||
// overwrite this one's. This run waits for its IME result — on the first pass
|
||||
// AgentExecutor.log is read in full before the IME log (session eff1b413).
|
||||
ParkPlatformRun(pendingSlot);
|
||||
}
|
||||
else if (!pendingSlot.StartedAtUtc.HasValue)
|
||||
{
|
||||
// The slot was opened by its result while the start block was still hidden;
|
||||
@@ -328,7 +362,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_stateDirty = true;
|
||||
}
|
||||
}
|
||||
if (!_pendingPlatformScripts.ContainsKey(id))
|
||||
if (_pendingPlatformScripts.TryGetValue(id, out var slot))
|
||||
{
|
||||
if (slot.UserId == null && userId != null) slot.UserId = userId;
|
||||
if (fromExecutor && !slot.ExecutorStartedAtUtc.HasValue)
|
||||
{
|
||||
slot.ExecutorStartedAtUtc = currentStartTs;
|
||||
_stateDirty = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// A fresh start for this policy (no pending entry) begins a NEW execution.
|
||||
// Clear any emitted-marker from a prior run so IME re-evaluations / retries of
|
||||
@@ -336,24 +379,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// deduped away. A start line of the emitted run itself never reaches this point
|
||||
// (the guard above), so this never clears the current run's own marker.
|
||||
_platformScriptResultEmitted.Remove(id);
|
||||
_pendingPlatformScripts[id] = new ScriptExecutionState
|
||||
slot = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = id,
|
||||
ScriptType = "platform",
|
||||
UserId = userId,
|
||||
RunId = NewRunId(),
|
||||
// Prefer the source CMTrace timestamp so a script that started before the
|
||||
// agent launched (replayed log content) is dated to its real start, not now.
|
||||
// Set only at slot creation: the start line fires twice (agentexecutor + ime
|
||||
// source) but the earliest wins.
|
||||
StartedAtUtc = currentStartTs,
|
||||
StartedAtProvenance = currentStartProvenance,
|
||||
ExecutorStartedAtUtc = fromExecutor ? currentStartTs : (DateTime?)null,
|
||||
};
|
||||
_pendingPlatformScripts[id] = slot;
|
||||
ScriptRuns.NoteStarted(userId, id, UtcNowProvider());
|
||||
// Live "running" indicator — same signal health scripts emit. Gated on slot
|
||||
// creation so the duplicate start line (agentexecutor + ime source) doesn't
|
||||
// double-emit for the same execution.
|
||||
try { OnScriptStarted?.Invoke(new ScriptStartedInfo { PolicyId = id, ScriptType = "platform" }); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: OnScriptStarted handler threw: {ex.Message}"); }
|
||||
}
|
||||
RecordInvocationMarker(id, isClose: false);
|
||||
RecordInvocationMarker(id, isClose: false, LastMatchedLogTimestamp.HasValue ? RunIdOfStartLine(slot, fromExecutor, currentStartTs) : slot.RunId);
|
||||
NoteInvocationActivity();
|
||||
// Started lines fire twice per script (agentexecutor + ime source) and carry no
|
||||
// outcome — the matching `platform script completed` line below carries result+exit
|
||||
@@ -361,6 +409,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_logger.Debug($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})");
|
||||
}
|
||||
|
||||
/// <summary>How long after IME's "Script file … is generated" line its executor writes its start line (about 2 s).</summary>
|
||||
private static readonly TimeSpan ImeLaunchWindow = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// The run a timestamped start line belongs to, for its invocation marker. An executor start line belongs to the
|
||||
/// slot unless it is older than the slot's executor start — re-read behind a later run — then to the parked run
|
||||
/// it started. IME's "Script file … is generated" precedes its executor: it belongs to the live run whose
|
||||
/// executor started within <see cref="ImeLaunchWindow"/> after it, else to a slot whose executor has not
|
||||
/// started yet; otherwise its run is not known (yet) and the lines behind it go nowhere.
|
||||
/// </summary>
|
||||
private string RunIdOfStartLine(ScriptExecutionState slot, bool fromExecutor, DateTime lineTs)
|
||||
{
|
||||
if (fromExecutor)
|
||||
{
|
||||
if (!slot.ExecutorStartedAtUtc.HasValue || slot.ExecutorStartedAtUtc.Value <= lineTs) return slot.RunId;
|
||||
var parked = _parkedPlatformScripts.FirstOrDefault(r =>
|
||||
string.Equals(r.PolicyId, slot.PolicyId, StringComparison.OrdinalIgnoreCase) && r.ExecutorStartedAtUtc == lineTs);
|
||||
return parked?.RunId ?? EmittedRunId;
|
||||
}
|
||||
|
||||
var launched = LivePlatformRuns(slot.PolicyId)
|
||||
.Where(r => r.ExecutorStartedAtUtc.HasValue && r.ExecutorStartedAtUtc.Value >= lineTs && r.ExecutorStartedAtUtc.Value - lineTs <= ImeLaunchWindow)
|
||||
.OrderBy(r => r.ExecutorStartedAtUtc.Value)
|
||||
.FirstOrDefault();
|
||||
if (launched != null) return launched.RunId;
|
||||
return slot.ExecutorStartedAtUtc.HasValue ? EmittedRunId : slot.RunId;
|
||||
}
|
||||
|
||||
// Routes line-by-line script-data handlers (context / exitCode / output) to the right
|
||||
// accumulator based on the pattern's scriptType parameter. Health-script lines feed the
|
||||
// single-slot _pendingHealthScript (the HS-NEW-RESULT JSON delivers the full cycle in one
|
||||
@@ -439,6 +515,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
ApplyExitCode(script, exitCode, LastMatchedLogTimestamp ?? UtcNowProvider(), CaptureLastMatchedProvenance());
|
||||
else if (ownerPolicyId != null)
|
||||
_logger.Debug($"ImeLogTracker: exit code {exitCode} belongs to platform script {ownerPolicyId}, whose completion was already emitted — dropped");
|
||||
else
|
||||
LogLineBehindWriterBoundary($"exit code {exitCode}");
|
||||
// An exit code means the executor just wrote its end block and IME is about to
|
||||
// write the result — both land at a stale stream position when another process
|
||||
// appended meanwhile. Check the ledgers on the next pass.
|
||||
@@ -465,17 +543,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null;
|
||||
var script = GetCurrentScriptForLineUpdate(parameters);
|
||||
if (script == null) return;
|
||||
if (script == null)
|
||||
{
|
||||
LogLineBehindWriterBoundary($"script output ({outputType ?? "combined"})");
|
||||
return;
|
||||
}
|
||||
|
||||
// PS-AGENT-OUTPUT captures both stdout and stderr in one pattern
|
||||
var output = match.Groups["output"]?.Value;
|
||||
var error = match.Groups["error"]?.Value;
|
||||
var error = match.Groups["error"];
|
||||
|
||||
if (!string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error))
|
||||
if (error != null && error.Success)
|
||||
{
|
||||
// Combined pattern (write output done. output = ..., error = ...)
|
||||
script.Stdout = TruncateOutput(output.Trim());
|
||||
script.Stderr = TruncateOutput(error.Trim());
|
||||
// Combined pattern (write output done. output = ..., error = ...): both texts as
|
||||
// AgentExecutor wrote them to the result files — the error part is the error file
|
||||
// IME judges. The "error from script" line before it is a snapshot taken before the
|
||||
// output readers finished, so this one replaces it, an empty stdout included.
|
||||
script.Stdout = TruncateOutput(output?.Trim());
|
||||
script.Stderr = TruncateOutput(error.Value.Trim());
|
||||
}
|
||||
else if (string.Equals(outputType, "stderr", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -507,7 +592,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
|
||||
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId, CaptureLastMatchedProvenance());
|
||||
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId, CaptureLastMatchedProvenance(), ImeUserIdFrom(match.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -521,48 +606,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// line reopened the slot, produced a fallback duplicate (session e7f3c910).
|
||||
/// <see cref="FlushPendingPlatformScriptResults"/> emits the held completion.
|
||||
/// </summary>
|
||||
internal void CompletePlatformScriptFromImeResult(string policyId, string result, DateTime? resultLineTimestampUtc, string patternId, CmTraceLineProvenance resultProvenance)
|
||||
internal void CompletePlatformScriptFromImeResult(string policyId, string result, DateTime? resultLineTimestampUtc, string patternId, CmTraceLineProvenance resultProvenance, string userId = null)
|
||||
{
|
||||
// Dedup: the deadline-based fallback (FlushPendingPlatformScriptResults) may already have
|
||||
// emitted this script from its AgentExecutor exit code because IME's authoritative
|
||||
// PS-SCRIPT-RESULT line arrived late. The fallback carries the same exit code + stdout,
|
||||
// so re-emitting here would duplicate the timeline entry and inflate counts. Drop the
|
||||
// now-redundant pending entry and skip.
|
||||
if (_platformScriptResultEmitted.ContainsKey(policyId))
|
||||
// The result belongs to the run of this policy that started last before its line —
|
||||
// the run in flight, or an earlier one that waits for its result while a later run
|
||||
// already started (ParkPlatformRun).
|
||||
var script = FindRunForResult(policyId, userId, resultLineTimestampUtc);
|
||||
if (script == null)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: platform script {policyId} PS-SCRIPT-RESULT arrived after fallback emit — skipping duplicate");
|
||||
_pendingPlatformScripts.Remove(policyId);
|
||||
return;
|
||||
}
|
||||
// Dedup: the deadline-based fallback (FlushPendingPlatformScriptResults) may already
|
||||
// have emitted this run from its AgentExecutor end block because IME's authoritative
|
||||
// PS-SCRIPT-RESULT line arrived late, or the same result line is read again.
|
||||
// Re-emitting would duplicate the timeline entry and inflate counts.
|
||||
if (_platformScriptResultEmitted.ContainsKey(policyId))
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: platform script {policyId} PS-SCRIPT-RESULT arrived after its run was emitted — skipping duplicate");
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge with pending AgentExecutor data if available
|
||||
if (_pendingPlatformScripts.TryGetValue(policyId, out var script))
|
||||
{
|
||||
// Stale-result guard: on an intra-lifetime re-run of the same policy, run 1's
|
||||
// late PS-SCRIPT-RESULT can arrive after run 2's start line already created a
|
||||
// fresh slot (the start clears the emitted-marker). Merging would pair run 2's
|
||||
// StartedAtUtc/state with run 1's result and then drop run 2's genuine result at
|
||||
// the marker check above. A result line always postdates its own run's start
|
||||
// line in the source log, so a result older than the pending slot's start
|
||||
// belongs to a previous run — drop it and keep the live slot intact.
|
||||
if (script.StartedAtUtc.HasValue
|
||||
&& resultLineTimestampUtc.HasValue
|
||||
&& resultLineTimestampUtc.Value < script.StartedAtUtc.Value)
|
||||
// Stale-result guard: every run of this policy in flight started after the result
|
||||
// line — a result line always postdates its own run's start line in the source log,
|
||||
// so it belongs to a previous run. Keep the live runs intact.
|
||||
if (HasLivePlatformRun(policyId))
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: platform script {policyId} result predates the current run's start — stale result from a previous run, skipping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
script = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = policyId,
|
||||
ScriptType = "platform"
|
||||
ScriptType = "platform",
|
||||
UserId = userId,
|
||||
RunId = NewRunId(),
|
||||
};
|
||||
_pendingPlatformScripts[policyId] = script;
|
||||
ScriptRuns.NoteStarted(userId, policyId, UtcNowProvider());
|
||||
}
|
||||
|
||||
if (script.UserId == null && userId != null) script.UserId = userId;
|
||||
ResolveParkedRunsBefore(script);
|
||||
script.Result = result;
|
||||
script.ResultSource = "ime_policy_result";
|
||||
script.ResultObservedAtUtc = resultLineTimestampUtc ?? UtcNowProvider();
|
||||
@@ -591,19 +674,150 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private void EmitPlatformScriptResult(ScriptExecutionState script, string note = null)
|
||||
{
|
||||
_logger.Info($"ImeLogTracker: platform script completed: {script.PolicyId}, result={script.Result}, exit={script.ExitCode}{(note != null ? $" ({note})" : string.Empty)}");
|
||||
if (string.IsNullOrEmpty(script.RunId)) script.RunId = NewRunId();
|
||||
EmitScriptEvent(script);
|
||||
// The register keeps the latest run per (user, policy) for the registry reconciliation:
|
||||
// a run that waited for its result while a later one started must not take that later
|
||||
// run's entry.
|
||||
if (!HasLaterLivePlatformRun(script)) ScriptRuns.NoteEmitted(script, UtcNowProvider());
|
||||
// The run's source stamp, capped at the clock: a result line cannot be read before it
|
||||
// was written, so a stamp ahead of the clock is a wrong zone assumption on that line
|
||||
// (a rewound result resolved in the reader's zone, session 4377911b: +1 h). Left
|
||||
// uncapped, the marker made the genuine NEXT run's start line read as a late line of
|
||||
// this run and its result a duplicate.
|
||||
// this run and its result a duplicate. Only ever raised: a run emitted after a later
|
||||
// one must not move the reference back.
|
||||
var runStamp = script.ResultObservedAtUtc ?? script.ExitObservedAtUtc ?? UtcNowProvider();
|
||||
var nowUtc = UtcNowProvider();
|
||||
_platformScriptResultEmitted[script.PolicyId] = runStamp > nowUtc ? nowUtc : runStamp;
|
||||
_pendingPlatformScripts.Remove(script.PolicyId);
|
||||
var stamp = runStamp > nowUtc ? nowUtc : runStamp;
|
||||
if (!_platformScriptResultEmitted.TryGetValue(script.PolicyId, out var previousStamp) || stamp > previousStamp)
|
||||
_platformScriptResultEmitted[script.PolicyId] = stamp;
|
||||
if (_pendingPlatformScripts.TryGetValue(script.PolicyId, out var current) && ReferenceEquals(current, script))
|
||||
_pendingPlatformScripts.Remove(script.PolicyId);
|
||||
else
|
||||
_parkedPlatformScripts.Remove(script);
|
||||
_stateDirty = true;
|
||||
}
|
||||
|
||||
private static string NewRunId() => Guid.NewGuid().ToString("D");
|
||||
|
||||
/// <summary>A run older than this by source time is from a previous enrollment, not a run in flight.</summary>
|
||||
private static readonly TimeSpan StalePlatformRunAge = TimeSpan.FromHours(24);
|
||||
|
||||
private static bool SameUserOrUnknown(string a, string b)
|
||||
=> a == null || b == null || string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static DateTime StartOf(ScriptExecutionState run) => run.StartedAtUtc ?? DateTime.MinValue;
|
||||
|
||||
/// <summary>The runs of a policy that are not emitted yet: the earlier ones waiting for their IME result, then the one in flight.</summary>
|
||||
private IEnumerable<ScriptExecutionState> LivePlatformRuns(string policyId)
|
||||
{
|
||||
foreach (var run in _parkedPlatformScripts)
|
||||
{
|
||||
if (string.Equals(run.PolicyId, policyId, StringComparison.OrdinalIgnoreCase)) yield return run;
|
||||
}
|
||||
if (_pendingPlatformScripts.TryGetValue(policyId, out var current)) yield return current;
|
||||
}
|
||||
|
||||
private bool HasLivePlatformRun(string policyId) => LivePlatformRuns(policyId).Any();
|
||||
|
||||
/// <summary>The live run a start marker names, or null once that run was emitted.</summary>
|
||||
private ScriptExecutionState FindLivePlatformRun(string policyId, string runId)
|
||||
=> LivePlatformRuns(policyId).FirstOrDefault(r => string.Equals(r.RunId, runId, StringComparison.Ordinal));
|
||||
|
||||
private bool HasLaterLivePlatformRun(ScriptExecutionState script)
|
||||
=> LivePlatformRuns(script.PolicyId).Any(r => !ReferenceEquals(r, script)
|
||||
&& SameUserOrUnknown(r.UserId, script.UserId) && StartOf(r) > StartOf(script));
|
||||
|
||||
/// <summary>
|
||||
/// The live run an IME result line belongs to: of the runs of its policy that started at or before the line,
|
||||
/// the latest — one of the same IME user when there is one.
|
||||
/// </summary>
|
||||
private ScriptExecutionState FindRunForResult(string policyId, string userId, DateTime? resultLineTimestampUtc)
|
||||
{
|
||||
ScriptExecutionState sameUser = null, anyUser = null;
|
||||
foreach (var run in LivePlatformRuns(policyId))
|
||||
{
|
||||
if (resultLineTimestampUtc.HasValue && run.StartedAtUtc.HasValue && run.StartedAtUtc.Value > resultLineTimestampUtc.Value) continue;
|
||||
if (anyUser == null || StartOf(run) > StartOf(anyUser)) anyUser = run;
|
||||
if (SameUserOrUnknown(run.UserId, userId) && (sameUser == null || StartOf(run) > StartOf(sameUser))) sameUser = run;
|
||||
}
|
||||
return sameUser ?? anyUser;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the run in flight aside when the next executor run of its policy starts: it keeps its own end block and
|
||||
/// waits for its IME result (<see cref="FindRunForResult"/>), the exit-code fallback, or a later run's result.
|
||||
/// </summary>
|
||||
private void ParkPlatformRun(ScriptExecutionState run)
|
||||
{
|
||||
_pendingPlatformScripts.Remove(run.PolicyId);
|
||||
_parkedPlatformScripts.Add(run);
|
||||
_stateDirty = true;
|
||||
if (_parkedPlatformScripts.Count > MaxParkedPlatformScripts)
|
||||
{
|
||||
var oldest = _parkedPlatformScripts.OrderBy(StartOf).First();
|
||||
_logger.Warning($"ImeLogTracker: more than {MaxParkedPlatformScripts} platform runs wait for their IME result — resolving the oldest ({oldest.PolicyId}, started {oldest.StartedAtUtc:o}) now");
|
||||
ResolveParkedRun(oldest, "too many runs waiting for their IME result");
|
||||
}
|
||||
_logger.Debug($"ImeLogTracker: platform script {run.PolicyId}: the next executor run started before IME's result of run {run.RunId} was read — that run keeps its end block and waits for its result");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Results arrive in run order: a parked run of the same IME user that started before the run a result belongs to
|
||||
/// gets none any more (its result line was lost) — it goes out now, ahead of the later run.
|
||||
/// </summary>
|
||||
private void ResolveParkedRunsBefore(ScriptExecutionState owner)
|
||||
{
|
||||
if (_parkedPlatformScripts.Count == 0 || !owner.StartedAtUtc.HasValue) return;
|
||||
var earlier = _parkedPlatformScripts
|
||||
.Where(r => !ReferenceEquals(r, owner)
|
||||
&& string.Equals(r.PolicyId, owner.PolicyId, StringComparison.OrdinalIgnoreCase)
|
||||
&& SameUserOrUnknown(r.UserId, owner.UserId)
|
||||
&& r.StartedAtUtc.HasValue && r.StartedAtUtc.Value < owner.StartedAtUtc.Value)
|
||||
.OrderBy(StartOf)
|
||||
.ToList();
|
||||
foreach (var run in earlier) ResolveParkedRun(run, "a later run of this policy got its IME result first");
|
||||
}
|
||||
|
||||
/// <summary>A parked run that will not get its IME result: out with what it has, or dropped when it has nothing.</summary>
|
||||
private void ResolveParkedRun(ScriptExecutionState run, string why)
|
||||
{
|
||||
if (run.Result != null)
|
||||
{
|
||||
EmitPlatformScriptResult(run, why);
|
||||
}
|
||||
else if (run.ExitCode.HasValue)
|
||||
{
|
||||
EmitFromExecutorEndBlock(run, why);
|
||||
}
|
||||
else
|
||||
{
|
||||
_parkedPlatformScripts.Remove(run);
|
||||
_stateDirty = true;
|
||||
_logger.Debug($"ImeLogTracker: platform script {run.PolicyId} run {run.RunId} has neither IME result nor executor end block — dropped ({why})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IME's own verdict from a run's end block: Success exactly when the error file was empty
|
||||
/// (<c>IsNullOrEmpty(errorFile.Trim())</c> in IME), whatever the exit code — PowerShell returns 0 after a
|
||||
/// non-terminating error and any code a script exits with. Only when the stderr line was never read does the
|
||||
/// exit code stand in.
|
||||
/// </summary>
|
||||
internal static string ImeResultFromEndBlock(ScriptExecutionState script)
|
||||
{
|
||||
if (script.Stderr != null) return string.IsNullOrWhiteSpace(script.Stderr) ? "Success" : "Failed";
|
||||
return script.ExitCode == 0 ? "Success" : "Failed";
|
||||
}
|
||||
|
||||
private void EmitFromExecutorEndBlock(ScriptExecutionState script, string why)
|
||||
{
|
||||
script.Result = ImeResultFromEndBlock(script);
|
||||
script.ResultSource = "agentexecutor_fallback";
|
||||
var basis = script.Stderr == null ? $"exit code {script.ExitCode}, stderr not read" : script.Stderr.Trim().Length == 0 ? "empty stderr" : "stderr present";
|
||||
EmitPlatformScriptResult(script, $"{why} — {script.Result} from the executor end block ({basis}) (fallback)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grace period after a platform script's AgentExecutor exit code is observed before
|
||||
/// <see cref="FlushPendingPlatformScriptResults"/> emits a completion from that exit code.
|
||||
@@ -641,29 +855,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// out once its executor end block has been read — the block arrives within one pass, so
|
||||
/// stdout/stderr are in by now — or after <see cref="PlatformScriptEndBlockGrace"/>.</item>
|
||||
/// <item>A script with a known AgentExecutor exit code but no IME <c>PS-SCRIPT-RESULT</c>
|
||||
/// line within <see cref="PlatformScriptResultGrace"/> is emitted from the exit code
|
||||
/// (0 → Success, else Failed), tagged <c>resultSource=agentexecutor_fallback</c> so the
|
||||
/// data is honest about its provenance. Without this, scripts that completed shortly
|
||||
/// before the agent terminates were silently dropped.</item>
|
||||
/// line within <see cref="PlatformScriptResultGrace"/> is emitted from its end block by
|
||||
/// IME's own rule (<see cref="ImeResultFromEndBlock"/>), tagged
|
||||
/// <c>resultSource=agentexecutor_fallback</c> so the data is honest about its provenance.
|
||||
/// Without this, scripts that completed shortly before the agent terminates were silently
|
||||
/// dropped.</item>
|
||||
/// </list>
|
||||
/// Parked runs go first, oldest first, so a policy's runs leave in run order; one without
|
||||
/// result and end block keeps waiting — across a shutdown too — until a later run's result
|
||||
/// resolves it or it is a day old.
|
||||
/// Runs on the single-threaded polling loop (no locking needed, same as the handlers).
|
||||
/// <paramref name="force"/> bypasses both grace checks for the final pass on shutdown.
|
||||
/// </summary>
|
||||
internal void FlushPendingPlatformScriptResults(DateTime nowUtc, bool force = false)
|
||||
{
|
||||
if (_pendingPlatformScripts.Count == 0) return;
|
||||
if (_pendingPlatformScripts.Count == 0 && _parkedPlatformScripts.Count == 0) return;
|
||||
|
||||
var fallbackEmitted = false;
|
||||
foreach (var script in _pendingPlatformScripts.Values.ToList())
|
||||
foreach (var run in _parkedPlatformScripts.OrderBy(StartOf).ToList())
|
||||
{
|
||||
// Already emitted by the authoritative path — just drop the stale buffer entry.
|
||||
if (_platformScriptResultEmitted.ContainsKey(script.PolicyId))
|
||||
if (run.Result != null)
|
||||
{
|
||||
_pendingPlatformScripts.Remove(script.PolicyId);
|
||||
_stateDirty = true;
|
||||
if (run.ExitCode.HasValue || force || !run.ResultHeldSinceUtc.HasValue || nowUtc - run.ResultHeldSinceUtc.Value >= PlatformScriptEndBlockGrace)
|
||||
EmitPlatformScriptResult(run, run.ExitCode.HasValue ? "executor end block read after the result" : $"no executor end block within {PlatformScriptEndBlockGrace.TotalSeconds:F0}s");
|
||||
continue;
|
||||
}
|
||||
if (run.ExitCode.HasValue)
|
||||
{
|
||||
if (!force && (!run.ExitObservedAtUtc.HasValue || nowUtc - run.ExitObservedAtUtc.Value < PlatformScriptResultGrace)) continue;
|
||||
EmitFromExecutorEndBlock(run, $"result not seen within {PlatformScriptResultGrace.TotalSeconds:F0}s{(force ? ", shutdown flush" : string.Empty)}");
|
||||
fallbackEmitted = true;
|
||||
continue;
|
||||
}
|
||||
if (run.StartedAtUtc.HasValue && nowUtc - run.StartedAtUtc.Value > StalePlatformRunAge)
|
||||
{
|
||||
_parkedPlatformScripts.Remove(run);
|
||||
_stateDirty = true;
|
||||
_logger.Debug($"ImeLogTracker: platform script {run.PolicyId} run {run.RunId} waited a day without IME result or executor end block — dropped");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var script in _pendingPlatformScripts.Values.ToList())
|
||||
{
|
||||
if (script.Result != null)
|
||||
{
|
||||
if (script.ExitCode.HasValue)
|
||||
@@ -682,10 +915,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
if (nowUtc - script.ExitObservedAtUtc.Value < PlatformScriptResultGrace) continue;
|
||||
}
|
||||
|
||||
script.Result = script.ExitCode.Value == 0 ? "Success" : "Failed";
|
||||
script.ResultSource = "agentexecutor_fallback";
|
||||
EmitPlatformScriptResult(script,
|
||||
$"result not seen within {PlatformScriptResultGrace.TotalSeconds:F0}s{(force ? ", shutdown flush" : string.Empty)} — emitted from AgentExecutor exit code {script.ExitCode.Value} (fallback)");
|
||||
EmitFromExecutorEndBlock(script, $"result not seen within {PlatformScriptResultGrace.TotalSeconds:F0}s{(force ? ", shutdown flush" : string.Empty)}");
|
||||
fallbackEmitted = true;
|
||||
}
|
||||
|
||||
@@ -697,7 +927,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>Test seam: inject a pending platform script as if AgentExecutor.log had reported
|
||||
/// its start + exit code, without an IME PS-SCRIPT-RESULT line.</summary>
|
||||
internal void SeedPendingPlatformScriptForTesting(string policyId, int? exitCode, DateTime? exitObservedAtUtc, string stdout = null, DateTime? startedAtUtc = null,
|
||||
CmTraceLineProvenance startedAtProvenance = null, CmTraceLineProvenance exitProvenance = null)
|
||||
CmTraceLineProvenance startedAtProvenance = null, CmTraceLineProvenance exitProvenance = null, string stderr = null)
|
||||
{
|
||||
// Mirrors a fresh execution start: a new run clears any prior emitted-marker (see
|
||||
// HandleScriptStarted) so re-runs of the same policy within one lifetime emit again.
|
||||
@@ -706,9 +936,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
PolicyId = policyId,
|
||||
ScriptType = "platform",
|
||||
RunId = NewRunId(),
|
||||
ExitCode = exitCode,
|
||||
ExitObservedAtUtc = exitObservedAtUtc,
|
||||
Stdout = stdout,
|
||||
Stderr = stderr,
|
||||
StartedAtUtc = startedAtUtc,
|
||||
StartedAtProvenance = startedAtProvenance,
|
||||
ExitProvenance = exitProvenance,
|
||||
@@ -717,6 +949,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
RecordInvocationMarker(policyId, isClose: false);
|
||||
}
|
||||
|
||||
/// <summary>Test seam: earlier runs waiting for their IME result while a later run of their policy is in flight.</summary>
|
||||
internal int ParkedPlatformScriptCountForTest => _parkedPlatformScripts.Count;
|
||||
|
||||
/// <summary>Test seam: the authoritative IME PS-SCRIPT-RESULT path for a platform script
|
||||
/// (the production handler minus regex extraction). <paramref name="resultLineTimestampUtc"/>
|
||||
/// stands in for the CMTrace timestamp of the PS-SCRIPT-RESULT line (production reads it
|
||||
@@ -1163,11 +1398,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
internal static readonly TimeSpan HeldScriptStartGrace = TimeSpan.FromMinutes(30);
|
||||
internal static readonly TimeSpan HeldScriptStartShutdownGrace = TimeSpan.FromMinutes(5);
|
||||
|
||||
private static string TruncateOutput(string output)
|
||||
internal static string TruncateOutput(string output)
|
||||
{
|
||||
if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength)
|
||||
return output;
|
||||
return output.Substring(0, MaxScriptOutputLength) + "...[truncated]";
|
||||
return output.Substring(0, MaxScriptOutputLength) + OutputTruncationMarker;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
+22
-8
@@ -123,16 +123,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
var ledger = IsMultiWriterLogFile(fileName) ? GetLedger(fileName) : null;
|
||||
|
||||
// Set by a rewind: entries that still read unchanged behind the old bookmark are
|
||||
// skipped, and nothing below the old bookmark is fresh or a calibration anchor.
|
||||
HashSet<LedgerEntry> rewoundEntries = null;
|
||||
// skipped (their invocation markers are given back), and nothing below the old
|
||||
// bookmark is fresh or a calibration anchor.
|
||||
RewindState rewind = null;
|
||||
long oldBookmark = -1;
|
||||
var writerBoundaries = IsWriterBoundaryLogFile(fileName);
|
||||
|
||||
if (ledger != null)
|
||||
{
|
||||
var rewindTo = await PrepareLedgerAsync(filePath, fileName, ledger, startPos, passNowUtc, token);
|
||||
if (rewindTo >= 0)
|
||||
{
|
||||
rewoundEntries = ApplyRewind(filePath, fileName, ledger, rewindTo, startPos);
|
||||
rewind = ApplyRewind(filePath, fileName, ledger, rewindTo, startPos);
|
||||
oldBookmark = startPos;
|
||||
startPos = rewindTo;
|
||||
}
|
||||
@@ -164,7 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
// Guard zone: the file grew and is open anyway — re-check the entries just behind
|
||||
// the bookmark, where a stale writer's block lands when it is short.
|
||||
if (ledger != null && rewoundEntries == null && ledger.Entries.Count > 0)
|
||||
if (ledger != null && rewind == null && ledger.Entries.Count > 0)
|
||||
{
|
||||
var guardFrom = ledger.OffsetOfEntryAtOrAfter(startPos - OverwriteGuardBytes);
|
||||
if (guardFrom >= 0 && guardFrom < startPos)
|
||||
@@ -178,7 +180,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_verifiedBytes += startPos - ledger.StartOffset;
|
||||
_verifyPasses++;
|
||||
if (earliest >= 0) divergence = earliest;
|
||||
rewoundEntries = ApplyRewind(filePath, fileName, ledger, divergence, startPos);
|
||||
rewind = ApplyRewind(filePath, fileName, ledger, divergence, startPos);
|
||||
oldBookmark = startPos;
|
||||
startPos = divergence;
|
||||
}
|
||||
@@ -259,7 +261,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// A fragment right at the bookmark of a multi-writer file (the previous
|
||||
// entry ended exactly here, this one does not open an entry) is the tail of
|
||||
// a block a stale writer laid over already-read bytes: verify before reading on.
|
||||
if (ledger != null && rewoundEntries == null && entry.Offset == ledger.EndOffset
|
||||
if (ledger != null && rewind == null && entry.Offset == ledger.EndOffset
|
||||
&& ledger.LastFragmentOffset != entry.Offset && !entry.Text.StartsWith("<![LOG["))
|
||||
{
|
||||
ledger.VerifyRequested = true;
|
||||
@@ -270,8 +272,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
|
||||
if (ledger != null) ledger.Record(entry);
|
||||
if (rewoundEntries != null && rewoundEntries.Contains(new LedgerEntry { Offset = entry.Offset, Hash = entry.Hash }))
|
||||
continue; // unchanged behind the old bookmark — processed before the rewind
|
||||
var unchanged = rewind != null && rewind.IsUnchanged(entry);
|
||||
if (unchanged) RestoreInvocationMarkers(rewind, entry.Offset);
|
||||
|
||||
// Writer boundary (see ImeLogTracker.Overwrite.cs): an AgentExecutor.log entry that is
|
||||
// not exactly one CMTrace record ends the bytes of the executor in front of it. Never
|
||||
// matched — its text is the cut of two writers. Evaluated for unchanged entries too:
|
||||
// what owns the position in front of them may have changed with the rewind.
|
||||
if (writerBoundaries && !IsSingleCmTraceRecord(entry.Text))
|
||||
{
|
||||
CloseAtWriterBoundary(fileName, entry.Offset, entry.Text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (unchanged) continue; // unchanged behind the old bookmark — processed before the rewind
|
||||
|
||||
_currentPassLinesAreFresh = passLinesAreFresh && entry.Offset >= oldBookmark;
|
||||
|
||||
|
||||
+186
-32
@@ -293,13 +293,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Moves the bookmark back to the first changed entry; returns the entries to skip if they still read unchanged behind it.</summary>
|
||||
private HashSet<LedgerEntry> ApplyRewind(string filePath, string fileName, FileLedger ledger, long rewindTo, long bookmark)
|
||||
/// <summary>
|
||||
/// What one rewind pass carries: the entries to skip if they still read unchanged behind the rewind point, and
|
||||
/// the invocation markers set aside there until their entries prove unchanged. Pass-local by design — whatever
|
||||
/// is not given back dies with the pass on every exit path.
|
||||
/// </summary>
|
||||
private sealed class RewindState
|
||||
{
|
||||
public RewindState(string fileName, HashSet<LedgerEntry> unchanged, List<InvocationMarker> markers, InvocationMarker lastPlatformMarker)
|
||||
{
|
||||
FileName = fileName;
|
||||
Unchanged = unchanged;
|
||||
Markers = markers;
|
||||
LastPlatformMarker = lastPlatformMarker;
|
||||
}
|
||||
|
||||
public string FileName { get; }
|
||||
public HashSet<LedgerEntry> Unchanged { get; }
|
||||
/// <summary>Set aside at the rewind, ascending by offset.</summary>
|
||||
public List<InvocationMarker> Markers { get; }
|
||||
/// <summary>The tracker-wide last platform marker, when it was among the set-aside ones.</summary>
|
||||
public InvocationMarker LastPlatformMarker { get; }
|
||||
public int Next { get; set; }
|
||||
|
||||
public bool IsUnchanged(AssembledEntry entry) => Unchanged.Contains(new LedgerEntry { Offset = entry.Offset, Hash = entry.Hash });
|
||||
}
|
||||
|
||||
/// <summary>Moves the bookmark back to the first changed entry; returns what the re-read needs to skip and to give back.</summary>
|
||||
private RewindState ApplyRewind(string filePath, string fileName, FileLedger ledger, long rewindTo, long bookmark)
|
||||
{
|
||||
var removed = ledger.TruncateFrom(rewindTo);
|
||||
_positionTracker.SetPosition(filePath, rewindTo);
|
||||
_heldTails.Remove(filePath);
|
||||
InvalidateInvocationMarkersFrom(fileName, rewindTo);
|
||||
var lastPlatformMarker = _lastPlatformMarker;
|
||||
var markers = TakeInvocationMarkersFrom(fileName, rewindTo, out var tookLastPlatformMarker);
|
||||
_overwriteRewinds++;
|
||||
_overwriteBytesReprocessed += bookmark - rewindTo;
|
||||
|
||||
@@ -314,7 +341,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
_logger.Debug(message);
|
||||
}
|
||||
return removed;
|
||||
return new RewindState(fileName, removed, markers, tookLastPlatformMarker ? lastPlatformMarker : null);
|
||||
}
|
||||
|
||||
private long RetentionFloor(string fileName, long bookmark)
|
||||
@@ -331,20 +358,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// AgentExecutor.log carries "Powershell exit code is N" / "write output done" without a
|
||||
// policy id. Their owner is decided by FILE POSITION, not by the order lines were read:
|
||||
// an executor writes its end block at its own stream position, i.e. directly after its
|
||||
// own start block in the final bytes, so the nearest preceding invocation marker names
|
||||
// it — and stays right after a rewind re-reads that region. A marker is a platform start
|
||||
// (policy id), some other invocation (detection, remediation, requirement: null) or a
|
||||
// close ("gets invoked" banner, "Agent executor completed").
|
||||
// policy id. Their owner is decided by FILE POSITION: the nearest preceding marker — a
|
||||
// platform start (policy id), some other invocation (detection, remediation, requirement:
|
||||
// null), a close ("gets invoked" banner, "Agent executor completed") or a writer boundary.
|
||||
//
|
||||
// Every executor writes its lines contiguously from its own stream position, so its end
|
||||
// block follows its own start block — but that start block may be gone. Two executors that
|
||||
// open the file at the same end write their start blocks over each other, and the end block
|
||||
// of the one that lost then sits behind the OTHER executor's start block (session 756970cf:
|
||||
// a detection script's exit code and output on a platform script). Where one writer's bytes
|
||||
// meet another's, the cut leaves an entry that is not exactly one CMTrace record — a
|
||||
// fragment, a merged line or an empty line — unless the line boundaries of both writers
|
||||
// coincide byte-exactly. That writer boundary closes the platform invocation in front of it.
|
||||
// AgentExecutor.log only: in the IME log the service writes the markers and the lines they
|
||||
// own itself, and its fragments are executor telemetry lines.
|
||||
//
|
||||
// A marker lives exactly as long as the bytes of its entry: a rewind sets the markers from
|
||||
// the rewind point on aside, and an entry that reads unchanged gets its markers back.
|
||||
|
||||
private sealed class InvocationMarker
|
||||
{
|
||||
public long Offset;
|
||||
public string PolicyId;
|
||||
/// <summary>
|
||||
/// The platform run the start line belongs to: the lines behind it go to that run while it is pending or
|
||||
/// waits for its IME result, and nowhere once it was emitted — never to a later run of the same policy.
|
||||
/// Null only on seeded test markers, which resolve by policy.
|
||||
/// </summary>
|
||||
public string RunId;
|
||||
public bool IsClose;
|
||||
/// <summary>Set on a writer boundary: the platform invocation it closed.</summary>
|
||||
public string ClosedPolicyId;
|
||||
}
|
||||
|
||||
/// <summary>Run id of a start line whose run was emitted already (a late line): owns its lines, matches no live run.</summary>
|
||||
private const string EmittedRunId = "emitted";
|
||||
|
||||
private const string ExecutorLogFileName = "AgentExecutor.log";
|
||||
|
||||
private readonly Dictionary<string, List<InvocationMarker>> _invocationMarkers =
|
||||
new Dictionary<string, List<InvocationMarker>>(StringComparer.OrdinalIgnoreCase);
|
||||
private InvocationMarker _lastPlatformMarker;
|
||||
@@ -354,24 +405,62 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private readonly HashSet<string> _platformMarkerFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
private long _testEntryOrdinal;
|
||||
|
||||
private void RecordInvocationMarker(string policyId, bool isClose)
|
||||
/// <summary>
|
||||
/// The executor log, live or rotated (<c>AgentExecutor-yyyyMMdd-HHmmss.log</c>): its invocations are one process
|
||||
/// each, so a writer boundary ends an invocation — in the final bytes of a rotation as much as in the live file.
|
||||
/// </summary>
|
||||
internal static bool IsWriterBoundaryLogFile(string fileName)
|
||||
=> string.Equals(fileName, ExecutorLogFileName, StringComparison.OrdinalIgnoreCase)
|
||||
|| (fileName != null
|
||||
&& fileName.StartsWith("AgentExecutor-", StringComparison.OrdinalIgnoreCase)
|
||||
&& fileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// True when the entry is exactly one CMTrace record: it opens with <c><![LOG[</c> (culture-sensitive like
|
||||
/// <c>CmTraceLogParser.TryParseLine</c>, so a BOM is ignorable) and holds no second one — the parser takes the
|
||||
/// message up to the LAST trailer, so a line cut anywhere, its trailer included, would swallow the record
|
||||
/// written over it. An empty entry is the terminator of a cut line. A script whose own output contains the
|
||||
/// open tag loses that output line: the bytes cannot tell it from a merged line.
|
||||
/// </summary>
|
||||
internal static bool IsSingleCmTraceRecord(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || !text.StartsWith(LogOpenTag)) return false;
|
||||
var first = text.IndexOf(LogOpenTag, StringComparison.Ordinal);
|
||||
return first >= 0 && text.IndexOf(LogOpenTag, first + LogOpenTag.Length, StringComparison.Ordinal) < 0;
|
||||
}
|
||||
|
||||
private List<InvocationMarker> GetOrCreateMarkerList(string file)
|
||||
{
|
||||
if (_currentEntryOffset < 0) return;
|
||||
var file = _currentSourceFileName ?? TestSourceFileName;
|
||||
List<InvocationMarker> list;
|
||||
if (!_invocationMarkers.TryGetValue(file, out list))
|
||||
{
|
||||
list = new List<InvocationMarker>();
|
||||
_invocationMarkers[file] = list;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static void AppendInvocationMarker(List<InvocationMarker> list, InvocationMarker marker)
|
||||
{
|
||||
if (list.Count >= MaxInvocationMarkersPerFile) list.RemoveAt(0);
|
||||
list.Add(marker);
|
||||
}
|
||||
|
||||
private void RecordInvocationMarker(string policyId, bool isClose, string runId = null)
|
||||
{
|
||||
if (_currentEntryOffset < 0) return;
|
||||
var file = _currentSourceFileName ?? TestSourceFileName;
|
||||
var list = GetOrCreateMarkerList(file);
|
||||
|
||||
if (list.Count > 0 && list[list.Count - 1].Offset == _currentEntryOffset)
|
||||
{
|
||||
// Two patterns on one line: the platform start outranks the generic argument line.
|
||||
// A writer boundary is never upgraded.
|
||||
var last = list[list.Count - 1];
|
||||
if (!isClose && policyId != null)
|
||||
if (!isClose && policyId != null && last.ClosedPolicyId == null)
|
||||
{
|
||||
last.PolicyId = policyId;
|
||||
last.RunId = runId;
|
||||
last.IsClose = false;
|
||||
_lastPlatformMarker = last;
|
||||
_platformMarkerFiles.Add(file);
|
||||
@@ -379,9 +468,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return;
|
||||
}
|
||||
|
||||
if (list.Count >= MaxInvocationMarkersPerFile) list.RemoveAt(0);
|
||||
var marker = new InvocationMarker { Offset = _currentEntryOffset, PolicyId = policyId, IsClose = isClose };
|
||||
list.Add(marker);
|
||||
var marker = new InvocationMarker { Offset = _currentEntryOffset, PolicyId = policyId, RunId = runId, IsClose = isClose };
|
||||
AppendInvocationMarker(list, marker);
|
||||
if (!isClose && policyId != null)
|
||||
{
|
||||
_lastPlatformMarker = marker;
|
||||
@@ -390,21 +478,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pending platform script that owns the entry being processed, or null.
|
||||
/// <paramref name="ownerPolicyId"/> names the owning platform script even when its slot is
|
||||
/// gone (completion already emitted); null when no platform invocation owns the entry.
|
||||
/// An entry of <paramref name="fileName"/> at <paramref name="offset"/> that is not exactly one CMTrace record:
|
||||
/// the bytes behind it were written by another process than the bytes in front of it. Closes the platform
|
||||
/// invocation that owns the position; records nothing when none is open there (a cut multi-line output yields
|
||||
/// one fragment per line). Idempotent at the same offset.
|
||||
/// </summary>
|
||||
private ScriptExecutionState ResolvePlatformScriptForCurrentEntry(out string ownerPolicyId)
|
||||
private void CloseAtWriterBoundary(string fileName, long offset, string text)
|
||||
{
|
||||
var owner = FindOwningMarker(fileName, offset);
|
||||
if (owner == null || owner.IsClose || owner.PolicyId == null) return;
|
||||
|
||||
var list = GetOrCreateMarkerList(fileName);
|
||||
if (list.Count > 0 && list[list.Count - 1].Offset >= offset) return;
|
||||
AppendInvocationMarker(list, new InvocationMarker { Offset = offset, IsClose = true, ClosedPolicyId = owner.PolicyId });
|
||||
|
||||
var kind = string.IsNullOrEmpty(text) ? "empty line" : text.StartsWith(LogOpenTag) ? "merged line" : "fragment";
|
||||
_logger.Debug($"ImeLogTracker: writer boundary in {fileName} at offset {offset} ({kind}, {text?.Length ?? 0} chars) closes platform script {owner.PolicyId} (opened at {owner.Offset}) — the bytes behind it belong to another executor");
|
||||
}
|
||||
|
||||
/// <summary>The marker that owns <paramref name="offset"/> in <paramref name="file"/>, or null.</summary>
|
||||
private InvocationMarker FindOwningMarker(string file, long offset)
|
||||
{
|
||||
ownerPolicyId = null;
|
||||
var file = _currentSourceFileName ?? TestSourceFileName;
|
||||
InvocationMarker marker = null;
|
||||
List<InvocationMarker> list = null;
|
||||
if (_currentEntryOffset >= 0 && _invocationMarkers.TryGetValue(file, out list))
|
||||
if (offset >= 0 && _invocationMarkers.TryGetValue(file, out list))
|
||||
{
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (list[i].Offset <= _currentEntryOffset)
|
||||
if (list[i].Offset <= offset)
|
||||
{
|
||||
marker = list[i];
|
||||
break;
|
||||
@@ -420,24 +521,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// (session 24dc69d1: the health-script worker's exit line pre-filled a platform slot's
|
||||
// exit code, its launch line the slot's context).
|
||||
if (marker == null && (list == null || list.Count == 0) && _platformMarkerFiles.Contains(file)) marker = _lastPlatformMarker;
|
||||
return marker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pending platform script that owns the entry being processed, or null.
|
||||
/// <paramref name="ownerPolicyId"/> names the owning platform script even when its slot is
|
||||
/// gone (completion already emitted); null when no platform invocation owns the entry.
|
||||
/// </summary>
|
||||
private ScriptExecutionState ResolvePlatformScriptForCurrentEntry(out string ownerPolicyId)
|
||||
{
|
||||
ownerPolicyId = null;
|
||||
var marker = FindOwningMarker(_currentSourceFileName ?? TestSourceFileName, _currentEntryOffset);
|
||||
if (marker == null || marker.IsClose || marker.PolicyId == null) return null;
|
||||
|
||||
ownerPolicyId = marker.PolicyId;
|
||||
if (marker.RunId != null) return FindLivePlatformRun(marker.PolicyId, marker.RunId);
|
||||
ScriptExecutionState state;
|
||||
return _pendingPlatformScripts.TryGetValue(marker.PolicyId, out state) ? state : null;
|
||||
}
|
||||
|
||||
private void InvalidateInvocationMarkersFrom(string fileName, long offset)
|
||||
/// <summary>Debug trace for an executor line that a writer boundary kept away from a platform script.</summary>
|
||||
private void LogLineBehindWriterBoundary(string what)
|
||||
{
|
||||
var marker = FindOwningMarker(_currentSourceFileName ?? TestSourceFileName, _currentEntryOffset);
|
||||
if (marker?.ClosedPolicyId == null) return;
|
||||
_logger.Debug($"ImeLogTracker: {what} at offset {_currentEntryOffset} lies behind the writer boundary at {marker.Offset} that closed platform script {marker.ClosedPolicyId} — another executor's end block, not attributed");
|
||||
}
|
||||
|
||||
/// <summary>Removes the markers from <paramref name="offset"/> on and returns them in offset order.</summary>
|
||||
private List<InvocationMarker> TakeInvocationMarkersFrom(string fileName, long offset, out bool tookLastPlatformMarker)
|
||||
{
|
||||
tookLastPlatformMarker = false;
|
||||
var taken = new List<InvocationMarker>();
|
||||
List<InvocationMarker> list;
|
||||
if (!_invocationMarkers.TryGetValue(fileName, out list) || list.Count == 0) return;
|
||||
var removedLast = false;
|
||||
for (var i = list.Count - 1; i >= 0 && list[i].Offset >= offset; i--)
|
||||
if (!_invocationMarkers.TryGetValue(fileName, out list) || list.Count == 0) return taken;
|
||||
|
||||
var from = list.Count;
|
||||
while (from > 0 && list[from - 1].Offset >= offset) from--;
|
||||
for (var i = from; i < list.Count; i++)
|
||||
{
|
||||
if (ReferenceEquals(list[i], _lastPlatformMarker)) removedLast = true;
|
||||
list.RemoveAt(i);
|
||||
if (ReferenceEquals(list[i], _lastPlatformMarker)) tookLastPlatformMarker = true;
|
||||
taken.Add(list[i]);
|
||||
}
|
||||
if (removedLast)
|
||||
list.RemoveRange(from, list.Count - from);
|
||||
|
||||
if (tookLastPlatformMarker)
|
||||
{
|
||||
_lastPlatformMarker = null;
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
@@ -449,6 +578,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
private void InvalidateInvocationMarkersFrom(string fileName, long offset) => TakeInvocationMarkersFrom(fileName, offset, out _);
|
||||
|
||||
/// <summary>
|
||||
/// An entry that reads unchanged after a rewind keeps its markers: gives back the set-aside markers at exactly
|
||||
/// <paramref name="entryOffset"/>. Set-aside markers in front of it belonged to entries that changed and stay
|
||||
/// gone with their bytes.
|
||||
/// </summary>
|
||||
private void RestoreInvocationMarkers(RewindState rewind, long entryOffset)
|
||||
{
|
||||
var markers = rewind.Markers;
|
||||
while (rewind.Next < markers.Count && markers[rewind.Next].Offset < entryOffset) rewind.Next++;
|
||||
while (rewind.Next < markers.Count && markers[rewind.Next].Offset == entryOffset)
|
||||
{
|
||||
var marker = markers[rewind.Next++];
|
||||
var list = GetOrCreateMarkerList(rewind.FileName);
|
||||
if (list.Count > 0 && list[list.Count - 1].Offset >= marker.Offset) continue;
|
||||
AppendInvocationMarker(list, marker);
|
||||
if (ReferenceEquals(marker, rewind.LastPlatformMarker)) _lastPlatformMarker = marker;
|
||||
}
|
||||
}
|
||||
|
||||
private long OldestPendingScriptMarkerOffset(string fileName)
|
||||
@@ -474,6 +625,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>What a rollover of the file does to its invocation markers.</summary>
|
||||
internal void ClearInvocationMarkersForTest(string fileName) => InvalidateInvocationMarkersFrom(fileName, 0);
|
||||
|
||||
internal int InvocationMarkerCountForTest(string fileName)
|
||||
=> _invocationMarkers.TryGetValue(fileName, out var list) ? list.Count : 0;
|
||||
|
||||
internal int LedgerEntryCountForTest(string fileName)
|
||||
{
|
||||
FileLedger ledger;
|
||||
|
||||
+38
-1
@@ -182,6 +182,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private readonly Dictionary<string, ScriptExecutionState> _pendingPlatformScripts =
|
||||
new Dictionary<string, ScriptExecutionState>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Earlier runs of a policy whose next executor run started before IME's result of them was
|
||||
// read (IME re-runs a failed script on later check-ins; the first pass reads all of
|
||||
// AgentExecutor.log before the IME log). Each keeps its own end block until its result
|
||||
// arrives — session eff1b413 lost that and paired the first result with the last run's block.
|
||||
private readonly List<ScriptExecutionState> _parkedPlatformScripts = new List<ScriptExecutionState>();
|
||||
private const int MaxParkedPlatformScripts = 64;
|
||||
|
||||
/// <summary>
|
||||
/// The platform runs this tracker started and emitted, for the registry reconciliation that
|
||||
/// checks them against IME's saved results (D-316). Shared with the registry host's thread.
|
||||
/// </summary>
|
||||
internal PlatformScriptRunRegister ScriptRuns { get; } = new PlatformScriptRunRegister();
|
||||
|
||||
// Health-script (remediation) line-by-line accumulator — single slot because IME
|
||||
// executes health scripts SEQUENTIALLY within a session (verified across multiple
|
||||
// diagnostic captures: ProcessScript → context → exit → stdout → stderr → compliance,
|
||||
@@ -240,7 +253,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// the 32K-char Table Storage property limit that bounds the event downstream. Growth
|
||||
// driver is the number of log lines the bootstrap chain writes, so re-measure before
|
||||
// adding a chatty step rather than raising this again.
|
||||
private const int MaxScriptOutputLength = 8192;
|
||||
internal const int MaxScriptOutputLength = 8192;
|
||||
internal const string OutputTruncationMarker = "...[truncated]";
|
||||
private const int MaxMultiLineBufferLines = 100;
|
||||
// Size cap for ONE entry — a single physical line (enforced by BoundedLineReader before
|
||||
// the line is materialized) and an assembled multiline entry alike. Any process able to
|
||||
@@ -389,6 +403,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
VerifiedBytes = _verifiedBytes,
|
||||
PassMaxMs = _passMaxMs,
|
||||
PassGapMaxMs = _passGapMaxMs,
|
||||
PlatformOutputVerified = ScriptRuns.Verified,
|
||||
PlatformOutputCorrected = ScriptRuns.Corrected,
|
||||
PatternHits = new Dictionary<string, int>(_patternHits, StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
}
|
||||
@@ -1034,6 +1050,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
internal async Task RunPollPassAsync(CancellationToken token)
|
||||
{
|
||||
var startMs = MonotonicMillisProvider();
|
||||
var passStartedUtc = UtcNowProvider();
|
||||
if (_lastPassEndMs >= 0 && startMs - _lastPassEndMs > _passGapMaxMs)
|
||||
{
|
||||
_passGapMaxMs = startMs - _lastPassEndMs;
|
||||
@@ -1045,6 +1062,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
FlushRecurringScripts(UtcNowProvider(), shuttingDown: false);
|
||||
CheckPendingTokenFailure(UtcNowProvider());
|
||||
|
||||
// The lines written before this pass started are read now — a held tail or a fragment
|
||||
// awaiting its check follows a pass or two later, which TrackerCatchUp covers. The
|
||||
// registry reconciliation waits for such a pass before it pairs an IME save with a run.
|
||||
if (!token.IsCancellationRequested) ScriptRuns.NotePassCompleted(passStartedUtc);
|
||||
if (ScriptRuns.ConsumeCountersChanged()) _stateDirty = true;
|
||||
|
||||
var endMs = MonotonicMillisProvider();
|
||||
if (endMs - startMs > _passMaxMs)
|
||||
{
|
||||
@@ -1175,6 +1198,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
_parkedPlatformScripts.Clear();
|
||||
if (state.ParkedPlatformScripts != null)
|
||||
{
|
||||
foreach (var parked in state.ParkedPlatformScripts)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(parked?.PolicyId) && _parkedPlatformScripts.Count < MaxParkedPlatformScripts)
|
||||
_parkedPlatformScripts.Add(parked);
|
||||
}
|
||||
}
|
||||
|
||||
_scriptTimeoutSuspectedPosted.Clear();
|
||||
if (state.ScriptTimeoutSuspectedPosted != null)
|
||||
{
|
||||
@@ -1207,6 +1240,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_verifiedBytes = state.Health.VerifiedBytes;
|
||||
_passMaxMs = state.Health.PassMaxMs;
|
||||
_passGapMaxMs = state.Health.PassGapMaxMs;
|
||||
ScriptRuns.RestoreCounters(state.Health.PlatformOutputVerified, state.Health.PlatformOutputCorrected);
|
||||
}
|
||||
if (state.PatternHits != null)
|
||||
{
|
||||
@@ -1259,6 +1293,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// must survive restarts — see the LoadState comment for the duplicate scenario.
|
||||
PlatformScriptResultEmittedAt = new Dictionary<string, DateTime>(_platformScriptResultEmitted, StringComparer.OrdinalIgnoreCase),
|
||||
PendingPlatformScripts = _pendingPlatformScripts.Values.ToList(),
|
||||
ParkedPlatformScripts = _parkedPlatformScripts.ToList(),
|
||||
ScriptTimeoutSuspectedPosted = _scriptTimeoutSuspectedPosted.ToList(),
|
||||
RecurringScripts = _recurringScripts.ToPersisted(),
|
||||
};
|
||||
@@ -1283,6 +1318,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
VerifiedBytes = _verifiedBytes,
|
||||
PassMaxMs = _passMaxMs,
|
||||
PassGapMaxMs = _passGapMaxMs,
|
||||
PlatformOutputVerified = ScriptRuns.Verified,
|
||||
PlatformOutputCorrected = ScriptRuns.Corrected,
|
||||
};
|
||||
state.PatternHits = new Dictionary<string, int>(_patternHits, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
+10
@@ -81,6 +81,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// </summary>
|
||||
public long PassGapMaxMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform-script runs whose emitted output IME's saved result (registry, after the batch
|
||||
/// report) confirmed — the exit code of the same end block with it. Together with
|
||||
/// <see cref="PlatformOutputCorrected"/> the field measure of the registry reconciliation (D-316).
|
||||
/// </summary>
|
||||
public long PlatformOutputVerified { get; set; }
|
||||
|
||||
/// <summary>Platform-script runs whose emitted output IME's saved result corrected (script_output_reconciliation).</summary>
|
||||
public long PlatformOutputCorrected { get; set; }
|
||||
|
||||
/// <summary>Match count per enabled pattern ID — every enabled pattern is present, zeros included.</summary>
|
||||
public IReadOnlyDictionary<string, int> PatternHits { get; set; }
|
||||
|
||||
|
||||
+7
@@ -151,6 +151,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// that run from the start of the next one.
|
||||
public Dictionary<string, DateTime> PlatformScriptResultEmittedAt { get; set; }
|
||||
public List<ScriptExecutionState> PendingPlatformScripts { get; set; }
|
||||
// Earlier runs that wait for their IME result while a later run of the same policy is in
|
||||
// flight; a restart between reading AgentExecutor.log and the IME log must keep them. Null on
|
||||
// older state files → none.
|
||||
public List<ScriptExecutionState> ParkedPlatformScripts { get; set; }
|
||||
public List<string> ScriptTimeoutSuspectedPosted { get; set; }
|
||||
|
||||
// Recurring health-script runs collapsed per policy. Saved in the same write as
|
||||
@@ -185,6 +189,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Poll-loop timing maxima (null-defaulting on older state files).
|
||||
public long PassMaxMs { get; set; }
|
||||
public long PassGapMaxMs { get; set; }
|
||||
// Registry reconciliation of platform-script output (null-defaulting on older state files).
|
||||
public long PlatformOutputVerified { get; set; }
|
||||
public long PlatformOutputCorrected { get; set; }
|
||||
}
|
||||
|
||||
public class PackageStateData
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>What the registry reconciliation may do with a saved IME result right now.</summary>
|
||||
internal enum PlatformRunClaim
|
||||
{
|
||||
/// <summary>The run is handed out for reconciliation; it is never handed out again.</summary>
|
||||
Claimed,
|
||||
/// <summary>Not decidable yet: the tracker has not read past the save, or the run is not emitted yet.</summary>
|
||||
Wait,
|
||||
/// <summary>No run of this agent owns the save (none seen, or already reconciled).</summary>
|
||||
None,
|
||||
}
|
||||
|
||||
/// <summary>One platform-script run as the tracker emitted it; the register hands out copies.</summary>
|
||||
internal sealed class PlatformScriptRun
|
||||
{
|
||||
public string PolicyId;
|
||||
public string UserId;
|
||||
public string RunId;
|
||||
public DateTime StartSeenUtc;
|
||||
public bool Emitted;
|
||||
public bool Claimed;
|
||||
public string Result;
|
||||
public string ResultSource;
|
||||
public int? ExitCode;
|
||||
/// <summary>As emitted: null when no output line of the run was read, empty when it said so.</summary>
|
||||
public string Stdout;
|
||||
public string Stderr;
|
||||
|
||||
public PlatformScriptRun Clone() => (PlatformScriptRun)MemberwiseClone();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The platform-script runs the tracker has seen, the latest per (IME user id, policy id), for the
|
||||
/// registry reconciliation (D-316). IME saves a run's output under
|
||||
/// <c>Policies\<UserId>\<PolicyId></c> only after it reported the whole script batch, so the
|
||||
/// observer reading it must know which run a changed key belongs to and what was emitted for it.
|
||||
/// Written on the tracker's poll thread, claimed on the registry host's timer thread: every member
|
||||
/// locks, and nothing calls out while holding the lock.
|
||||
/// <para>
|
||||
/// A save belongs to the latest run of its key: IME runs a policy at most once per check-in and
|
||||
/// saves after reporting that check-in's batch, so the next run of the key comes with a later
|
||||
/// check-in. (Overlapping check-ins can run a key twice at once; then IME itself shares the key's
|
||||
/// result files and the judge's foreign verdict is the safety net.) A run that waited for its IME
|
||||
/// result while a later run of its key already started is emitted without touching that later
|
||||
/// run's entry. A save is paired only after the tracker completed a pass that started
|
||||
/// <see cref="TrackerCatchUp"/> after the save was seen — every line written before the save, the
|
||||
/// run's start among them, has been read by then, a stalled poll loop included.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class PlatformScriptRunRegister
|
||||
{
|
||||
/// <summary>
|
||||
/// How far behind a seen save the tracker must have started a completed pass before the save
|
||||
/// is paired; covers the ledger's verify cadence (1 s) for a start line hidden by an overwrite.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan TrackerCatchUp = TimeSpan.FromSeconds(2);
|
||||
|
||||
internal const int MaxRuns = 256;
|
||||
|
||||
private readonly object _sync = new object();
|
||||
private readonly Dictionary<string, PlatformScriptRun> _latest =
|
||||
new Dictionary<string, PlatformScriptRun>(StringComparer.OrdinalIgnoreCase);
|
||||
private DateTime _lastCompletedPassStartUtc = DateTime.MinValue;
|
||||
private long _verified;
|
||||
private long _corrected;
|
||||
private bool _countersChanged;
|
||||
|
||||
private static string KeyOf(string userId, string policyId) => (userId ?? string.Empty) + "|" + policyId;
|
||||
|
||||
/// <summary>A platform run began: its start line, or its result when no start line was seen.</summary>
|
||||
public void NoteStarted(string userId, string policyId, DateTime seenUtc)
|
||||
{
|
||||
if (string.IsNullOrEmpty(policyId)) return;
|
||||
lock (_sync)
|
||||
{
|
||||
_latest[KeyOf(userId, policyId)] = new PlatformScriptRun { PolicyId = policyId, UserId = userId, StartSeenUtc = seenUtc };
|
||||
TrimLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The tracker emitted the run with these end-block values.</summary>
|
||||
public void NoteEmitted(ScriptExecutionState script, DateTime emittedUtc)
|
||||
{
|
||||
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
|
||||
lock (_sync)
|
||||
{
|
||||
var key = KeyOf(script.UserId, script.PolicyId);
|
||||
if (!_latest.TryGetValue(key, out var run) || run.Emitted)
|
||||
{
|
||||
run = new PlatformScriptRun { PolicyId = script.PolicyId, UserId = script.UserId, StartSeenUtc = emittedUtc };
|
||||
_latest[key] = run;
|
||||
}
|
||||
run.Emitted = true;
|
||||
run.RunId = script.RunId;
|
||||
run.Result = script.Result;
|
||||
run.ResultSource = script.ResultSource;
|
||||
run.ExitCode = script.ExitCode;
|
||||
run.Stdout = script.Stdout;
|
||||
run.Stderr = script.Stderr;
|
||||
TrimLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The tracker finished a poll pass that started at <paramref name="passStartedUtc"/>.</summary>
|
||||
public void NotePassCompleted(DateTime passStartedUtc)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (passStartedUtc > _lastCompletedPassStartUtc) _lastCompletedPassStartUtc = passStartedUtc;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands out the run a save of (<paramref name="userId"/>, <paramref name="policyId"/>) seen at
|
||||
/// <paramref name="savedSeenUtc"/> belongs to — once. A run whose user id the tracker could not
|
||||
/// read is keyed without one.
|
||||
/// </summary>
|
||||
public PlatformRunClaim TryClaim(string userId, string policyId, DateTime savedSeenUtc, out PlatformScriptRun run)
|
||||
{
|
||||
run = null;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_lastCompletedPassStartUtc < savedSeenUtc + TrackerCatchUp) return PlatformRunClaim.Wait;
|
||||
|
||||
if (!_latest.TryGetValue(KeyOf(userId, policyId), out var latest)
|
||||
&& !_latest.TryGetValue(KeyOf(null, policyId), out latest))
|
||||
return PlatformRunClaim.None;
|
||||
if (latest.Claimed) return PlatformRunClaim.None;
|
||||
if (!latest.Emitted) return PlatformRunClaim.Wait;
|
||||
|
||||
latest.Claimed = true;
|
||||
run = latest.Clone();
|
||||
return PlatformRunClaim.Claimed;
|
||||
}
|
||||
}
|
||||
|
||||
public void CountVerified()
|
||||
{
|
||||
lock (_sync) { _verified++; _countersChanged = true; }
|
||||
}
|
||||
|
||||
public void CountCorrected()
|
||||
{
|
||||
lock (_sync) { _corrected++; _countersChanged = true; }
|
||||
}
|
||||
|
||||
/// <summary>Runs whose emitted output IME's saved result confirmed (restart-safe, session-cumulative).</summary>
|
||||
public long Verified { get { lock (_sync) return _verified; } }
|
||||
|
||||
/// <summary>Runs whose emitted output IME's saved result corrected (restart-safe, session-cumulative).</summary>
|
||||
public long Corrected { get { lock (_sync) return _corrected; } }
|
||||
|
||||
public void RestoreCounters(long verified, long corrected)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_verified = verified;
|
||||
_corrected = corrected;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True once after a counter moved — the tracker then persists its state.</summary>
|
||||
public bool ConsumeCountersChanged()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var changed = _countersChanged;
|
||||
_countersChanged = false;
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
||||
private void TrimLocked()
|
||||
{
|
||||
while (_latest.Count > MaxRuns)
|
||||
{
|
||||
string oldestKey = null;
|
||||
var oldest = DateTime.MaxValue;
|
||||
foreach (var kv in _latest)
|
||||
{
|
||||
if (kv.Value.StartSeenUtc < oldest)
|
||||
{
|
||||
oldest = kv.Value.StartSeenUtc;
|
||||
oldestKey = kv.Key;
|
||||
}
|
||||
}
|
||||
if (oldestKey == null) return;
|
||||
_latest.Remove(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -15,6 +15,28 @@ public class ScriptExecutionState
|
||||
/// <summary>"platform" or "remediation".</summary>
|
||||
public string ScriptType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IME user id a platform script ran for (the all-zero GUID in device context), read from its
|
||||
/// script path or its result line. Keys IME's saved result
|
||||
/// (<c>Policies\<UserId>\<PolicyId></c>) that the registry reconciliation compares against.
|
||||
/// </summary>
|
||||
public string UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity of one platform-script run, given when its slot opens and surfaced as <c>runId</c> on
|
||||
/// the event: the invocation markers bind the run's executor lines to it, and a later
|
||||
/// <c>script_output_reconciliation</c> names it when it corrects that run.
|
||||
/// </summary>
|
||||
public string RunId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Source timestamp of the run's AgentExecutor start line (<c>Adding argument powershell with
|
||||
/// value …</c>). Every executor process writes that line once, so a later one for the same
|
||||
/// policy starts the next run, never this one. Null until the line is read, and on state files
|
||||
/// from before the field.
|
||||
/// </summary>
|
||||
public System.DateTime? ExecutorStartedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp of the script's first observed start line, taken from the source CMTrace log
|
||||
/// timestamp (so it dates correctly even when the agent replays historic IME log content that
|
||||
|
||||
+2
-1
@@ -413,7 +413,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_tenantId,
|
||||
_post,
|
||||
_logger,
|
||||
_helloTracker);
|
||||
_helloTracker,
|
||||
stateDirectory: _stateDirectory);
|
||||
_shellCoreTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
|
||||
_shellCoreTracker.WhiteGloveCompleted += OnWhiteGloveCompleted;
|
||||
_shellCoreTracker.EspFailureDetected += OnShellCoreEspFailureDetected;
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Cross-restart dedup for event-log watchers that backfill a channel on start. RecordIds are
|
||||
/// per channel, so every watched channel owns one instance with its own state file.
|
||||
/// <para>
|
||||
/// Two boundaries, deliberately separate: the restart watermark is the highest RecordId a
|
||||
/// PRIOR run emitted (loaded once, immutable for this run); the seen-set holds what THIS run
|
||||
/// processed. A single high-water mark would be wrong because the live watcher is armed before
|
||||
/// the backfill runs: a live record with a higher RecordId can arrive first and would then
|
||||
/// make the backfill skip every older, never-emitted record.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class EventRecordWatermark
|
||||
{
|
||||
private readonly string _stateFilePath;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _label;
|
||||
private readonly object _lock = new object();
|
||||
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
|
||||
private long _restartWatermark = -1;
|
||||
private long _maxEmittedRecordId = -1;
|
||||
|
||||
/// <param name="stateDirectory">Directory for the state file; null keeps the dedup in memory only.</param>
|
||||
/// <param name="stateFileName">File name inside <paramref name="stateDirectory"/>.</param>
|
||||
/// <param name="label">Watcher name for log lines.</param>
|
||||
public EventRecordWatermark(string stateDirectory, string stateFileName, AgentLogger logger, string label)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_label = label ?? "EventLog";
|
||||
_stateFilePath = string.IsNullOrEmpty(stateDirectory) || string.IsNullOrEmpty(stateFileName)
|
||||
? null
|
||||
: Path.Combine(Environment.ExpandEnvironmentVariables(stateDirectory), stateFileName);
|
||||
}
|
||||
|
||||
/// <summary>Highest RecordId emitted by a prior run; -1 when none was persisted.</summary>
|
||||
public long RestartWatermark
|
||||
{
|
||||
get { lock (_lock) return _restartWatermark; }
|
||||
}
|
||||
|
||||
/// <summary>True when the record was already emitted, by a prior run or by this one.</summary>
|
||||
public bool IsAlreadyProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
|
||||
lock (_lock)
|
||||
{
|
||||
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically claims <paramref name="recordId"/> for this run. True on first sight (the
|
||||
/// caller processes it), false when it was already processed. Records without a RecordId
|
||||
/// (-1) are always claimed, never tracked. Claiming persists nothing — only
|
||||
/// <see cref="MarkEmitted"/> does, so a record that is counted instead of emitted costs no
|
||||
/// state-file write (CBS maintenance writes hundreds of servicing records in seconds).
|
||||
/// </summary>
|
||||
public bool TryClaim(long recordId)
|
||||
{
|
||||
if (recordId < 0) return true;
|
||||
lock (_lock)
|
||||
{
|
||||
return recordId > _restartWatermark && _seenThisRun.Add(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists <paramref name="recordId"/> when it raises the emitted maximum, so a restarted
|
||||
/// agent's backfill skips it. Called after the record's event and engine signal were
|
||||
/// handed to the ingress: a crash before that re-emits the record instead of losing it.
|
||||
/// The ingress makes them durable only when its worker appends them to the SignalLog —
|
||||
/// normally milliseconds later; a process killed inside that window (e.g. by a reboot)
|
||||
/// loses the record, and the backfill does not bring it back. Counted records above the
|
||||
/// persisted maximum are counted again after a restart, which only touches the activity
|
||||
/// summary.
|
||||
/// </summary>
|
||||
public void MarkEmitted(long recordId)
|
||||
{
|
||||
if (recordId < 0) return;
|
||||
|
||||
long toPersist = -1;
|
||||
lock (_lock)
|
||||
{
|
||||
if (recordId > _maxEmittedRecordId)
|
||||
{
|
||||
_maxEmittedRecordId = recordId;
|
||||
toPersist = recordId;
|
||||
}
|
||||
}
|
||||
if (toPersist >= 0) Persist(toPersist);
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
if (_stateFilePath == null || !File.Exists(_stateFilePath)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var state = JsonConvert.DeserializeObject<WatermarkState>(File.ReadAllText(_stateFilePath));
|
||||
if (state == null) return;
|
||||
lock (_lock)
|
||||
{
|
||||
_restartWatermark = state.LastRecordId;
|
||||
_maxEmittedRecordId = state.LastRecordId;
|
||||
}
|
||||
_logger.Info($"{_label} watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to load {_label} watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void Persist(long recordId)
|
||||
{
|
||||
if (_stateFilePath == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_stateFilePath));
|
||||
var json = JsonConvert.SerializeObject(
|
||||
new WatermarkState { LastRecordId = recordId, PersistedUtc = DateTime.UtcNow },
|
||||
Formatting.Indented);
|
||||
var tempPath = _stateFilePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Copy(tempPath, _stateFilePath, overwrite: true);
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to persist {_label} watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WatermarkState
|
||||
{
|
||||
public long LastRecordId { get; set; }
|
||||
public DateTime PersistedUtc { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry second pillar for platform scripts (D-316). After IME reported a script batch to
|
||||
/// Intune it saves every run of the batch under
|
||||
/// <c>HKLM\SOFTWARE\Microsoft\IntuneManagementExtension\Policies\<UserId>\<PolicyId></c>:
|
||||
/// <c>Result</c>, <c>ErrorCode</c> and <c>ResultDetails</c>, whose <c>ExecutionMsg</c> is what IME read
|
||||
/// from the result file of exactly that policy — the stdout when the run succeeded (IME's Success
|
||||
/// means an empty stderr file, whatever the exit code), the stderr when it failed with ErrorCode 99.
|
||||
/// The log tracker assigns a run's exit code and output by file position in AgentExecutor.log, where
|
||||
/// no policy id anchors them; this observer checks what was emitted against the keyed value and
|
||||
/// emits <c>script_output_reconciliation</c> when they disagree.
|
||||
/// <para>
|
||||
/// Snapshot-and-diff like <see cref="ImeRegistryAppStateObserver"/>, ticked by the same host: the
|
||||
/// first tick is the silent baseline, a later save is paired with its run through
|
||||
/// <see cref="PlatformScriptRunRegister"/>. No exit code lives in the registry: a run whose output
|
||||
/// matches proves the exit code of the same end block; a contradicted run loses its exit code.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class ImeRegistryScriptResultObserver
|
||||
{
|
||||
public const string SourceLabel = "RegistryScriptResult";
|
||||
|
||||
internal const string PoliciesKeyPath = ImeRegistryAppStateObserver.ImeRootKeyPath + @"\Policies";
|
||||
|
||||
/// <summary>A save whose run never became claimable is dropped after this long (send failure of a later batch, agent restart).</summary>
|
||||
internal static readonly TimeSpan PendingExpiry = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>Safety cap on correction events per agent run; corrections beyond it are counted only.</summary>
|
||||
internal const int MaxCorrectionsPerSession = 100;
|
||||
|
||||
private readonly PlatformScriptRunRegister _runs;
|
||||
private readonly Func<IReadOnlyDictionary<string, SavedScriptResult>> _snapshotSource;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger? _logger;
|
||||
private readonly IClock _clock;
|
||||
private readonly object _sync = new object();
|
||||
|
||||
private IReadOnlyDictionary<string, SavedScriptResult>? _last;
|
||||
private readonly Dictionary<string, (SavedScriptResult Saved, DateTime SeenUtc)> _pending =
|
||||
new Dictionary<string, (SavedScriptResult, DateTime)>(StringComparer.OrdinalIgnoreCase);
|
||||
private int _correctionsEmitted;
|
||||
|
||||
/// <param name="snapshotSource">Replaces the live registry walk in tests; per instance, so parallel test classes never share it.</param>
|
||||
public ImeRegistryScriptResultObserver(
|
||||
PlatformScriptRunRegister runs,
|
||||
InformationalEventPost post,
|
||||
AgentLogger? logger,
|
||||
IClock clock,
|
||||
Func<IReadOnlyDictionary<string, SavedScriptResult>>? snapshotSource = null)
|
||||
{
|
||||
_runs = runs ?? throw new ArgumentNullException(nameof(runs));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger;
|
||||
_snapshotSource = snapshotSource ?? ReadSnapshot;
|
||||
}
|
||||
|
||||
/// <summary>One observation pass: snapshot → new saves → pair → judge → correct. Fail-soft: never throws.</summary>
|
||||
public void Tick(string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var snapshot = _snapshotSource();
|
||||
var nowUtc = _clock.UtcNow;
|
||||
|
||||
if (_last == null)
|
||||
{
|
||||
// Saves that predate this agent run belong to runs it never emitted.
|
||||
_last = snapshot;
|
||||
_logger?.Info($"RegistryScriptResult: baseline captured ({snapshot.Count} saved platform-script results, trigger={reason})");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var kv in snapshot)
|
||||
{
|
||||
if (_last.TryGetValue(kv.Key, out var previous) && previous.SameSaveAs(kv.Value)) continue;
|
||||
_pending[kv.Key] = (kv.Value, nowUtc); // a newer save of the key replaces a waiting one
|
||||
}
|
||||
_last = snapshot;
|
||||
|
||||
ProcessPending(nowUtc);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Debug($"RegistryScriptResult: tick failed (fail-soft): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessPending(DateTime nowUtc)
|
||||
{
|
||||
foreach (var key in _pending.Keys.ToList())
|
||||
{
|
||||
var (saved, seenUtc) = _pending[key];
|
||||
var claim = _runs.TryClaim(saved.UserId, saved.PolicyId, seenUtc, out var run);
|
||||
if (claim == PlatformRunClaim.Wait)
|
||||
{
|
||||
if (nowUtc - seenUtc >= PendingExpiry) _pending.Remove(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
_pending.Remove(key);
|
||||
if (claim == PlatformRunClaim.Claimed && run != null) Reconcile(run, saved, nowUtc);
|
||||
}
|
||||
}
|
||||
|
||||
private void Reconcile(PlatformScriptRun run, SavedScriptResult saved, DateTime nowUtc)
|
||||
{
|
||||
var judgement = PlatformOutputJudge.Judge(run, saved);
|
||||
switch (judgement.Verdict)
|
||||
{
|
||||
case PlatformOutputVerdict.Verified:
|
||||
_runs.CountVerified();
|
||||
_logger?.Debug($"RegistryScriptResult: platform script {run.PolicyId} output confirmed by IME's saved result");
|
||||
return;
|
||||
case PlatformOutputVerdict.NotComparable:
|
||||
_logger?.Debug($"RegistryScriptResult: platform script {run.PolicyId} not comparable ({judgement.Reason})");
|
||||
return;
|
||||
}
|
||||
|
||||
_runs.CountCorrected();
|
||||
if (_correctionsEmitted >= MaxCorrectionsPerSession)
|
||||
{
|
||||
_logger?.Warning($"RegistryScriptResult: correction for {run.PolicyId} not emitted — cap of {MaxCorrectionsPerSession} per agent run reached");
|
||||
return;
|
||||
}
|
||||
_correctionsEmitted++;
|
||||
|
||||
var foreign = judgement.Verdict == PlatformOutputVerdict.Foreign;
|
||||
var culture = CultureInfo.InvariantCulture;
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["policyId"] = run.PolicyId,
|
||||
["scriptType"] = "platform",
|
||||
["outcome"] = foreign ? "foreign" : "repaired",
|
||||
["reason"] = judgement.Reason,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(run.RunId)) data["runId"] = run.RunId!;
|
||||
if (!string.IsNullOrEmpty(saved.Result)) data["result"] = saved.Result!;
|
||||
if (saved.ErrorCode.HasValue) data["registryErrorCode"] = saved.ErrorCode.Value.ToString(culture);
|
||||
// A present key replaces the emitted value, an empty one clears it; an absent key keeps it.
|
||||
if (judgement.Stdout != null) data["stdout"] = judgement.Stdout;
|
||||
if (judgement.Stderr != null) data["stderr"] = judgement.Stderr;
|
||||
if (foreign && run.ExitCode.HasValue) data["previousExitCode"] = run.ExitCode.Value.ToString(culture);
|
||||
|
||||
var shortId = run.PolicyId.Length > 8 ? run.PolicyId.Substring(0, 8) : run.PolicyId;
|
||||
var message = foreign
|
||||
? $"Platform script {shortId}: output corrected from IME's saved result; the log had attributed another script's output, exit code unverified"
|
||||
: $"Platform script {shortId}: output completed from IME's saved result";
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.ScriptOutputReconciliation,
|
||||
source: SourceLabel,
|
||||
message: message,
|
||||
severity: foreign ? EventSeverity.Warning : EventSeverity.Info,
|
||||
immediateUpload: foreign,
|
||||
data: data,
|
||||
occurredAtUtc: nowUtc);
|
||||
|
||||
if (foreign)
|
||||
_logger?.Warning($"RegistryScriptResult: platform script {run.PolicyId} run {run.RunId} carried another script's end block ({judgement.Reason}) — corrected from IME's saved result");
|
||||
else
|
||||
_logger?.Info($"RegistryScriptResult: platform script {run.PolicyId} run {run.RunId} output completed from IME's saved result ({judgement.Reason})");
|
||||
}
|
||||
|
||||
// ── snapshot ────────────────────────────────────────────────────────────
|
||||
|
||||
internal static string KeyOf(string userId, string policyId) => userId + "|" + policyId;
|
||||
|
||||
internal static IReadOnlyDictionary<string, SavedScriptResult> ReadSnapshot()
|
||||
{
|
||||
var snapshot = new Dictionary<string, SavedScriptResult>(StringComparer.OrdinalIgnoreCase);
|
||||
using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
using var policies = baseKey.OpenSubKey(PoliciesKeyPath);
|
||||
if (policies == null) return snapshot;
|
||||
|
||||
foreach (var userId in policies.GetSubKeyNames())
|
||||
{
|
||||
if (!Guid.TryParse(userId, out _)) continue;
|
||||
using var userKey = policies.OpenSubKey(userId);
|
||||
if (userKey == null) continue;
|
||||
|
||||
foreach (var policyId in userKey.GetSubKeyNames())
|
||||
{
|
||||
if (!Guid.TryParse(policyId, out _)) continue;
|
||||
using var policyKey = userKey.OpenSubKey(policyId);
|
||||
if (policyKey == null) continue;
|
||||
|
||||
snapshot[KeyOf(userId, policyId)] = new SavedScriptResult
|
||||
{
|
||||
UserId = userId,
|
||||
PolicyId = policyId,
|
||||
Result = policyKey.GetValue("Result") as string,
|
||||
ErrorCode = TryReadInt(policyKey.GetValue("ErrorCode")),
|
||||
ResultDetails = policyKey.GetValue("ResultDetails") as string,
|
||||
LastUpdatedTimeUtc = policyKey.GetValue("LastUpdatedTimeUtc") as string,
|
||||
};
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static int? TryReadInt(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case int i: return i;
|
||||
case long l when l >= int.MinValue && l <= int.MaxValue: return (int)l;
|
||||
case string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed): return parsed;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One platform-script result as IME saved it under <c>Policies\<UserId>\<PolicyId></c>.</summary>
|
||||
internal sealed class SavedScriptResult
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string PolicyId { get; set; } = string.Empty;
|
||||
public string? Result { get; set; }
|
||||
public int? ErrorCode { get; set; }
|
||||
public string? ResultDetails { get; set; }
|
||||
public string? LastUpdatedTimeUtc { get; set; }
|
||||
|
||||
/// <summary>The same save: IME stamps every save with a new <c>LastUpdatedTimeUtc</c>.</summary>
|
||||
public bool SameSaveAs(SavedScriptResult other)
|
||||
=> string.Equals(LastUpdatedTimeUtc, other.LastUpdatedTimeUtc, StringComparison.Ordinal)
|
||||
&& string.Equals(Result, other.Result, StringComparison.Ordinal)
|
||||
&& ErrorCode == other.ErrorCode
|
||||
&& string.Equals(ResultDetails, other.ResultDetails, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// <c>ExecutionMsg</c> of the CryptoResult JSON in <c>ResultDetails</c> (IME 1.73 and later), or null
|
||||
/// when the value is not that JSON ("Hash mismatch", a launch failure).
|
||||
/// </summary>
|
||||
public string? ExecutionMessage()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ResultDetails)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(ResultDetails!);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
return doc.RootElement.TryGetProperty("ExecutionMsg", out var msg) && msg.ValueKind == JsonValueKind.String
|
||||
? msg.GetString()
|
||||
: null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PlatformOutputVerdict
|
||||
{
|
||||
/// <summary>IME's saved result cannot confirm or contradict the run (no output saved, other failure kind).</summary>
|
||||
NotComparable,
|
||||
/// <summary>The emitted output is what IME saved: the end block, its exit code included, is the run's own.</summary>
|
||||
Verified,
|
||||
/// <summary>The log delivered the run's output incomplete; IME's saved text completes it, the exit code stands.</summary>
|
||||
Repaired,
|
||||
/// <summary>The emitted output contradicts IME's saved result: the end block was another executor's.</summary>
|
||||
Foreign,
|
||||
}
|
||||
|
||||
internal sealed class PlatformOutputJudgement
|
||||
{
|
||||
public PlatformOutputVerdict Verdict { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
/// <summary>Value to publish (Repaired/Foreign): null keeps the emitted stdout, empty clears it.</summary>
|
||||
public string? Stdout { get; set; }
|
||||
/// <summary>Value to publish (Repaired/Foreign): null keeps the emitted stderr, empty clears it.</summary>
|
||||
public string? Stderr { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares a platform-script run as the tracker emitted it with IME's saved result. Comparable are
|
||||
/// a Success (ExecutionMsg = stdout; IME's Success means the stderr file was empty) and a Failed with
|
||||
/// ErrorCode 99 (ExecutionMsg = stderr). Both texts are compared after CR/LF normalisation and Trim;
|
||||
/// the tracker's own 8192-character cut is a prefix of the saved text.
|
||||
/// </summary>
|
||||
internal static class PlatformOutputJudge
|
||||
{
|
||||
internal const int ImeStderrErrorCode = 99;
|
||||
private const string OutputErrorSeparator = ", error =";
|
||||
|
||||
public static PlatformOutputJudgement Judge(PlatformScriptRun run, SavedScriptResult saved)
|
||||
{
|
||||
var executionMessage = saved.ExecutionMessage();
|
||||
if (executionMessage == null) return NotComparable("no_execution_message");
|
||||
|
||||
// The run's IME result line and the save come from the same IME run.
|
||||
if (string.Equals(run.ResultSource, "ime_policy_result", StringComparison.Ordinal)
|
||||
&& !string.Equals(run.Result, saved.Result, StringComparison.OrdinalIgnoreCase))
|
||||
return NotComparable("result_differs");
|
||||
|
||||
if (string.Equals(saved.Result, "Success", StringComparison.OrdinalIgnoreCase))
|
||||
return JudgeSuccess(run, Normalize(executionMessage)!);
|
||||
if (string.Equals(saved.Result, "Failed", StringComparison.OrdinalIgnoreCase) && saved.ErrorCode == ImeStderrErrorCode)
|
||||
return JudgeStderrFailure(run, Normalize(executionMessage)!);
|
||||
return NotComparable("no_saved_output");
|
||||
}
|
||||
|
||||
private static PlatformOutputJudgement JudgeSuccess(PlatformScriptRun run, string savedStdout)
|
||||
{
|
||||
var stdout = NormalizeEmitted(run.Stdout, out var stdoutCut);
|
||||
var stderr = Normalize(run.Stderr);
|
||||
var stderrClean = string.IsNullOrEmpty(stderr);
|
||||
|
||||
if (!string.IsNullOrEmpty(stdout) && Agrees(stdout!, stdoutCut, savedStdout))
|
||||
return stderrClean ? Verified() : Correct(PlatformOutputVerdict.Repaired, "stderr_cleared", savedStdout, string.Empty);
|
||||
|
||||
if (stdout != null && stdout.Length == 0 && savedStdout.Length == 0)
|
||||
return stderrClean ? Verified() : Correct(PlatformOutputVerdict.Foreign, "stderr_on_success", string.Empty, string.Empty);
|
||||
|
||||
if (stdout == null)
|
||||
{
|
||||
if (!stderrClean) return Correct(PlatformOutputVerdict.Foreign, "stderr_on_success", savedStdout, string.Empty);
|
||||
return savedStdout.Length > 0
|
||||
? Correct(PlatformOutputVerdict.Repaired, "stdout_missing", savedStdout, string.Empty)
|
||||
: NotComparable("nothing_read");
|
||||
}
|
||||
|
||||
// "write output done. output = X, error = Y" splits at the first ", error =" — a stdout that
|
||||
// contains the separator ends up partly in stderr. The saved text shows the cut right there.
|
||||
if (stdout.Length > 0 && !stdoutCut && !stderrClean
|
||||
&& savedStdout.StartsWith(stdout, StringComparison.Ordinal)
|
||||
&& savedStdout.Substring(stdout.Length).TrimStart().StartsWith(OutputErrorSeparator, StringComparison.Ordinal))
|
||||
return Correct(PlatformOutputVerdict.Repaired, "stdout_split", savedStdout, string.Empty);
|
||||
|
||||
return Correct(PlatformOutputVerdict.Foreign, "stdout_differs", savedStdout, string.Empty);
|
||||
}
|
||||
|
||||
private static PlatformOutputJudgement JudgeStderrFailure(PlatformScriptRun run, string savedStderr)
|
||||
{
|
||||
var stderr = NormalizeEmitted(run.Stderr, out var stderrCut);
|
||||
if (!string.IsNullOrEmpty(stderr) && Agrees(stderr!, stderrCut, savedStderr)) return Verified();
|
||||
|
||||
// Nothing of the end block read: complete the stderr, the run's stdout stays as emitted.
|
||||
if (run.Stderr == null && run.Stdout == null)
|
||||
return Correct(PlatformOutputVerdict.Repaired, "stderr_missing", null, savedStderr);
|
||||
|
||||
// The end block read said something else — an empty stderr included: IME read stderr from this
|
||||
// run's error file. The block's stdout is the other executor's as well.
|
||||
return Correct(PlatformOutputVerdict.Foreign, "stderr_differs", string.Empty, savedStderr);
|
||||
}
|
||||
|
||||
private static bool Agrees(string emitted, bool emittedCut, string saved)
|
||||
=> string.Equals(emitted, saved, StringComparison.Ordinal)
|
||||
|| (emittedCut && saved.StartsWith(emitted, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>CR LF and lone CR to LF, then Trim — what the tracker's line reader and handlers make of a text.</summary>
|
||||
internal static string? Normalize(string? text)
|
||||
=> text?.Replace("\r\n", "\n").Replace('\r', '\n').Trim();
|
||||
|
||||
private static string? NormalizeEmitted(string? text, out bool cut)
|
||||
{
|
||||
cut = false;
|
||||
if (text == null) return null;
|
||||
if (text.EndsWith(ImeLogTracker.OutputTruncationMarker, StringComparison.Ordinal))
|
||||
{
|
||||
cut = true;
|
||||
text = text.Substring(0, text.Length - ImeLogTracker.OutputTruncationMarker.Length);
|
||||
}
|
||||
return Normalize(text);
|
||||
}
|
||||
|
||||
private static PlatformOutputJudgement Verified()
|
||||
=> new PlatformOutputJudgement { Verdict = PlatformOutputVerdict.Verified, Reason = "match" };
|
||||
|
||||
private static PlatformOutputJudgement NotComparable(string reason)
|
||||
=> new PlatformOutputJudgement { Verdict = PlatformOutputVerdict.NotComparable, Reason = reason };
|
||||
|
||||
private static PlatformOutputJudgement Correct(PlatformOutputVerdict verdict, string reason, string? stdout, string? stderr)
|
||||
=> new PlatformOutputJudgement
|
||||
{
|
||||
Verdict = verdict,
|
||||
Reason = reason,
|
||||
// The tracker's own output cap: a corrected value is never longer than an emitted one.
|
||||
Stdout = stdout == null ? null : ImeLogTracker.TruncateOutput(stdout),
|
||||
Stderr = stderr == null ? null : ImeLogTracker.TruncateOutput(stderr),
|
||||
};
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the registry state behind the OOBE quality update (D-310, S4/S5): the NDUP state
|
||||
/// Windows keeps while the update page runs, and both policy paths of the ESP setting "Install
|
||||
/// Windows quality updates". <see cref="OobeUpdateTelemetry"/> logs it and reports it as
|
||||
/// <c>oobe_update_state</c>. Read-only; the values are update-state flags and policy values,
|
||||
/// no user data.
|
||||
/// </summary>
|
||||
internal static class OobeUpdateRegistrySnapshot
|
||||
{
|
||||
/// <summary>The snapshot's keys, each with the alias it carries in <c>oobe_update_state</c>.</summary>
|
||||
internal static readonly (string Alias, string Path)[] Keys =
|
||||
{
|
||||
("setupOobeNdup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE\NDUP"),
|
||||
("setupOobeNdupUpdates", @"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE\NDUP\Updates"),
|
||||
("ndup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\NDUP"),
|
||||
("ndupUpdates", @"SOFTWARE\Microsoft\Windows\CurrentVersion\NDUP\Updates"),
|
||||
("espSetupPolicy", @"SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\Device\Setup\Policy"),
|
||||
("policyManagerSystem", PolicyManagerSystemKey),
|
||||
("policiesOobe", @"SOFTWARE\Policies\Microsoft\Windows\OOBE"),
|
||||
};
|
||||
|
||||
// Carries every System policy of the device; only the OOBE ones belong in this snapshot.
|
||||
internal const string PolicyManagerSystemKey = @"SOFTWARE\Microsoft\PolicyManager\current\device\System";
|
||||
|
||||
internal const int MaxEntries = 30;
|
||||
internal const int MaxValueLength = 200;
|
||||
|
||||
/// <summary>One key of the snapshot: whether it exists, whether it holds values, and its line.</summary>
|
||||
internal sealed class KeyState
|
||||
{
|
||||
public KeyState(string alias, string path, bool exists, bool hasValues, string line)
|
||||
{
|
||||
Alias = alias;
|
||||
Path = path;
|
||||
Exists = exists;
|
||||
HasValues = hasValues;
|
||||
Line = line;
|
||||
}
|
||||
|
||||
public string Alias { get; }
|
||||
public string Path { get; }
|
||||
public bool Exists { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the key holds at least one value — for <see cref="PolicyManagerSystemKey"/>
|
||||
/// one with OOBE in its name. Subkeys alone are no values.
|
||||
/// </summary>
|
||||
public bool HasValues { get; }
|
||||
|
||||
/// <summary><see cref="FormatEntries"/> plus the subkey names; null when the key is absent.</summary>
|
||||
public string Line { get; }
|
||||
}
|
||||
|
||||
/// <summary>Reads every key from the 64-bit view. Throws on a registry failure; the caller is fail-soft.</summary>
|
||||
public static IReadOnlyList<KeyState> Read()
|
||||
{
|
||||
var states = new List<KeyState>(Keys.Length);
|
||||
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
|
||||
{
|
||||
foreach (var (alias, path) in Keys)
|
||||
{
|
||||
using (var key = hklm.OpenSubKey(path))
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
states.Add(new KeyState(alias, path, exists: false, hasValues: false, line: null));
|
||||
continue;
|
||||
}
|
||||
|
||||
var entries = key.GetValueNames()
|
||||
.Select(name => new KeyValuePair<string, object>(name, key.GetValue(name)))
|
||||
.ToList();
|
||||
states.Add(Describe(alias, path, entries, key.GetSubKeyNames()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/// <summary>The state of a present key from its values and subkey names.</summary>
|
||||
internal static KeyState Describe(string alias, string path, IReadOnlyList<KeyValuePair<string, object>> entries, string[] subKeys)
|
||||
{
|
||||
var oobeOnly = string.Equals(path, PolicyManagerSystemKey, StringComparison.OrdinalIgnoreCase);
|
||||
var line = FormatEntries(entries, oobeOnly);
|
||||
if (subKeys != null && subKeys.Length > 0)
|
||||
line += $" | subkeys: {string.Join(", ", subKeys.Take(MaxEntries))}";
|
||||
return new KeyState(alias, path, exists: true,
|
||||
hasValues: entries.Any(e => !oobeOnly || IsOobeName(e.Key)), line: line);
|
||||
}
|
||||
|
||||
/// <summary>One agent.log line per present key, one naming the absent keys.</summary>
|
||||
public static void Log(AgentLogger logger, string moment, IReadOnlyList<KeyState> states)
|
||||
{
|
||||
if (logger == null || states == null) return;
|
||||
|
||||
var absent = new List<string>();
|
||||
foreach (var state in states)
|
||||
{
|
||||
if (!state.Exists)
|
||||
{
|
||||
absent.Add(state.Path);
|
||||
continue;
|
||||
}
|
||||
logger.Info($"OOBE update registry ({moment}): HKLM\\{state.Path}: {state.Line}");
|
||||
}
|
||||
|
||||
if (absent.Count > 0)
|
||||
logger.Info($"OOBE update registry ({moment}): absent: {string.Join(", ", absent)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>name=value</c> pairs ordered by name, bounded in count and length.
|
||||
/// <paramref name="oobeOnly"/> keeps only names that mention OOBE.
|
||||
/// </summary>
|
||||
internal static string FormatEntries(IEnumerable<KeyValuePair<string, object>> entries, bool oobeOnly)
|
||||
{
|
||||
var selected = entries
|
||||
.Where(e => !oobeOnly || IsOobeName(e.Key))
|
||||
.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (selected.Count == 0) return oobeOnly ? "(no OOBE values)" : "(no values)";
|
||||
|
||||
var shown = selected.Take(MaxEntries)
|
||||
.Select(e => $"{(e.Key.Length == 0 ? "(default)" : e.Key)}={FormatValue(e.Value)}");
|
||||
var line = string.Join(", ", shown);
|
||||
return selected.Count > MaxEntries ? $"{line}, … (+{selected.Count - MaxEntries} more)" : line;
|
||||
}
|
||||
|
||||
internal static string FormatValue(object value)
|
||||
{
|
||||
string text;
|
||||
switch (value)
|
||||
{
|
||||
case null: text = "(null)"; break;
|
||||
case int i: text = i.ToString(CultureInfo.InvariantCulture); break;
|
||||
case long l: text = l.ToString(CultureInfo.InvariantCulture); break;
|
||||
case string s: text = s; break;
|
||||
case string[] multi: text = string.Join("|", multi); break;
|
||||
case byte[] bytes: text = $"(binary {bytes.Length} bytes)"; break;
|
||||
default: text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; break;
|
||||
}
|
||||
return text.Length > MaxValueLength ? text.Substring(0, MaxValueLength) + "…" : text;
|
||||
}
|
||||
|
||||
private static bool IsOobeName(string name) =>
|
||||
name != null && name.IndexOf("OOBE", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// One CloudExperienceHost record of the OOBE update page, parsed by
|
||||
/// <see cref="ShellCoreTracker.ParseUpdatePageRecord"/>.
|
||||
/// </summary>
|
||||
internal sealed class OobeUpdatePageRecord
|
||||
{
|
||||
public const string PageStarted = "page_started";
|
||||
public const string PageStopped = "page_stopped";
|
||||
public const string EventName = "event_name";
|
||||
|
||||
public OobeUpdatePageRecord(string cxhEvent, int windowsEventId, string page = null, string result = null, string name = null, string value = null)
|
||||
{
|
||||
CxhEvent = cxhEvent;
|
||||
WindowsEventId = windowsEventId;
|
||||
Page = page;
|
||||
Result = result;
|
||||
Name = name;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary><see cref="PageStarted"/>, <see cref="PageStopped"/> or <see cref="EventName"/>.</summary>
|
||||
public string CxhEvent { get; }
|
||||
public int WindowsEventId { get; }
|
||||
|
||||
/// <summary>The page's CXID (start), or the page a stop is attributed to.</summary>
|
||||
public string Page { get; }
|
||||
|
||||
/// <summary>The page result of a stop.</summary>
|
||||
public string Result { get; }
|
||||
|
||||
/// <summary>The event name of a 62406/62407 record.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>A 62407 value of a safe form (boolean, integer, HRESULT); null otherwise.</summary>
|
||||
public string Value { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measurement telemetry of the OOBE quality update (D-310): the update page's
|
||||
/// CloudExperienceHost records as <c>oobe_update_page</c> and the registry state behind it as
|
||||
/// <c>oobe_update_state</c>. Debug, bounded per agent run, no decisions. Owned by
|
||||
/// <see cref="ShellCoreTracker"/>, which observes the page.
|
||||
/// </summary>
|
||||
internal sealed class OobeUpdateTelemetry
|
||||
{
|
||||
internal const int MaxPageEventsPerRun = 150;
|
||||
internal const int MaxPageEventsPerKey = 3;
|
||||
internal const int MaxStateEventsPerRun = 6;
|
||||
internal const int MaxStateLineLength = 1000;
|
||||
|
||||
/// <summary>When a registry snapshot is taken (<c>moment</c> of <c>oobe_update_state</c>).</summary>
|
||||
internal static class Moments
|
||||
{
|
||||
public const string AgentStart = "agent_start";
|
||||
public const string UpdatePageStarted = "update_page_started";
|
||||
public const string UpdatePageStopped = "update_page_stopped";
|
||||
public const string AgentStop = "agent_stop";
|
||||
}
|
||||
|
||||
private const string Source = "ShellCoreTracker";
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> _readRegistry;
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private readonly Dictionary<string, int> _pageEventsPerKey = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
private int _pageEvents;
|
||||
private bool _pageCapLogged;
|
||||
private int _stateEvents;
|
||||
private string _lastStateContent;
|
||||
|
||||
public OobeUpdateTelemetry(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> readRegistry)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_readRegistry = readRegistry ?? throw new ArgumentNullException(nameof(readRegistry));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>oobe_update_page</c> per update-page record, at most
|
||||
/// <see cref="MaxPageEventsPerKey"/> per page/name — a page that logs the same name in a
|
||||
/// loop cannot flood the session. Event names also share <see cref="MaxPageEventsPerRun"/>
|
||||
/// per run; a page start or stop is outside that budget, because the time attribution
|
||||
/// reads the update's span from them. True when the record was reported.
|
||||
/// </summary>
|
||||
public bool ReportPage(OobeUpdatePageRecord record, DateTime occurredAtUtc, bool isBackfill)
|
||||
{
|
||||
if (record == null) return false;
|
||||
|
||||
var isEventName = record.CxhEvent == OobeUpdatePageRecord.EventName;
|
||||
var key = $"{record.CxhEvent}:{record.Page ?? record.Name}";
|
||||
int occurrence;
|
||||
lock (_lock)
|
||||
{
|
||||
if (isEventName && _pageEvents >= MaxPageEventsPerRun)
|
||||
{
|
||||
if (!_pageCapLogged)
|
||||
{
|
||||
_pageCapLogged = true;
|
||||
_logger.Info($"OOBE update page telemetry: {MaxPageEventsPerRun} event names reached, the rest of this run stays in agent.log");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
_pageEventsPerKey.TryGetValue(key, out occurrence);
|
||||
if (occurrence >= MaxPageEventsPerKey) return false;
|
||||
occurrence++;
|
||||
_pageEventsPerKey[key] = occurrence;
|
||||
if (isEventName) _pageEvents++;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "cxhEvent", record.CxhEvent },
|
||||
{ "windowsEventId", record.WindowsEventId },
|
||||
{ "eventTime", occurredAtUtc.ToString("o") },
|
||||
{ "backfill", isBackfill },
|
||||
{ "occurrence", occurrence },
|
||||
};
|
||||
if (record.Page != null) data["page"] = record.Page;
|
||||
if (record.Result != null) data["result"] = record.Result;
|
||||
if (record.Name != null) data["name"] = record.Name;
|
||||
if (record.Value != null) data["value"] = record.Value;
|
||||
|
||||
try
|
||||
{
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = occurredAtUtc,
|
||||
EventType = Constants.EventTypes.OobeUpdatePage,
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = Source,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = DescribePage(record),
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"OOBE update page telemetry failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the OOBE update registry state, logs it and reports it as <c>oobe_update_state</c>
|
||||
/// — only when at least one key holds values and the content changed since the last report
|
||||
/// of this run, at most <see cref="MaxStateEventsPerRun"/> times. Fail-soft.
|
||||
/// </summary>
|
||||
public void ReportState(string moment, string page = null)
|
||||
{
|
||||
var label = page == null ? moment : $"{moment} {page}";
|
||||
IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState> states;
|
||||
try
|
||||
{
|
||||
states = _readRegistry() ?? Array.Empty<OobeUpdateRegistrySnapshot.KeyState>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"OOBE update registry ({label}): snapshot failed: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
OobeUpdateRegistrySnapshot.Log(_logger, label, states);
|
||||
|
||||
var withValues = states.Where(s => s.HasValues).ToList();
|
||||
if (withValues.Count == 0) return;
|
||||
|
||||
var content = string.Join("\n", withValues.Select(s => $"{s.Alias}={s.Line}"));
|
||||
lock (_lock)
|
||||
{
|
||||
if (string.Equals(content, _lastStateContent, StringComparison.Ordinal)) return;
|
||||
if (_stateEvents >= MaxStateEventsPerRun) return;
|
||||
_lastStateContent = content;
|
||||
_stateEvents++;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "moment", moment },
|
||||
{ "keysWithValues", withValues.Count },
|
||||
};
|
||||
if (page != null) data["page"] = page;
|
||||
foreach (var state in withValues)
|
||||
{
|
||||
data[state.Alias] = state.Line.Length > MaxStateLineLength
|
||||
? state.Line.Substring(0, MaxStateLineLength) + "…"
|
||||
: state.Line;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.OobeUpdateState,
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = Source,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"OOBE update registry state ({label}): {withValues.Count} key(s) with values",
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"OOBE update state telemetry failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribePage(OobeUpdatePageRecord record)
|
||||
{
|
||||
switch (record.CxhEvent)
|
||||
{
|
||||
case OobeUpdatePageRecord.PageStarted:
|
||||
return $"OOBE update page started: {record.Page}";
|
||||
case OobeUpdatePageRecord.PageStopped:
|
||||
return $"OOBE update page stopped: {record.Page} (result: {record.Result})";
|
||||
default:
|
||||
return record.Value == null
|
||||
? $"OOBE update page event: {record.Name}"
|
||||
: $"OOBE update page event: {record.Name} = {record.Value}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches the CBS servicing steps of OS updates in the <c>Setup</c> log
|
||||
/// (<c>Microsoft-Windows-Servicing</c>, D-310): 1 initiating changes (current → target state),
|
||||
/// 2 target state reached, 3 failed, 4 a restart is needed to finish, 6 failed and partially
|
||||
/// installed. The Windows Update client logs only the start of an installation; these steps
|
||||
/// show the long part of it — staging, installing, "reboot required" right before the restart.
|
||||
/// <para>
|
||||
/// Only packages of the OS update clients (<c>UpdateAgentLCU</c> for the cumulative update,
|
||||
/// <c>WindowsUpdateAgent</c>) are emitted as <c>windows_update_servicing</c> and reported to
|
||||
/// the decision engine; language packs, features on demand and DISM operations started by
|
||||
/// apps are counted into the host's <see cref="UpdateActivityTally"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class ServicingTracker : IDisposable
|
||||
{
|
||||
internal const string Channel = "Setup";
|
||||
internal const string ProviderName = "Microsoft-Windows-Servicing";
|
||||
|
||||
internal const int EventId_InitiatingChanges = 1;
|
||||
internal const int EventId_StateReached = 2;
|
||||
internal const int EventId_ChangeFailed = 3;
|
||||
internal const int EventId_RebootRequired = 4;
|
||||
internal const int EventId_PartiallyInstalled = 6;
|
||||
|
||||
internal static readonly int[] TargetedEventIds =
|
||||
{
|
||||
EventId_InitiatingChanges, EventId_StateReached, EventId_ChangeFailed, EventId_RebootRequired, EventId_PartiallyInstalled,
|
||||
};
|
||||
|
||||
internal const string WatermarkStateFileName = "servicing-watermark.json";
|
||||
|
||||
// CBS client ids of the Windows Update installers (seen on 24H2/25H2: UpdateAgentLCU).
|
||||
private static readonly HashSet<string> OsUpdateClients = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"UpdateAgentLCU",
|
||||
"WindowsUpdateAgent",
|
||||
};
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly bool _backfillEnabled;
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly UpdateActivityTally _tally;
|
||||
private readonly Action<string, string, DateTime> _onOsUpdateActivity;
|
||||
private readonly EventRecordWatermark _watermark;
|
||||
private EventLogWatcher _watcher;
|
||||
|
||||
public ServicingTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
bool backfillEnabled = true,
|
||||
int backfillLookbackMinutes = 60,
|
||||
string stateDirectory = null,
|
||||
UpdateActivityTally tally = null,
|
||||
Action<string, string, DateTime> onOsUpdateActivity = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_tally = tally;
|
||||
_onOsUpdateActivity = onOsUpdateActivity;
|
||||
_watermark = new EventRecordWatermark(stateDirectory, WatermarkStateFileName, logger, "Servicing");
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_watermark.Load();
|
||||
StartWatcher();
|
||||
|
||||
if (_backfillEnabled && _backfillLookbackMinutes > 0)
|
||||
BackfillRecentEvents();
|
||||
else
|
||||
_logger.Info("Servicing backfill disabled by config");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_watcher == null) return;
|
||||
try
|
||||
{
|
||||
_watcher.Enabled = false;
|
||||
_watcher.Dispose();
|
||||
_logger.Info("Servicing watcher stopped");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error stopping Servicing watcher", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
internal void LoadWatermark() => _watermark.Load();
|
||||
|
||||
private static HashSet<int> TargetedSet => new HashSet<int>(TargetedEventIds);
|
||||
|
||||
private void StartWatcher()
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(Channel, PathType.LogName,
|
||||
WindowsUpdateTracker.BuildXPath(TargetedSet, ProviderName));
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
_watcher.Enabled = true;
|
||||
_logger.Info($"Servicing watcher started: {Channel} ({ProviderName}, targetedIds={string.Join(",", TargetedEventIds)})");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"Servicing event log not found: {Channel}");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"Servicing watcher access denied for {Channel}: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to start Servicing watcher for {Channel}", ex);
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
|
||||
collectorName: "ServicingTracker", reason: $"watcher_arm_failed:{Channel}", ex: ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
|
||||
{
|
||||
if (e.EventRecord == null) return;
|
||||
try
|
||||
{
|
||||
ProcessRecord(e.EventRecord, isBackfill: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error processing Servicing event", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessRecord(EventRecord record, bool isBackfill)
|
||||
{
|
||||
var recordId = record.RecordId ?? -1;
|
||||
if (_watermark.IsAlreadyProcessed(recordId)) return;
|
||||
|
||||
string xml = null;
|
||||
try { xml = record.ToXml(); }
|
||||
catch { }
|
||||
|
||||
var fields = ParseUserData(xml);
|
||||
fields.TryGetValue("PackageIdentifier", out var package);
|
||||
fields.TryGetValue("InitialPackageStateTextized", out var initialState);
|
||||
fields.TryGetValue("IntendedPackageStateTextized", out var targetState);
|
||||
fields.TryGetValue("ErrorCode", out var errorCode);
|
||||
fields.TryGetValue("Client", out var client);
|
||||
|
||||
ProcessEvent(
|
||||
eventId: record.Id,
|
||||
recordId: recordId,
|
||||
timeCreatedUtc: record.TimeCreated?.ToUniversalTime(),
|
||||
package: package,
|
||||
initialState: initialState,
|
||||
targetState: targetState,
|
||||
errorCode: errorCode,
|
||||
client: client,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
/// <summary>Test seam — drives the processing without a Windows-only <see cref="EventRecord"/>.</summary>
|
||||
internal void ProcessEvent(
|
||||
int eventId,
|
||||
long recordId,
|
||||
DateTime? timeCreatedUtc,
|
||||
string package,
|
||||
string initialState,
|
||||
string targetState,
|
||||
string errorCode,
|
||||
string client,
|
||||
bool isBackfill)
|
||||
{
|
||||
if (!_watermark.TryClaim(recordId)) return;
|
||||
|
||||
if (string.IsNullOrEmpty(client) || !OsUpdateClients.Contains(client))
|
||||
{
|
||||
_tally?.Increment($"servicing_other_{eventId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var pkg = string.IsNullOrEmpty(package) ? "(unknown package)" : package;
|
||||
var target = string.IsNullOrEmpty(targetState) ? "?" : targetState;
|
||||
|
||||
string step;
|
||||
string osStep;
|
||||
string message;
|
||||
var severity = EventSeverity.Info;
|
||||
string hresultHex = null;
|
||||
string hresultSymbol = null;
|
||||
if (WindowsUpdateTracker.TryNormalizeHResult(errorCode, out var hresultValue, out var hex) && hresultValue != 0)
|
||||
{
|
||||
hresultHex = hex;
|
||||
hresultSymbol = WindowsUpdateTracker.ResolveHResultSymbol(hresultValue);
|
||||
}
|
||||
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InitiatingChanges:
|
||||
step = "initiating";
|
||||
osStep = IsState(targetState, "Installed") ? OsUpdateSteps.InstallStarted
|
||||
: IsState(targetState, "Staged") ? OsUpdateSteps.StagingStarted
|
||||
: null;
|
||||
message = $"Servicing started for {pkg}: {(string.IsNullOrEmpty(initialState) ? "?" : initialState)} → {target}";
|
||||
break;
|
||||
case EventId_StateReached:
|
||||
step = "state_reached";
|
||||
osStep = IsState(targetState, "Installed") ? OsUpdateSteps.Installed
|
||||
: IsState(targetState, "Staged") ? OsUpdateSteps.Staged
|
||||
: null;
|
||||
message = $"Servicing finished for {pkg}: now {target}";
|
||||
break;
|
||||
case EventId_RebootRequired:
|
||||
step = "reboot_required";
|
||||
osStep = OsUpdateSteps.RebootRequired;
|
||||
message = $"Servicing needs a restart to finish {pkg} → {target}";
|
||||
break;
|
||||
case EventId_ChangeFailed:
|
||||
case EventId_PartiallyInstalled:
|
||||
step = "failed";
|
||||
osStep = OsUpdateSteps.Failed;
|
||||
severity = EventSeverity.Warning;
|
||||
message = $"Servicing FAILED for {pkg} → {target}" +
|
||||
(hresultHex != null ? $" ({hresultHex} {hresultSymbol})" : string.Empty) +
|
||||
(eventId == EventId_PartiallyInstalled ? ", package is partially installed" : string.Empty);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "servicingEventId", eventId },
|
||||
{ "step", step },
|
||||
{ "package", pkg },
|
||||
{ "client", client },
|
||||
{ "backfilled", isBackfill },
|
||||
};
|
||||
if (!string.IsNullOrEmpty(initialState)) data["initialState"] = initialState;
|
||||
if (!string.IsNullOrEmpty(targetState)) data["targetState"] = targetState;
|
||||
if (hresultHex != null)
|
||||
{
|
||||
data["hresult"] = hresultHex;
|
||||
data["hresultSymbol"] = hresultSymbol;
|
||||
}
|
||||
if (recordId >= 0) data["recordId"] = recordId;
|
||||
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
EventType = Constants.EventTypes.WindowsUpdateServicing,
|
||||
Severity = severity,
|
||||
Source = Constants.EventSources.ServicingWatcher,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
|
||||
if (osStep != null && _onOsUpdateActivity != null)
|
||||
{
|
||||
try { _onOsUpdateActivity(osStep, pkg, timeCreatedUtc ?? DateTime.UtcNow); }
|
||||
catch (Exception ex) { _logger.Warning($"Servicing: OS update activity callback failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Last: everything this record produced has been handed to the ingress first.
|
||||
_watermark.MarkEmitted(recordId);
|
||||
}
|
||||
|
||||
private static bool IsState(string state, string expected) =>
|
||||
string.Equals(state, expected, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Child elements of the first element under <c>UserData</c>
|
||||
/// (<c>CbsPackageInitiateChanges</c> / <c>CbsPackageChangeState</c>), keyed by local name,
|
||||
/// case-insensitively. Empty on null/malformed XML — never throws.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> ParseUserData(string xml)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrEmpty(xml)) return result;
|
||||
|
||||
try
|
||||
{
|
||||
var doc = XDocument.Parse(xml);
|
||||
var userData = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "UserData");
|
||||
var payload = userData?.Elements().FirstOrDefault();
|
||||
if (payload == null) return result;
|
||||
|
||||
foreach (var el in payload.Elements())
|
||||
{
|
||||
if (!result.ContainsKey(el.Name.LocalName))
|
||||
result[el.Name.LocalName] = el.Value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed / unexpected XML — best effort.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void BackfillRecentEvents()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lookbackMs = (long)_backfillLookbackMinutes * 60 * 1000;
|
||||
var xpath = WindowsUpdateTracker.BuildXPath(TargetedSet, ProviderName, lookbackMs);
|
||||
_logger.Info($"Servicing backfill: scanning {Channel} (lookback={_backfillLookbackMinutes}min, restartWatermark={_watermark.RestartWatermark})");
|
||||
|
||||
int processed = 0;
|
||||
using (var reader = new EventLogReader(new EventLogQuery(Channel, PathType.LogName, xpath)))
|
||||
{
|
||||
EventRecord record;
|
||||
while ((record = reader.ReadEvent()) != null)
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
ProcessRecord(record, isBackfill: true);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processed > 0)
|
||||
_logger.Info($"Servicing backfill: scanned {processed} event(s)");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"Servicing event log not found during backfill: {Channel}");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"Servicing backfill access denied: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Servicing backfill failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+359
-11
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -31,9 +33,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
internal const string ShellCoreEventLogChannel = "Microsoft-Windows-Shell-Core/Operational";
|
||||
internal const int EventId_ShellCore_WebAppStarted = 62404;
|
||||
internal const int EventId_ShellCore_WebAppStopped = 62405;
|
||||
internal const int EventId_ShellCore_WebAppEventName = 62406;
|
||||
internal const int EventId_ShellCore_WebAppEvent = 62407;
|
||||
internal const int BackfillLookbackMinutes = 5;
|
||||
|
||||
// 62405 (page stopped) and 62406 (event with a name only) feed the CloudExperienceHost
|
||||
// navigation breadcrumbs in agent.log and the OOBE update page telemetry (D-310), nothing else.
|
||||
private const string WatchedEventIdsXPath =
|
||||
"(EventID=62404 or EventID=62405 or EventID=62406 or EventID=62407)";
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound for the caller-supplied backfill lookback. Matches the agent's max
|
||||
/// lifetime (360 min) — a window wider than the agent can ever live would only widen
|
||||
@@ -45,22 +54,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private static readonly HashSet<int> TrackedShellCoreEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_ShellCore_WebAppStarted,
|
||||
EventId_ShellCore_WebAppStopped,
|
||||
EventId_ShellCore_WebAppEventName,
|
||||
EventId_ShellCore_WebAppEvent
|
||||
};
|
||||
|
||||
private static readonly Regex EspExitingPattern = new Regex(
|
||||
@"OOBE_ESP.*Exiting", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// Manifest templates: 62404 "... started. CXID: '%1'.", 62405 "... stopped. Result: '%1'.",
|
||||
// 62406 "... Event 1. Name: '%1'.", 62407 "... Event 2. Name: '%1', Value: '%2'.". The
|
||||
// fields are read from the event PROPERTIES (%1/%2): the formatted message is localized
|
||||
// ("Ergebnis: '…'", "Nom : « … »"), so these patterns are only the fallback for a record
|
||||
// without properties and accept « » and a space before the colon. A 62407 value can carry
|
||||
// URLs with tenant ids — a value leaves the tracker only in a form that cannot
|
||||
// (SafeValuePattern: a boolean, an integer, an HRESULT).
|
||||
private static readonly Regex CxidPattern = new Regex(
|
||||
@"CXID\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex ResultPattern = new Regex(
|
||||
@"Result\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex NamePattern = new Regex(
|
||||
@"Name\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex ValuePattern = new Regex(
|
||||
@"Value\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex SafeValuePattern = new Regex(
|
||||
@"^(?:true|false|-?[0-9]{1,10}|0x[0-9a-f]{1,8})$",
|
||||
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private const int MaxBreadcrumbTokenLength = 128;
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly HelloTracker _helloTracker;
|
||||
private readonly OobeUpdateTelemetry _oobeUpdate;
|
||||
// Shell-Core RecordIds of the update-page records already reported: the restart backfill
|
||||
// must not report a record an earlier run already sent (D-310 field data: the same 20
|
||||
// records came twice after a restart).
|
||||
private readonly EventRecordWatermark _pageWatermark;
|
||||
internal const string PageWatermarkStateFileName = "shellcore-page-watermark.json";
|
||||
|
||||
private EventLogWatcher _watcher;
|
||||
private int _started;
|
||||
private int _stopReported;
|
||||
private bool _espExitDetected;
|
||||
private bool _whiteGloveDetected;
|
||||
private bool _helloWizardStartDetected;
|
||||
private string _lastCxhPage; // 62405 names no page, it ends the last one started
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
/// <summary>
|
||||
@@ -99,26 +139,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
HelloTracker helloTracker)
|
||||
HelloTracker helloTracker,
|
||||
Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> oobeUpdateRegistryReader = null,
|
||||
string stateDirectory = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_helloTracker = helloTracker; // nullable — HelloTracker may be unavailable in some test setups
|
||||
// Test seam: a fake registry reader; production reads the real registry.
|
||||
_oobeUpdate = new OobeUpdateTelemetry(sessionId, tenantId, post, logger,
|
||||
oobeUpdateRegistryReader ?? OobeUpdateRegistrySnapshot.Read);
|
||||
_pageWatermark = new EventRecordWatermark(stateDirectory, PageWatermarkStateFileName, logger, "Shell-Core update page");
|
||||
}
|
||||
|
||||
internal bool IsEspExitedForTest { get { lock (_stateLock) { return _espExitDetected; } } }
|
||||
internal bool IsWhiteGloveDetectedForTest { get { lock (_stateLock) { return _whiteGloveDetected; } } }
|
||||
|
||||
/// <summary>Loads the update-page watermark as <see cref="Start"/> does, without arming the watcher.</summary>
|
||||
internal void LoadPageWatermark() => _pageWatermark.Load();
|
||||
|
||||
public void Start()
|
||||
{
|
||||
// The OOBE update registry state at every agent start — so also right after the
|
||||
// update's restart (D-310).
|
||||
if (Interlocked.Exchange(ref _started, 1) == 0)
|
||||
{
|
||||
_oobeUpdate.ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
_pageWatermark.Load();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(
|
||||
ShellCoreEventLogChannel,
|
||||
PathType.LogName,
|
||||
"*[System[(EventID=62404 or EventID=62407)]]");
|
||||
$"*[System[{WatchedEventIdsXPath}]]");
|
||||
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
@@ -143,6 +200,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
// Stop runs again from Dispose — one snapshot, and only for a tracker that started.
|
||||
if (Volatile.Read(ref _started) == 1 && Interlocked.Exchange(ref _stopReported, 1) == 0)
|
||||
{
|
||||
_oobeUpdate.ReportState(OobeUpdateTelemetry.Moments.AgentStop);
|
||||
}
|
||||
|
||||
if (_watcher == null) return;
|
||||
try
|
||||
{
|
||||
@@ -172,7 +235,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var description = record.FormatDescription() ?? $"Event ID {record.Id}";
|
||||
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
|
||||
|
||||
ProcessEvent(record.Id, description, timestamp, record.ProviderName ?? "", isBackfill: false);
|
||||
ProcessEvent(record.Id, description, timestamp, record.ProviderName ?? "", isBackfill: false,
|
||||
recordId: record.RecordId ?? -1, properties: ReadProperties(record));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -184,8 +248,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// Core event-processing logic. Exposed as internal so tests can drive it without
|
||||
/// needing to synthesize an <see cref="EventRecord"/> (abstract + Windows-only).
|
||||
/// </summary>
|
||||
internal void ProcessEvent(int eventId, string description, DateTime timestamp, string providerName, bool isBackfill)
|
||||
internal void ProcessEvent(
|
||||
int eventId,
|
||||
string description,
|
||||
DateTime timestamp,
|
||||
string providerName,
|
||||
bool isBackfill,
|
||||
long recordId = -1,
|
||||
IReadOnlyList<string> properties = null)
|
||||
{
|
||||
var fields = ReadCxhFields(eventId, description, properties);
|
||||
ObserveCxhRecord(eventId, fields, timestamp, isBackfill, recordId);
|
||||
if (eventId == EventId_ShellCore_WebAppStopped || eventId == EventId_ShellCore_WebAppEventName) return;
|
||||
|
||||
string eventType;
|
||||
EventSeverity severity = EventSeverity.Info;
|
||||
string message;
|
||||
@@ -197,7 +272,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_ShellCore_WebAppStarted: // 62404
|
||||
if (description.Contains("AADHello") || description.Contains("'NGC'"))
|
||||
if (IsHelloWizardStart(fields.Primary, description))
|
||||
{
|
||||
eventType = Constants.EventTypes.HelloWizardStarted;
|
||||
message = "Windows Hello wizard started (CloudExperienceHost)";
|
||||
@@ -388,9 +463,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var query = new EventLogQuery(
|
||||
ShellCoreEventLogChannel,
|
||||
PathType.LogName,
|
||||
$"*[System[(EventID=62404 or EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
$"*[System[{WatchedEventIdsXPath} and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
|
||||
var records = new List<(int Id, string Description, DateTime OccurredAtUtc)>();
|
||||
var records = new List<ShellCoreRecord>();
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
|
||||
@@ -402,7 +477,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// (EspAndHelloTrackerAdapter) can stamp signals with the source time
|
||||
// rather than collapsing to wall-clock-now.
|
||||
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
|
||||
records.Add((record.Id, description, timestamp));
|
||||
records.Add(new ShellCoreRecord(record.Id, description, timestamp,
|
||||
record.RecordId ?? -1, ReadProperties(record)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -455,6 +531,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
internal void ReplayBackfillRecords(
|
||||
IReadOnlyList<(int Id, string Description, DateTime OccurredAtUtc)> records)
|
||||
{
|
||||
if (records == null) return;
|
||||
var converted = new List<ShellCoreRecord>(records.Count);
|
||||
foreach (var r in records) converted.Add(new ShellCoreRecord(r.Id, r.Description, r.OccurredAtUtc));
|
||||
ReplayBackfillRecords(converted);
|
||||
}
|
||||
|
||||
internal void ReplayBackfillRecords(IReadOnlyList<ShellCoreRecord> records)
|
||||
{
|
||||
if (records == null || records.Count == 0) return;
|
||||
|
||||
@@ -466,13 +550,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
foreach (var record in records)
|
||||
{
|
||||
var description = record.Description ?? string.Empty;
|
||||
var fields = ReadCxhFields(record.Id, description, record.Properties);
|
||||
ObserveCxhRecord(record.Id, fields, record.OccurredAtUtc, isBackfill: true, recordId: record.RecordId);
|
||||
|
||||
if (record.Id == EventId_ShellCore_WebAppStarted)
|
||||
{
|
||||
HandleBackfillRecord(record.Id, description, record.OccurredAtUtc);
|
||||
HandleBackfillRecord(record.Id, description, record.OccurredAtUtc, fields.Primary);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 62405/62406 only feed the breadcrumbs and the update page telemetry above.
|
||||
if (record.Id != EventId_ShellCore_WebAppEvent) continue;
|
||||
|
||||
var isFailure = HasEspFailurePattern(description);
|
||||
var isExit = !isFailure && EspExitingPattern.IsMatch(description);
|
||||
if (!isFailure && !isExit) continue;
|
||||
@@ -535,7 +624,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// (<c>record.TimeCreated</c>); subscribers read it via
|
||||
/// <see cref="LastEventOccurredAtUtc"/> during their synchronous event handler.
|
||||
/// </summary>
|
||||
internal void HandleBackfillRecord(int eventId, string description, DateTime occurredAtUtc)
|
||||
internal void HandleBackfillRecord(int eventId, string description, DateTime occurredAtUtc, string cxid = null)
|
||||
{
|
||||
if (eventId == EventId_ShellCore_WebAppStarted)
|
||||
{
|
||||
@@ -543,7 +632,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// are unrelated. Fire-once so a replayed log tail cannot re-raise (downstream is
|
||||
// idempotent anyway: HelloTracker once-guard, adapter dedup flag, engine
|
||||
// set-once fact — this guard just keeps the noise down).
|
||||
if (!description.Contains("AADHello") && !description.Contains("'NGC'")) return;
|
||||
if (!IsHelloWizardStart(cxid ?? ReadCxhFields(eventId, description, null).Primary, description)) return;
|
||||
|
||||
bool shouldRaiseWizard;
|
||||
lock (_stateLock)
|
||||
@@ -569,6 +658,242 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// CloudExperienceHost navigation: breadcrumbs and OOBE update page telemetry (D-310)
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Writes one agent.log line per CloudExperienceHost page start/stop and per update-page
|
||||
/// event name, so a diagnostics package shows the OOBE navigation. Records of the update
|
||||
/// page itself (<c>OobeNDUP</c>, <c>RebootNDUP</c> and its <c>ExpeditedUpdate_*</c> names)
|
||||
/// are also reported as <c>oobe_update_page</c> — once across agent runs, by RecordId — and
|
||||
/// at the page's live start and stop the registry state behind it as
|
||||
/// <c>oobe_update_state</c>. No decision.
|
||||
/// </summary>
|
||||
private void ObserveCxhRecord(int eventId, CxhFields fields, DateTime occurredAtUtc, bool isBackfill, long recordId)
|
||||
{
|
||||
try
|
||||
{
|
||||
string page;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (eventId == EventId_ShellCore_WebAppStarted && fields.Primary != null)
|
||||
_lastCxhPage = fields.Primary;
|
||||
page = _lastCxhPage;
|
||||
}
|
||||
|
||||
var crumb = FormatCxhBreadcrumb(eventId, fields, page);
|
||||
if (crumb == null) return;
|
||||
_logger.Info(isBackfill ? $"{crumb} (backfill, at {occurredAtUtc:o})" : crumb);
|
||||
|
||||
var record = ParseUpdatePageRecord(eventId, fields, page);
|
||||
if (record == null) return;
|
||||
// A record an earlier run (or this one) already reported — the restart backfill
|
||||
// re-reads the minutes before the restart.
|
||||
if (!_pageWatermark.TryClaim(recordId)) return;
|
||||
|
||||
// Only a reported record moves the state file — at most the run budget plus the
|
||||
// page boundaries; one over the budget stays claimed for this run only.
|
||||
if (_oobeUpdate.ReportPage(record, occurredAtUtc, isBackfill))
|
||||
_pageWatermark.MarkEmitted(recordId);
|
||||
|
||||
// The registry is read now — only a live page boundary is the page's moment.
|
||||
if (!isBackfill && record.CxhEvent != OobeUpdatePageRecord.EventName)
|
||||
{
|
||||
_oobeUpdate.ReportState(
|
||||
record.CxhEvent == OobeUpdatePageRecord.PageStarted
|
||||
? OobeUpdateTelemetry.Moments.UpdatePageStarted
|
||||
: OobeUpdateTelemetry.Moments.UpdatePageStopped,
|
||||
record.Page);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ShellCoreTracker: CXH record observation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The own fields of a CloudExperienceHost record: CXID, result or name (%1) and the 62407 value (%2).</summary>
|
||||
internal readonly struct CxhFields
|
||||
{
|
||||
public CxhFields(string primary, string value)
|
||||
{
|
||||
Primary = primary;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary>62404: CXID; 62405: result; 62406/62407: event name. Null when not readable.</summary>
|
||||
public string Primary { get; }
|
||||
|
||||
/// <summary>62407: the raw value (never logged or sent unless it is of a safe form).</summary>
|
||||
public string Value { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The record's fields from its event properties, which carry them verbatim in every
|
||||
/// display language. Without properties (a test, a record that failed to render them) the
|
||||
/// formatted message is parsed — English punctuation and « » only, so a localized label
|
||||
/// ("Ergebnis", "Nom") yields null there.
|
||||
/// </summary>
|
||||
internal static CxhFields ReadCxhFields(int eventId, string description, IReadOnlyList<string> properties)
|
||||
{
|
||||
if (properties != null && properties.Count > 0)
|
||||
{
|
||||
return new CxhFields(
|
||||
Bound(properties[0]),
|
||||
eventId == EventId_ShellCore_WebAppEvent && properties.Count > 1 ? Bound(properties[1]) : null);
|
||||
}
|
||||
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_ShellCore_WebAppStarted:
|
||||
return new CxhFields(ExtractToken(CxidPattern, description), null);
|
||||
case EventId_ShellCore_WebAppStopped:
|
||||
return new CxhFields(ExtractToken(ResultPattern, description), null);
|
||||
case EventId_ShellCore_WebAppEventName:
|
||||
return new CxhFields(ExtractToken(NamePattern, description), null);
|
||||
case EventId_ShellCore_WebAppEvent:
|
||||
return new CxhFields(ExtractToken(NamePattern, description), ExtractToken(ValuePattern, description));
|
||||
default:
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The breadcrumb for one Shell-Core record, or null when it is not one: a page start
|
||||
/// (62404, its CXID), a page stop (62405, its result, attributed to the last started
|
||||
/// page), or an update-page event name (62406/62407 — a 62407 value only in a safe form).
|
||||
/// </summary>
|
||||
internal static string FormatCxhBreadcrumb(int eventId, string description, string lastPage) =>
|
||||
FormatCxhBreadcrumb(eventId, ReadCxhFields(eventId, description, null), lastPage);
|
||||
|
||||
internal static string FormatCxhBreadcrumb(int eventId, CxhFields fields, string lastPage)
|
||||
{
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_ShellCore_WebAppStarted:
|
||||
return fields.Primary == null ? null : $"CXH page started: {fields.Primary}";
|
||||
case EventId_ShellCore_WebAppStopped:
|
||||
return $"CXH page stopped: {lastPage ?? "?"} (result={fields.Primary ?? "?"})";
|
||||
case EventId_ShellCore_WebAppEventName:
|
||||
case EventId_ShellCore_WebAppEvent:
|
||||
{
|
||||
if (!IsUpdatePageToken(fields.Primary)) return null;
|
||||
var value = eventId == EventId_ShellCore_WebAppEvent ? SafeValue(fields.Value) : null;
|
||||
return value == null ? $"CXH event: {fields.Primary}" : $"CXH event: {fields.Primary} (value={value})";
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The record of the OOBE update page, or null for any other CloudExperienceHost record:
|
||||
/// the start of a page whose CXID names NDUP, the stop of such a page (62405, attributed
|
||||
/// to the last started page), or an update-page event name from 62406/62407.
|
||||
/// </summary>
|
||||
internal static OobeUpdatePageRecord ParseUpdatePageRecord(int eventId, string description, string lastPage) =>
|
||||
ParseUpdatePageRecord(eventId, ReadCxhFields(eventId, description, null), lastPage);
|
||||
|
||||
internal static OobeUpdatePageRecord ParseUpdatePageRecord(int eventId, CxhFields fields, string lastPage)
|
||||
{
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_ShellCore_WebAppStarted:
|
||||
return IsUpdatePageToken(fields.Primary)
|
||||
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, eventId, page: fields.Primary)
|
||||
: null;
|
||||
case EventId_ShellCore_WebAppStopped:
|
||||
return IsUpdatePageToken(lastPage)
|
||||
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, eventId,
|
||||
page: lastPage, result: fields.Primary ?? "?")
|
||||
: null;
|
||||
case EventId_ShellCore_WebAppEventName:
|
||||
case EventId_ShellCore_WebAppEvent:
|
||||
{
|
||||
if (!IsUpdatePageToken(fields.Primary)) return null;
|
||||
var value = eventId == EventId_ShellCore_WebAppEvent ? SafeValue(fields.Value) : null;
|
||||
return new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, eventId, name: fields.Primary, value: value);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsUpdatePageToken(string token) =>
|
||||
token != null
|
||||
&& (token.IndexOf("NDUP", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| token.IndexOf("ExpeditedUpdate", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
|
||||
/// <summary>
|
||||
/// The Hello wizard's CloudExperienceHost start: CXID <c>AADHello</c> or <c>NGC</c>. With
|
||||
/// no readable CXID the formatted message decides, as before (an English or German
|
||||
/// message quotes the CXID as <c>'NGC'</c>).
|
||||
/// </summary>
|
||||
internal static bool IsHelloWizardStart(string cxid, string description) =>
|
||||
cxid != null
|
||||
? cxid.Contains("AADHello") || string.Equals(cxid, "NGC", StringComparison.Ordinal)
|
||||
: description != null && (description.Contains("AADHello") || description.Contains("'NGC'"));
|
||||
|
||||
/// <summary>
|
||||
/// The 62407 value when it is a boolean, an integer of up to ten digits or an HRESULT —
|
||||
/// forms that cannot carry a URL or a tenant id (the CSP gate, error codes). Null for
|
||||
/// anything else.
|
||||
/// </summary>
|
||||
internal static string ExtractSafeValue(string description) =>
|
||||
SafeValue(ReadCxhFields(EventId_ShellCore_WebAppEvent, description, null).Value);
|
||||
|
||||
internal static string SafeValue(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return null;
|
||||
try
|
||||
{
|
||||
return SafeValuePattern.IsMatch(value) ? value : null;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractToken(Regex pattern, string description)
|
||||
{
|
||||
if (string.IsNullOrEmpty(description)) return null;
|
||||
try
|
||||
{
|
||||
var match = pattern.Match(description);
|
||||
return match.Success ? Bound(match.Groups[1].Value) : null;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Bound(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
return token.Length > MaxBreadcrumbTokenLength ? token.Substring(0, MaxBreadcrumbTokenLength) + "…" : token;
|
||||
}
|
||||
|
||||
/// <summary>The event's property values as strings (the manifest's %1, %2, …); null when unreadable.</summary>
|
||||
private static IReadOnlyList<string> ReadProperties(EventRecord record)
|
||||
{
|
||||
try
|
||||
{
|
||||
var properties = record.Properties;
|
||||
if (properties == null || properties.Count == 0) return null;
|
||||
var values = new string[properties.Count];
|
||||
for (var i = 0; i < properties.Count; i++)
|
||||
values[i] = Convert.ToString(properties[i]?.Value, CultureInfo.InvariantCulture);
|
||||
return values;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Pattern helpers
|
||||
// =====================================================================
|
||||
@@ -608,4 +933,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return "Unknown_ESP_Failure";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One Shell-Core record read by the restart backfill.</summary>
|
||||
internal readonly struct ShellCoreRecord
|
||||
{
|
||||
public ShellCoreRecord(int id, string description, DateTime occurredAtUtc, long recordId = -1, IReadOnlyList<string> properties = null)
|
||||
{
|
||||
Id = id;
|
||||
Description = description;
|
||||
OccurredAtUtc = occurredAtUtc;
|
||||
RecordId = recordId;
|
||||
Properties = properties;
|
||||
}
|
||||
|
||||
public int Id { get; }
|
||||
public string Description { get; }
|
||||
public DateTime OccurredAtUtc { get; }
|
||||
|
||||
/// <summary>The channel's RecordId; -1 when unknown (no cross-run dedup).</summary>
|
||||
public long RecordId { get; }
|
||||
|
||||
/// <summary>The event's property values (%1, %2, …); null when unknown.</summary>
|
||||
public IReadOnlyList<string> Properties { get; }
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -340,7 +340,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
EventType = Constants.EventTypes.SystemClockChanged,
|
||||
Severity = isLargeStep ? EventSeverity.Warning : EventSeverity.Info,
|
||||
Source = "SystemTimelineWatcher",
|
||||
Source = Constants.EventSources.SystemTimelineWatcher,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data,
|
||||
@@ -467,7 +467,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
EventType = Constants.EventTypes.SystemSleepEpisode,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "SystemTimelineWatcher",
|
||||
Source = Constants.EventSources.SystemTimelineWatcher,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data,
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Thread-safe counters for update activity that is counted instead of emitted (D-310): Store,
|
||||
/// Defender and other non-OS update events, repeated scan results, servicing steps of non-OS
|
||||
/// packages. Shared by the update watchers of one host, which reports the totals once as
|
||||
/// <c>windows_update_activity_summary</c> when it stops.
|
||||
/// </summary>
|
||||
internal sealed class UpdateActivityTally
|
||||
{
|
||||
private readonly object _lock = new object();
|
||||
private readonly Dictionary<string, int> _counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
public void Increment(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key)) return;
|
||||
lock (_lock)
|
||||
{
|
||||
_counts.TryGetValue(key, out var current);
|
||||
_counts[key] = current + 1;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEmpty
|
||||
{
|
||||
get { lock (_lock) return _counts.Count == 0; }
|
||||
}
|
||||
|
||||
/// <summary>Counts ordered by key, for a stable event payload.</summary>
|
||||
public IReadOnlyList<KeyValuePair<string, int>> Snapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _counts.OrderBy(kv => kv.Key, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Sorts a Windows Update client event into the update class that decides how it is reported
|
||||
/// (D-310). Only <see cref="Os"/> updates — Windows quality/cumulative/servicing-stack/feature
|
||||
/// updates, .NET Framework and the Malicious Software Removal Tool — are enrollment-relevant and
|
||||
/// emitted one by one. Defender intelligence and Store app updates arrive every few minutes on a
|
||||
/// fresh device (up to 171 downloads per hour in the field census) and are only counted.
|
||||
/// </summary>
|
||||
internal static class WindowsUpdateClassifier
|
||||
{
|
||||
public const string Os = "os";
|
||||
public const string Defender = "defender";
|
||||
public const string Store = "store";
|
||||
public const string Other = "other";
|
||||
|
||||
/// <summary>The Microsoft Store service; WU events carry it as <c>serviceGuid</c>.</summary>
|
||||
internal const string StoreServiceGuid = "855e8a7c-ecb4-4ca3-b045-1dfa50104289";
|
||||
|
||||
// Store packages are titled "<12-char product id>-<Publisher.App>", e.g. "9NBLGGH4NNS1-Microsoft.DesktopAppInstaller";
|
||||
// bundles add an "ApplicationSet-" prefix.
|
||||
private static readonly Regex StoreProductTitle =
|
||||
new Regex(@"^(ApplicationSet-)?[0-9A-Z]{12}-\S", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex DefenderTitle =
|
||||
new Regex(@"Microsoft Defender|Windows Defender", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
// "Windows" plus "Update"/"Upgrade" covers cumulative, quality, security, servicing-stack,
|
||||
// dynamic and feature updates in both the GA and the Insider title forms.
|
||||
private static readonly Regex WindowsUpdateTitle =
|
||||
new Regex(@"\bWindows\b.*\b(Update|Upgrade)\b|\b(Update|Upgrade)\b.*\bWindows\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex OsTitleExtras =
|
||||
new Regex(@"\.NET Framework|Servicing Stack|Malicious Software Removal Tool",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
public static string Classify(string updateTitle, string serviceGuid)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(serviceGuid)
|
||||
&& serviceGuid.Trim('{', '}', ' ').Equals(StoreServiceGuid, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Store;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updateTitle)) return Other;
|
||||
|
||||
try
|
||||
{
|
||||
if (StoreProductTitle.IsMatch(updateTitle)) return Store;
|
||||
if (DefenderTitle.IsMatch(updateTitle)) return Defender;
|
||||
if (OsTitleExtras.IsMatch(updateTitle) || WindowsUpdateTitle.IsMatch(updateTitle)) return Os;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
// A pathological title must never cost the event — it is reported as "other".
|
||||
}
|
||||
|
||||
return Other;
|
||||
}
|
||||
}
|
||||
}
|
||||
+269
-252
@@ -2,100 +2,106 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Services;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches <c>Microsoft-Windows-WindowsUpdateClient/Operational</c> for quality/cumulative
|
||||
/// update install activity that happens DURING Autopilot OOBE / ESP and forwards it as
|
||||
/// structured <c>windows_update_succeeded</c> / <c>windows_update_failed</c> /
|
||||
/// <c>windows_update_started</c> events.
|
||||
/// Watches ONE channel of the <c>Microsoft-Windows-WindowsUpdateClient</c> provider for update
|
||||
/// activity during OOBE / ESP and forwards it as <c>windows_update_started</c> /
|
||||
/// <c>windows_update_succeeded</c> / <c>windows_update_failed</c> events.
|
||||
/// <para>
|
||||
/// This is a blind spot no other tool surfaces well: a cumulative update installing mid-OOBE
|
||||
/// can break the enrollment (r/Intune KB5095189), and the ESP "Install Windows quality updates
|
||||
/// during OOBE" feature makes it increasingly common. The Intune console shows none of it — it
|
||||
/// is otherwise deep manual digging on the device.
|
||||
/// <b>Two channels (D-310).</b> The provider manifest writes the install events — 19 success,
|
||||
/// 20 failure (HRESULT), 43 install started, 44 download started — to the <b>System</b> log and
|
||||
/// the scan/download events — 25 scan failed, 26 scan found N, 31 download failed, 41
|
||||
/// downloaded — to the Operational channel. The host runs one instance per channel; RecordIds
|
||||
/// are per channel, so each instance keeps its own watermark. The System log is shared with
|
||||
/// every other provider, hence the provider filter in its XPath.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Backfill.</b> OOBE updates run at the early "Getting updates" screen, frequently BEFORE the
|
||||
/// agent starts. The WindowsUpdateClient/Operational .evtx persists across the mid-OOBE reboot,
|
||||
/// so a startup backfill with a generous lookback catches those pre-agent events.
|
||||
/// <b>Classification.</b> Only Windows/.NET updates (<see cref="WindowsUpdateClassifier.Os"/>)
|
||||
/// are emitted one by one and reported to the decision engine. Store, Defender and other
|
||||
/// updates arrive every few minutes on a fresh device and are only counted into the host's
|
||||
/// <see cref="UpdateActivityTally"/>. Repeated scan results (26) and scan failures (25) are
|
||||
/// deduplicated; their repeats are counted the same way.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cross-restart dedup.</b> Unlike the single-latch WhiteGlove backfill, WU events are many
|
||||
/// distinct records, so an agent restart would re-read (and re-emit) events already reported in
|
||||
/// the prior run. We persist the highest processed <see cref="EventRecord.RecordId"/> as a
|
||||
/// watermark and skip records at/below it. Live records always carry a higher RecordId and pass.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Verified EventIDs (build against these):</b> 19=install success, 20=install failure
|
||||
/// (carries HRESULT in <c>errorCode</c>), 43=install started, 44=download started. IDs whose
|
||||
/// semantics are unverified across sources (25/26/31, restart-required 21 vs 41) are excluded by
|
||||
/// default and can be added via <c>CollectorConfiguration.WindowsUpdateTargetedEventIds</c>
|
||||
/// after validation on a real device trace — no redeploy needed.
|
||||
/// <b>Backfill.</b> Updates can start before the agent does; the .evtx survives the reboot, so
|
||||
/// a startup backfill with a generous lookback catches them. Backfilled events keep their own
|
||||
/// event time on the timeline.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class WindowsUpdateTracker : IDisposable
|
||||
{
|
||||
internal const string Channel = "Microsoft-Windows-WindowsUpdateClient/Operational";
|
||||
internal const string SystemChannel = "System";
|
||||
internal const string ProviderName = "Microsoft-Windows-WindowsUpdateClient";
|
||||
internal const string OrchestratorChannel = "Microsoft-Windows-UpdateOrchestrator/Operational";
|
||||
|
||||
internal const int EventId_InstallSuccess = 19;
|
||||
internal const int EventId_InstallFailure = 20;
|
||||
// System channel (provider manifest).
|
||||
internal const int EventId_InstallSuccess = 19;
|
||||
internal const int EventId_InstallFailure = 20;
|
||||
internal const int EventId_InstallStarted = 43;
|
||||
internal const int EventId_DownloadStarted = 44;
|
||||
// Operational channel.
|
||||
internal const int EventId_ScanFailed = 25;
|
||||
internal const int EventId_ScanFound = 26;
|
||||
internal const int EventId_DownloadFailed = 31;
|
||||
internal const int EventId_Downloaded = 41;
|
||||
|
||||
internal const string WatermarkStateFileName = "windows-update-watermark.json";
|
||||
internal const string SystemWatermarkStateFileName = "windows-update-system-watermark.json";
|
||||
|
||||
// Hard bound for the unfiltered census scan — during OOBE both channels carry at most a
|
||||
// few hundred records in the lookback window; the cap only guards against a pathological
|
||||
// log. A truncated census says so in its payload (no silent caps).
|
||||
internal const int CensusRecordCap = 5000;
|
||||
|
||||
// Scan results repeat every few minutes; only changes are worth a timeline row.
|
||||
internal const int MaxScanFoundEmissions = 10;
|
||||
internal const int MaxScanFailedEmissions = 5;
|
||||
|
||||
private static readonly int[] DefaultSystemEventIds = { EventId_InstallSuccess, EventId_InstallFailure, EventId_InstallStarted, EventId_DownloadStarted };
|
||||
private static readonly int[] DefaultOperationalEventIds = { EventId_ScanFailed, EventId_ScanFound, EventId_DownloadFailed, EventId_Downloaded };
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly string _channel;
|
||||
private readonly bool _isSystemChannel;
|
||||
private readonly HashSet<int> _targetedEventIds;
|
||||
private readonly bool _backfillEnabled;
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly bool _channelCensusEnabled;
|
||||
private readonly Func<bool> _osBuildChangedProvider;
|
||||
private readonly UpdateActivityTally _tally;
|
||||
private readonly Action<string, string, DateTime> _onOsUpdateActivity;
|
||||
private readonly EventRecordWatermark _watermark;
|
||||
|
||||
// Count of targeted WU events emitted THIS run (live + backfill). Zero after the backfill
|
||||
// while the OS build provably changed = the watcher is blind to the update's channel/IDs.
|
||||
// Targeted events processed THIS run (any class). Zero after the backfill while the OS
|
||||
// build provably changed = the watcher is blind to the update's channel/IDs.
|
||||
private int _emittedThisRun;
|
||||
|
||||
private readonly object _throttleLock = new object();
|
||||
private readonly HashSet<string> _scanFailureCodesReported = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
private string _lastScanFoundCount;
|
||||
private int _scanFoundEmitted;
|
||||
|
||||
private EventLogWatcher _watcher;
|
||||
|
||||
// Dedup. Guarded because EventRecordWritten callbacks run concurrently with the backfill scan.
|
||||
//
|
||||
// Cross-restart boundary loaded from disk: the highest RecordId emitted in a PRIOR run.
|
||||
// Immutable during this run — events at/below it were already emitted before this process
|
||||
// started and must not be re-emitted by the backfill scan.
|
||||
private long _restartWatermark = -1;
|
||||
// RecordIds emitted in THIS run — the intra-run dedup set. Deliberately a HashSet, NOT a
|
||||
// high-water mark: the live watcher is armed before the backfill runs, so a live event with a
|
||||
// HIGHER RecordId can be processed before/during backfill. A high-water mark would then make
|
||||
// the dedup skip every older, never-emitted backfill event (recordId <= max) — silently
|
||||
// dropping exactly the early pre-agent OOBE updates that are this feature's whole point.
|
||||
// WU install events are low-volume (a handful per enrollment), so the set stays tiny.
|
||||
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
|
||||
// Highest RecordId emitted so far (max of restart watermark + this run). Persisted so the next
|
||||
// run's restart watermark is correct.
|
||||
private long _maxEmittedRecordId = -1;
|
||||
private readonly object _watermarkLock = new object();
|
||||
|
||||
/// <param name="channel">Event log channel; defaults to the Operational channel.</param>
|
||||
/// <param name="targetedEventIds">IDs to watch on <paramref name="channel"/>; defaults to the manifest set of that channel.</param>
|
||||
/// <param name="tally">Counter for activity that is counted instead of emitted; null drops the counts.</param>
|
||||
/// <param name="onOsUpdateActivity">Called for every Windows/.NET update step with (step, update title, event time).</param>
|
||||
public WindowsUpdateTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
@@ -106,25 +112,60 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
int backfillLookbackMinutes = 60,
|
||||
string stateDirectory = null,
|
||||
bool channelCensusEnabled = true,
|
||||
Func<bool> osBuildChangedProvider = null)
|
||||
Func<bool> osBuildChangedProvider = null,
|
||||
string channel = null,
|
||||
UpdateActivityTally tally = null,
|
||||
Action<string, string, DateTime> onOsUpdateActivity = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_channel = string.IsNullOrEmpty(channel) ? Channel : channel;
|
||||
_isSystemChannel = string.Equals(_channel, SystemChannel, StringComparison.OrdinalIgnoreCase);
|
||||
_targetedEventIds = targetedEventIds != null && targetedEventIds.Length > 0
|
||||
? new HashSet<int>(targetedEventIds)
|
||||
: new HashSet<int> { EventId_InstallSuccess, EventId_InstallFailure, EventId_InstallStarted, EventId_DownloadStarted };
|
||||
: new HashSet<int>(_isSystemChannel ? DefaultSystemEventIds : DefaultOperationalEventIds);
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_channelCensusEnabled = channelCensusEnabled;
|
||||
_osBuildChangedProvider = osBuildChangedProvider;
|
||||
_tally = tally;
|
||||
_onOsUpdateActivity = onOsUpdateActivity;
|
||||
_watermark = new EventRecordWatermark(
|
||||
stateDirectory,
|
||||
_isSystemChannel ? SystemWatermarkStateFileName : WatermarkStateFileName,
|
||||
logger,
|
||||
_isSystemChannel ? "WindowsUpdate (System)" : "WindowsUpdate");
|
||||
}
|
||||
|
||||
/// <summary>Channel this instance watches.</summary>
|
||||
internal string WatchedChannel => _channel;
|
||||
|
||||
/// <summary>Targeted events processed this run, any class.</summary>
|
||||
internal int EmittedThisRun => Volatile.Read(ref _emittedThisRun);
|
||||
|
||||
/// <summary>EventIDs this instance watches on <see cref="WatchedChannel"/>.</summary>
|
||||
internal IReadOnlyCollection<int> TargetedEventIds => _targetedEventIds;
|
||||
|
||||
/// <summary>
|
||||
/// True when the provider manifest writes <paramref name="eventId"/> to the System log
|
||||
/// (16–24, 27, 28, 32, 33, 43, 44, 212–218); all other IDs live in the Operational channel.
|
||||
/// </summary>
|
||||
internal static bool IsSystemChannelEventId(int eventId) =>
|
||||
(eventId >= 16 && eventId <= 24)
|
||||
|| eventId == 27 || eventId == 28
|
||||
|| eventId == 32 || eventId == 33
|
||||
|| eventId == 43 || eventId == 44
|
||||
|| (eventId >= 212 && eventId <= 218);
|
||||
|
||||
/// <summary>
|
||||
/// Watches the channel and backfills it. The blind-spot census is NOT run here: it needs
|
||||
/// the emissions of every channel, so the host runs it after all its trackers started.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
LoadWatermark();
|
||||
_watermark.Load();
|
||||
StartWatcher();
|
||||
|
||||
if (_backfillEnabled && _backfillLookbackMinutes > 0)
|
||||
@@ -133,10 +174,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("WindowsUpdate backfill disabled by config");
|
||||
_logger.Info($"WindowsUpdate backfill disabled by config ({_channel})");
|
||||
}
|
||||
|
||||
EmitChannelCensusIfBlind();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
@@ -146,11 +185,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
_watcher.Enabled = false;
|
||||
_watcher.Dispose();
|
||||
_logger.Info("WindowsUpdate watcher stopped");
|
||||
_logger.Info($"WindowsUpdate watcher stopped ({_channel})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error stopping WindowsUpdate watcher", ex);
|
||||
_logger.Error($"Error stopping WindowsUpdate watcher ({_channel})", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -160,52 +199,64 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
internal void LoadWatermark() => _watermark.Load();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watcher lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private string ProviderFilter => _isSystemChannel ? ProviderName : null;
|
||||
|
||||
private void StartWatcher()
|
||||
{
|
||||
if (_targetedEventIds.Count == 0)
|
||||
{
|
||||
_logger.Warning("WindowsUpdate watcher not started: no targeted EventIDs configured");
|
||||
_logger.Warning($"WindowsUpdate watcher not started: no targeted EventIDs configured ({_channel})");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var xpath = BuildXPath(_targetedEventIds);
|
||||
var query = new EventLogQuery(Channel, PathType.LogName, xpath);
|
||||
var query = new EventLogQuery(_channel, PathType.LogName, BuildXPath(_targetedEventIds, ProviderFilter));
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
_watcher.Enabled = true;
|
||||
_logger.Info($"WindowsUpdate watcher started: {Channel} (targetedIds={_targetedEventIds.Count})");
|
||||
_logger.Info($"WindowsUpdate watcher started: {_channel} (targetedIds={string.Join(",", _targetedEventIds.OrderBy(id => id))})");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate event log not found: {Channel} (normal on non-Windows 10/11 test environments)");
|
||||
_logger.Warning($"WindowsUpdate event log not found: {_channel} (normal on non-Windows 10/11 test environments)");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate watcher access denied for {Channel}: {ex.Message}");
|
||||
_logger.Warning($"WindowsUpdate watcher access denied for {_channel}: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to start WindowsUpdate watcher for {Channel}", ex);
|
||||
_logger.Error($"Failed to start WindowsUpdate watcher for {_channel}", ex);
|
||||
// MON-D1: surface a dead watcher as one-shot telemetry, not just a local log line.
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
|
||||
collectorName: "WindowsUpdateTracker", reason: $"watcher_arm_failed:{Channel}", ex: ex);
|
||||
collectorName: "WindowsUpdateTracker", reason: $"watcher_arm_failed:{_channel}", ex: ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Targeted-EventID XPath filter without a provider clause. Exposed for tests.</summary>
|
||||
internal static string BuildXPath(HashSet<int> targetedEventIds) =>
|
||||
BuildXPath(targetedEventIds, providerName: null);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the targeted-EventID XPath filter. Exposed as internal for tests.
|
||||
/// Targeted-EventID XPath filter, optionally restricted to <paramref name="providerName"/>
|
||||
/// (mandatory on the shared System log) and to the last <paramref name="lookbackMs"/>.
|
||||
/// </summary>
|
||||
internal static string BuildXPath(HashSet<int> targetedEventIds)
|
||||
internal static string BuildXPath(HashSet<int> targetedEventIds, string providerName, long? lookbackMs = null)
|
||||
{
|
||||
var idClauses = string.Join(" or ",
|
||||
targetedEventIds.OrderBy(id => id).Select(id => $"EventID={id}"));
|
||||
return $"*[System[({idClauses})]]";
|
||||
var clauses = new List<string>(3);
|
||||
if (!string.IsNullOrEmpty(providerName))
|
||||
clauses.Add($"Provider[@Name='{providerName}']");
|
||||
clauses.Add("(" + string.Join(" or ", targetedEventIds.OrderBy(id => id).Select(id => $"EventID={id}")) + ")");
|
||||
if (lookbackMs.HasValue)
|
||||
clauses.Add($"TimeCreated[timediff(@SystemTime) <= {lookbackMs.Value}]");
|
||||
return $"*[System[{string.Join(" and ", clauses)}]]";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -230,7 +281,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var recordId = record.RecordId ?? -1;
|
||||
|
||||
// Cheap dedup gate BEFORE the expensive XML/FormatDescription work.
|
||||
if (IsAlreadyProcessed(recordId))
|
||||
if (_watermark.IsAlreadyProcessed(recordId))
|
||||
return;
|
||||
|
||||
string xml = null;
|
||||
@@ -242,6 +293,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
eventData.TryGetValue("updateGuid", out var updateGuid);
|
||||
eventData.TryGetValue("updateRevisionNumber", out var updateRevision);
|
||||
eventData.TryGetValue("errorCode", out var errorCode);
|
||||
eventData.TryGetValue("serviceGuid", out var serviceGuid);
|
||||
eventData.TryGetValue("updateCount", out var updateCount);
|
||||
|
||||
string description = null;
|
||||
try { description = record.FormatDescription(); }
|
||||
@@ -257,7 +310,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
updateRevisionNumber: updateRevision,
|
||||
errorCode: errorCode,
|
||||
formattedDescription: description,
|
||||
isBackfill: isBackfill);
|
||||
isBackfill: isBackfill,
|
||||
serviceGuid: serviceGuid,
|
||||
updateCount: updateCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -275,66 +330,68 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string updateRevisionNumber,
|
||||
string errorCode,
|
||||
string formattedDescription,
|
||||
bool isBackfill)
|
||||
bool isBackfill,
|
||||
string serviceGuid = null,
|
||||
string updateCount = null)
|
||||
{
|
||||
if (!MarkProcessed(recordId))
|
||||
if (!_watermark.TryClaim(recordId))
|
||||
return; // already emitted (cross-restart or duplicate delivery)
|
||||
|
||||
System.Threading.Interlocked.Increment(ref _emittedThisRun);
|
||||
Interlocked.Increment(ref _emittedThisRun);
|
||||
|
||||
string eventType;
|
||||
EventSeverity severity;
|
||||
bool immediateUpload;
|
||||
ClassifyEventId(eventId, out eventType, out severity, out immediateUpload);
|
||||
var shape = ShapeFor(eventId);
|
||||
var title = string.IsNullOrEmpty(updateTitle) ? "(unknown update)" : updateTitle;
|
||||
|
||||
// Scan-level events name no update: deduplicate instead of classifying. Every other
|
||||
// event is classified; only Windows/.NET updates are listed, the rest is counted —
|
||||
// so windows_update_succeeded/failed always mean an OS update (ANALYZE-DEV-004/005).
|
||||
string updateClass = null;
|
||||
if (eventId == EventId_ScanFound)
|
||||
{
|
||||
if (!ClaimScanFoundEmission(updateCount)) { _tally?.Increment("scan_found_repeat"); return; }
|
||||
}
|
||||
else if (eventId == EventId_ScanFailed)
|
||||
{
|
||||
if (!ClaimScanFailedEmission(errorCode)) { _tally?.Increment("scan_failed_repeat"); return; }
|
||||
}
|
||||
else
|
||||
{
|
||||
updateClass = WindowsUpdateClassifier.Classify(updateTitle, serviceGuid);
|
||||
if (updateClass != WindowsUpdateClassifier.Os)
|
||||
{
|
||||
_tally?.Increment($"{updateClass}_{eventId}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "wuEventId", eventId },
|
||||
{ "wuChannel", _isSystemChannel ? "system" : "operational" },
|
||||
{ "wuPhase", shape.Phase },
|
||||
{ "backfilled", isBackfill },
|
||||
};
|
||||
if (updateClass != null) data["updateClass"] = updateClass;
|
||||
if (recordId >= 0) data["recordId"] = recordId;
|
||||
if (level.HasValue) data["level"] = level.Value;
|
||||
if (!string.IsNullOrEmpty(updateTitle)) data["updateTitle"] = updateTitle;
|
||||
if (!string.IsNullOrEmpty(updateGuid)) data["updateGuid"] = updateGuid;
|
||||
if (!string.IsNullOrEmpty(updateRevisionNumber)) data["updateRevisionNumber"] = updateRevisionNumber;
|
||||
if (!string.IsNullOrEmpty(updateCount)) data["updateCount"] = updateCount;
|
||||
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
|
||||
|
||||
// Decode the HRESULT for failure events (and any event that carries a non-empty errorCode).
|
||||
string hresultHex;
|
||||
if (TryNormalizeHResult(errorCode, out var hresultValue, out hresultHex))
|
||||
if (TryNormalizeHResult(errorCode, out var hresultValue, out var hresultHex))
|
||||
{
|
||||
data["hresult"] = hresultHex;
|
||||
data["hresultSymbol"] = ResolveHResultSymbol(hresultValue);
|
||||
}
|
||||
|
||||
var title = string.IsNullOrEmpty(updateTitle) ? "(unknown update)" : updateTitle;
|
||||
string message;
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InstallSuccess:
|
||||
message = $"Windows Update installed during enrollment: {title}";
|
||||
break;
|
||||
case EventId_InstallFailure:
|
||||
message = data.TryGetValue("hresultSymbol", out var sym)
|
||||
? $"Windows Update FAILED during enrollment: {title} ({data["hresult"]} {sym})"
|
||||
: $"Windows Update FAILED during enrollment: {title}";
|
||||
break;
|
||||
case EventId_DownloadStarted:
|
||||
message = $"Windows Update download started during enrollment: {title}";
|
||||
break;
|
||||
case EventId_InstallStarted:
|
||||
message = $"Windows Update install started during enrollment: {title}";
|
||||
break;
|
||||
default:
|
||||
message = $"Windows Update activity (EventID {eventId}) during enrollment: {title}";
|
||||
break;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(formattedDescription))
|
||||
{
|
||||
var truncated = formattedDescription.Length > 1000
|
||||
data["description"] = formattedDescription.Length > 1000
|
||||
? formattedDescription.Substring(0, 1000) + "…"
|
||||
: formattedDescription;
|
||||
data["description"] = truncated;
|
||||
}
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
@@ -343,46 +400,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
TenantId = _tenantId,
|
||||
// Use the WU event's own time, not the default UtcNow the ctor stamps: backfilled
|
||||
// pre-agent OOBE updates must land on the timeline at when the update actually
|
||||
// installed, not at agent start. InformationalEventPost forwards this as occurredAtUtc,
|
||||
// happened, not at agent start. InformationalEventPost forwards this as occurredAtUtc,
|
||||
// which drives the timeline entry's timestamp (EventTimelineEmitter).
|
||||
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "WindowsUpdateWatcher",
|
||||
EventType = shape.EventType,
|
||||
Severity = shape.Severity,
|
||||
Source = Constants.EventSources.WindowsUpdateWatcher,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Message = BuildMessage(eventId, title, updateCount, data),
|
||||
Data = data,
|
||||
ImmediateUpload = immediateUpload,
|
||||
ImmediateUpload = shape.Immediate,
|
||||
});
|
||||
|
||||
if (updateClass == WindowsUpdateClassifier.Os && shape.OsStep != null && _onOsUpdateActivity != null)
|
||||
{
|
||||
try { _onOsUpdateActivity(shape.OsStep, updateTitle, timeCreatedUtc ?? DateTime.UtcNow); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdate: OS update activity callback failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Last: everything this record produced has been handed to the ingress first.
|
||||
_watermark.MarkEmitted(recordId);
|
||||
}
|
||||
|
||||
private static void ClassifyEventId(int eventId, out string eventType, out EventSeverity severity, out bool immediateUpload)
|
||||
private struct EventShape
|
||||
{
|
||||
public string EventType;
|
||||
public EventSeverity Severity;
|
||||
public bool Immediate;
|
||||
public string Phase;
|
||||
/// <summary>Step reported to the decision engine for OS updates; null = not a step.</summary>
|
||||
public string OsStep;
|
||||
}
|
||||
|
||||
private static EventShape ShapeFor(int eventId)
|
||||
{
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InstallFailure:
|
||||
eventType = Constants.EventTypes.WindowsUpdateFailed;
|
||||
severity = EventSeverity.Error;
|
||||
immediateUpload = true; // a mid-OOBE update failure is high-signal — surface it fast
|
||||
break;
|
||||
case EventId_InstallSuccess:
|
||||
eventType = Constants.EventTypes.WindowsUpdateSucceeded;
|
||||
severity = EventSeverity.Info;
|
||||
immediateUpload = false;
|
||||
break;
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateSucceeded, Severity = EventSeverity.Info, Phase = "installed", OsStep = OsUpdateSteps.Installed };
|
||||
case EventId_InstallFailure:
|
||||
// A mid-OOBE install failure is high-signal — surface it fast.
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateFailed, Severity = EventSeverity.Error, Immediate = true, Phase = "install", OsStep = OsUpdateSteps.Failed };
|
||||
case EventId_InstallStarted:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "install", OsStep = OsUpdateSteps.InstallStarted };
|
||||
case EventId_DownloadStarted:
|
||||
eventType = Constants.EventTypes.WindowsUpdateStarted;
|
||||
severity = EventSeverity.Debug; // timeline context only
|
||||
immediateUpload = false;
|
||||
break;
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "download", OsStep = OsUpdateSteps.DownloadStarted };
|
||||
case EventId_Downloaded:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "downloaded", OsStep = OsUpdateSteps.Downloaded };
|
||||
case EventId_DownloadFailed:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateFailed, Severity = EventSeverity.Warning, Phase = "download", OsStep = OsUpdateSteps.Failed };
|
||||
case EventId_ScanFailed:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateFailed, Severity = EventSeverity.Warning, Phase = "scan" };
|
||||
case EventId_ScanFound:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Debug, Phase = "scan" };
|
||||
default:
|
||||
// A newly-enabled (still-being-validated) EventID: keep it visible (Info, not Debug)
|
||||
// so it can be assessed on real traces.
|
||||
eventType = Constants.EventTypes.WindowsUpdateStarted;
|
||||
severity = EventSeverity.Info;
|
||||
immediateUpload = false;
|
||||
break;
|
||||
// An EventID enabled via config whose meaning has not been mapped: keep it
|
||||
// visible (Info, not Debug) so it can be assessed on real traces.
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "activity" };
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildMessage(int eventId, string title, string updateCount, Dictionary<string, object> data)
|
||||
{
|
||||
var hresultSuffix = data.TryGetValue("hresultSymbol", out var sym)
|
||||
? $" ({data["hresult"]} {sym})"
|
||||
: string.Empty;
|
||||
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InstallSuccess: return $"Windows Update installed during enrollment: {title}";
|
||||
case EventId_InstallFailure: return $"Windows Update FAILED during enrollment: {title}{hresultSuffix}";
|
||||
case EventId_InstallStarted: return $"Windows Update install started during enrollment: {title}";
|
||||
case EventId_DownloadStarted: return $"Windows Update download started during enrollment: {title}";
|
||||
case EventId_Downloaded: return $"Windows Update downloaded during enrollment: {title}";
|
||||
case EventId_DownloadFailed: return $"Windows Update download FAILED during enrollment: {title}{hresultSuffix}";
|
||||
case EventId_ScanFailed: return $"Windows Update scan failed during enrollment{hresultSuffix}";
|
||||
case EventId_ScanFound:
|
||||
return string.IsNullOrEmpty(updateCount)
|
||||
? "Windows Update scan completed during enrollment"
|
||||
: $"Windows Update scan found {updateCount} update(s) during enrollment";
|
||||
default: return $"Windows Update activity (EventID {eventId}) during enrollment: {title}";
|
||||
}
|
||||
}
|
||||
|
||||
private bool ClaimScanFoundEmission(string updateCount)
|
||||
{
|
||||
lock (_throttleLock)
|
||||
{
|
||||
if (_scanFoundEmitted >= MaxScanFoundEmissions) return false;
|
||||
if (string.Equals(_lastScanFoundCount, updateCount ?? string.Empty, StringComparison.Ordinal)) return false;
|
||||
_lastScanFoundCount = updateCount ?? string.Empty;
|
||||
_scanFoundEmitted++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ClaimScanFailedEmission(string errorCode)
|
||||
{
|
||||
lock (_throttleLock)
|
||||
{
|
||||
if (_scanFailureCodesReported.Count >= MaxScanFailedEmissions) return false;
|
||||
return _scanFailureCodesReported.Add(errorCode ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,14 +614,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
try
|
||||
{
|
||||
var lookbackMs = (long)_backfillLookbackMinutes * 60 * 1000;
|
||||
var idClauses = string.Join(" or ",
|
||||
_targetedEventIds.OrderBy(id => id).Select(id => $"EventID={id}"));
|
||||
var xpath = $"*[System[({idClauses}) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]";
|
||||
var xpath = BuildXPath(_targetedEventIds, ProviderFilter, lookbackMs);
|
||||
|
||||
_logger.Info($"WindowsUpdate backfill: scanning {Channel} " +
|
||||
$"(lookback={_backfillLookbackMinutes}min, targetedIds={_targetedEventIds.Count}, restartWatermark={_restartWatermark})");
|
||||
_logger.Info($"WindowsUpdate backfill: scanning {_channel} " +
|
||||
$"(lookback={_backfillLookbackMinutes}min, targetedIds={_targetedEventIds.Count}, restartWatermark={_watermark.RestartWatermark})");
|
||||
|
||||
var query = new EventLogQuery(Channel, PathType.LogName, xpath);
|
||||
var query = new EventLogQuery(_channel, PathType.LogName, xpath);
|
||||
|
||||
int processed = 0;
|
||||
using (var reader = new EventLogReader(query))
|
||||
@@ -520,21 +636,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
if (processed == 0)
|
||||
_logger.Debug($"WindowsUpdate backfill: no targeted events in last {_backfillLookbackMinutes} minutes");
|
||||
_logger.Debug($"WindowsUpdate backfill: no targeted events in last {_backfillLookbackMinutes} minutes ({_channel})");
|
||||
else
|
||||
_logger.Info($"WindowsUpdate backfill: scanned {processed} event(s)");
|
||||
_logger.Info($"WindowsUpdate backfill: scanned {processed} event(s) ({_channel})");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate event log not found during backfill: {Channel} (normal on non-Windows 10/11 test environments)");
|
||||
_logger.Warning($"WindowsUpdate event log not found during backfill: {_channel} (normal on non-Windows 10/11 test environments)");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate backfill access denied: {ex.Message}");
|
||||
_logger.Warning($"WindowsUpdate backfill access denied ({_channel}): {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate backfill failed: {ex.Message}");
|
||||
_logger.Warning($"WindowsUpdate backfill failed ({_channel}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,17 +660,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>
|
||||
/// Self-evidencing blind spot diagnosis. Session 7443317c: an OOBE quality update
|
||||
/// provably installed (OS build changed across the mid-enrollment reboot) yet neither
|
||||
/// the live watcher nor two 60-min backfills captured a single targeted event — the
|
||||
/// update logged via a channel/EventID set the watcher is blind to. With diagnostics
|
||||
/// upload tenant-disabled there is no client-log path to find out which one; so when
|
||||
/// the blind spot provably occurred (build changed AND zero targeted events emitted
|
||||
/// this run), emit a one-shot unfiltered EventID histogram of both update channels
|
||||
/// over the lookback window. The histogram arrives via normal event upload and tells
|
||||
/// us which IDs to add to <c>WindowsUpdateTargetedEventIds</c> via remote config —
|
||||
/// no agent redeploy, no diagnostics access needed.
|
||||
/// provably installed (OS build changed across the mid-enrollment reboot) yet the watcher
|
||||
/// captured nothing — it read the install IDs from the Operational channel, while the
|
||||
/// manifest writes them to the System log (D-310). When the blind spot occurs again
|
||||
/// (build changed AND zero targeted events on every channel this run), emit a one-shot
|
||||
/// unfiltered EventID histogram of the Operational and UpdateOrchestrator channels.
|
||||
/// </summary>
|
||||
internal void EmitChannelCensusIfBlind()
|
||||
/// <param name="otherChannelsEmitted">Targeted events the host's other update watchers processed this run.</param>
|
||||
/// <param name="otherTargetedEventIds">EventIDs the host's other update watchers target, listed with this instance's own.</param>
|
||||
internal void EmitChannelCensusIfBlind(int otherChannelsEmitted = 0, IEnumerable<int> otherTargetedEventIds = null)
|
||||
{
|
||||
if (!_channelCensusEnabled) return;
|
||||
|
||||
@@ -562,7 +676,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
if (_osBuildChangedProvider == null || !_osBuildChangedProvider())
|
||||
return;
|
||||
if (System.Threading.Volatile.Read(ref _emittedThisRun) > 0)
|
||||
if (EmittedThisRun + otherChannelsEmitted > 0)
|
||||
return;
|
||||
|
||||
var lookback = _backfillLookbackMinutes > 0 ? _backfillLookbackMinutes : 60;
|
||||
@@ -581,8 +695,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{ "wuClientCensus", wuClientHistogram },
|
||||
{ "updateOrchestratorCensus", orchestratorHistogram },
|
||||
{ "lookbackMinutes", lookback },
|
||||
{ "restartWatermark", _restartWatermark },
|
||||
{ "targetedEventIds", string.Join(",", _targetedEventIds.OrderBy(id => id)) },
|
||||
{ "restartWatermark", _watermark.RestartWatermark },
|
||||
{ "targetedEventIds", string.Join(",", _targetedEventIds.Concat(otherTargetedEventIds ?? Enumerable.Empty<int>()).Distinct().OrderBy(id => id)) },
|
||||
};
|
||||
if (wuClient.Truncated || orchestrator.Truncated)
|
||||
data["censusTruncated"] = true;
|
||||
@@ -593,7 +707,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.WindowsUpdateChannelCensus,
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = "WindowsUpdateWatcher",
|
||||
Source = Constants.EventSources.WindowsUpdateWatcher,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = "Windows Update channel census: OS build changed across restart but no targeted WU events were captured — EventID histogram attached",
|
||||
Data = data,
|
||||
@@ -666,102 +780,5 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <summary>Compact "id=count" form ordered by EventID, e.g. "19=2,25=1,43=2".</summary>
|
||||
internal static string FormatHistogram(Dictionary<int, int> histogram) =>
|
||||
string.Join(",", histogram.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watermark dedup (cross-restart)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private bool IsAlreadyProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically claims <paramref name="recordId"/>. Returns true if this is the first time we
|
||||
/// see it (caller should emit), false if it was already processed — either in a PRIOR run
|
||||
/// (at/below the restart watermark) or already this run (in the seen-set). Only advances +
|
||||
/// persists the max on a genuinely-new, higher RecordId. Records without a RecordId (-1) are
|
||||
/// always emitted, never tracked or persisted.
|
||||
/// </summary>
|
||||
private bool MarkProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return true;
|
||||
|
||||
long toPersist = -1;
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
// Prior run, or already emitted this run. Note: a lower RecordId that was never
|
||||
// emitted this run (e.g. a backfill record read after a higher live record) is NOT
|
||||
// in _seenThisRun and is above the restart watermark, so it is correctly emitted.
|
||||
if (recordId <= _restartWatermark || !_seenThisRun.Add(recordId))
|
||||
return false;
|
||||
|
||||
if (recordId > _maxEmittedRecordId)
|
||||
{
|
||||
_maxEmittedRecordId = recordId;
|
||||
toPersist = _maxEmittedRecordId;
|
||||
}
|
||||
}
|
||||
if (toPersist >= 0) PersistWatermark(toPersist);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void LoadWatermark()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
|
||||
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
|
||||
if (!File.Exists(filePath)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var state = JsonConvert.DeserializeObject<WatermarkState>(json);
|
||||
if (state != null)
|
||||
{
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
_restartWatermark = state.LastRecordId;
|
||||
_maxEmittedRecordId = state.LastRecordId;
|
||||
}
|
||||
_logger.Info($"WindowsUpdate watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to load WindowsUpdate watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistWatermark(long recordId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
|
||||
var state = new WatermarkState { LastRecordId = recordId, PersistedUtc = DateTime.UtcNow };
|
||||
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
|
||||
var tempPath = filePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Copy(tempPath, filePath, overwrite: true);
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to persist WindowsUpdate watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WatermarkState
|
||||
{
|
||||
public long LastRecordId { get; set; }
|
||||
public DateTime PersistedUtc { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -215,6 +215,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
data["ime_verified_bytes"] = health.VerifiedBytes;
|
||||
data["ime_pass_max_ms"] = health.PassMaxMs;
|
||||
data["ime_pass_gap_max_ms"] = health.PassGapMaxMs;
|
||||
data["ime_platform_output_verified"] = health.PlatformOutputVerified;
|
||||
data["ime_platform_output_corrected"] = health.PlatformOutputCorrected;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -132,12 +132,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_informationalEvents = informationalEvents;
|
||||
_parkedTripwireDwell = parkedTripwireDwell ?? DefaultParkedTripwireDwell;
|
||||
|
||||
// If recovery loaded a state that already sits on a terminal stage (e.g. a crash
|
||||
// after a success-path step but before Stop()), treat it as already-notified so we
|
||||
// do not re-fire the hook.
|
||||
_terminalNotified = initialState.Stage.IsTerminal();
|
||||
// If recovery loaded a state that already ends the session (e.g. a crash after a
|
||||
// success-path step but before Stop()), treat it as already-notified so we do not
|
||||
// re-fire the hook — the orchestrator handles that state at start.
|
||||
_terminalNotified = EndsSession(initialState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the state ends this agent's session: a terminal stage, or the engine stopped
|
||||
/// waiting for the user without a verdict (<see cref="DecisionState.StoppedWaitingReason"/>,
|
||||
/// D-310) — the stage stays non-terminal there, and the agent ends the session like its
|
||||
/// max-lifetime watchdog.
|
||||
/// </summary>
|
||||
internal static bool EndsSession(DecisionState state) =>
|
||||
state.Stage.IsTerminal() || state.StoppedWaitingReason != null;
|
||||
|
||||
public DecisionState CurrentState => _currentState;
|
||||
|
||||
/// <summary>Test-Observability — anzahl aufeinanderfolgender Journal-Failures.</summary>
|
||||
@@ -284,7 +293,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// orchestrator turns this into the public EnrollmentTerminated event so peripheral
|
||||
// consumers (CleanupService, SummaryDialog, DiagnosticsPackageService) can react
|
||||
// without touching the kernel state machine.
|
||||
if (!_terminalNotified && _currentState.Stage.IsTerminal())
|
||||
if (!_terminalNotified && EndsSession(_currentState))
|
||||
{
|
||||
_terminalNotified = true;
|
||||
try { _onTerminalStageReached?.Invoke(_currentState); }
|
||||
@@ -405,7 +414,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
private static bool IsParkedWithoutDeadline(DecisionState state)
|
||||
{
|
||||
if (state.Stage.IsTerminal()) return false;
|
||||
if (EndsSession(state)) return false;
|
||||
if (state.Stage == SessionStage.Unknown || state.Stage == SessionStage.SessionStarted) return false;
|
||||
|
||||
// WDP (afee7ae0): Device Preparation has no ESP, so neither the AccountSetup
|
||||
|
||||
@@ -524,14 +524,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// EspTrackingWin32Apps, StatusServiceReports) via snapshot-and-diff. Emits
|
||||
// registry_app_state on real changes and app_state_reconciliation when the
|
||||
// registry outcome diverges from the IME-log-derived state (= built-in pattern-drift
|
||||
// alarm). Always-on observability host; the tracker probe is read-only.
|
||||
// alarm). Always-on observability host; the tracker probe is read-only. Under the same
|
||||
// root IME saves each platform script's result after reporting the batch: the script
|
||||
// observer checks the tracker's emitted runs against it (script_output_reconciliation).
|
||||
hosts.Add(new ImeRegistryAppStateHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
trackerStateProbe: () => imeLogHost.AllKnownPackageStates));
|
||||
trackerStateProbe: () => imeLogHost.AllKnownPackageStates,
|
||||
scriptRuns: imeLogHost.Tracker.ScriptRuns));
|
||||
|
||||
// Deterministic update corroboration — compares the persisted OS build (CurrentBuild.UBR)
|
||||
// across agent restarts and emits os_build_changed when it differs (session 7443317c:
|
||||
@@ -547,9 +550,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
stateDirectory: _stateDirectory);
|
||||
hosts.Add(osBuildChangeHost);
|
||||
|
||||
// Windows Update during OOBE watcher — subscribes to WindowsUpdateClient/Operational and
|
||||
// backfills recent events (OOBE quality updates run before the agent starts). Surfaces
|
||||
// quality/cumulative updates installing/failing DURING enrollment — otherwise invisible.
|
||||
// OS update watchers (D-310) — the Windows Update client on the System log (install
|
||||
// events) and its Operational channel (scan/download), plus the CBS servicing steps on
|
||||
// the Setup log; each backfills recent events because updates can start before the
|
||||
// agent does. Surfaces Windows/.NET updates installing/failing DURING enrollment and,
|
||||
// unless OobeUpdatePhaseMode is Off, feeds them into the engine's OOBE update phase.
|
||||
if (collectors.WindowsUpdateWatcherEnabled)
|
||||
{
|
||||
hosts.Add(new WindowsUpdateWatcherHost(
|
||||
@@ -562,7 +567,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
backfillLookbackMinutes: collectors.WindowsUpdateBackfillLookbackMinutes,
|
||||
stateDirectory: _stateDirectory,
|
||||
channelCensusEnabled: collectors.WindowsUpdateChannelCensusEnabled,
|
||||
osBuildChangedProvider: () => osBuildChangeHost.BuildChanged));
|
||||
osBuildChangedProvider: () => osBuildChangeHost.BuildChanged,
|
||||
operationalEventIds: collectors.WindowsUpdateOperationalEventIds,
|
||||
servicingWatcherEnabled: collectors.ServicingWatcherEnabled,
|
||||
osUpdateSignalsEnabled: DecisionCore.State.OobeUpdatePhaseModes.PostsSignals(collectors.OobeUpdatePhaseMode)));
|
||||
}
|
||||
|
||||
// MDM reboot-policy watcher — subscribes to DeviceManagement-Enterprise-Diagnostics-
|
||||
|
||||
@@ -740,7 +740,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// itself is constructed inside the onIngressReady hook (step 13b), which has
|
||||
// already fired by this point — so when our recovery-fire propagates through
|
||||
// Task.Run, the handler is fully wired.
|
||||
if (initialState.Stage == SessionStage.Completed || initialState.Stage == SessionStage.Failed)
|
||||
// The same applies to a state in which the engine stopped waiting (D-310): a
|
||||
// crash between that step and the termination must not leave the session running.
|
||||
if (initialState.Stage == SessionStage.Completed || initialState.Stage == SessionStage.Failed
|
||||
|| initialState.StoppedWaitingReason != null)
|
||||
{
|
||||
_logger.Warning(
|
||||
$"EnrollmentOrchestrator: post-recovery initialState already terminal " +
|
||||
@@ -869,25 +872,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Stop the max-lifetime watchdog — the real terminal arrived before it could trip.
|
||||
try { _maxLifetimeTimer?.Dispose(); _maxLifetimeTimer = null; } catch { }
|
||||
|
||||
var outcome = terminalState.Stage switch
|
||||
{
|
||||
SessionStage.Completed or SessionStage.WhiteGloveSealed
|
||||
=> EnrollmentTerminationOutcome.Succeeded,
|
||||
SessionStage.Failed => EnrollmentTerminationOutcome.Failed,
|
||||
_ => EnrollmentTerminationOutcome.TimedOut,
|
||||
};
|
||||
// D-310: the engine stopped waiting for the user without a verdict. The stage stays
|
||||
// non-terminal; the session ends like the max-lifetime watchdog ends it (TimedOut), so
|
||||
// the backend classifies it honestly — never as a failure.
|
||||
var stoppedWaiting = !terminalState.Stage.IsTerminal() && terminalState.StoppedWaitingReason != null;
|
||||
|
||||
var outcome = stoppedWaiting
|
||||
? EnrollmentTerminationOutcome.TimedOut
|
||||
: terminalState.Stage switch
|
||||
{
|
||||
SessionStage.Completed or SessionStage.WhiteGloveSealed
|
||||
=> EnrollmentTerminationOutcome.Succeeded,
|
||||
SessionStage.Failed => EnrollmentTerminationOutcome.Failed,
|
||||
_ => EnrollmentTerminationOutcome.TimedOut,
|
||||
};
|
||||
var reason = stoppedWaiting
|
||||
? EnrollmentTerminationReason.StoppedWaiting
|
||||
: EnrollmentTerminationReason.DecisionTerminalStage;
|
||||
|
||||
_logger.Info(
|
||||
$"EnrollmentOrchestrator: decision terminal stage reached (stage={terminalState.Stage}, outcome={outcome}) — dispatching Terminated(DecisionTerminalStage) off worker.");
|
||||
$"EnrollmentOrchestrator: decision ended the session (stage={terminalState.Stage}, outcome={outcome}, reason={reason}) — dispatching Terminated off worker.");
|
||||
|
||||
string? details = null;
|
||||
if (stoppedWaiting)
|
||||
details = StoppedWaitingReasons.Describe(terminalState.StoppedWaitingReason!.Value);
|
||||
else if (terminalState.Stage.IsPauseBeforePart2())
|
||||
details = "WhiteGlove Part 1 sealed — session will resume on Part 2 post-reboot; self-destruct suppressed.";
|
||||
|
||||
var terminatedArgs = new EnrollmentTerminatedEventArgs(
|
||||
reason: EnrollmentTerminationReason.DecisionTerminalStage,
|
||||
reason: reason,
|
||||
outcome: outcome,
|
||||
stageName: terminalState.Stage.ToString(),
|
||||
terminatedAtUtc: _clock.UtcNow,
|
||||
details: terminalState.Stage.IsPauseBeforePart2()
|
||||
? "WhiteGlove Part 1 sealed — session will resume on Part 2 post-reboot; self-destruct suppressed."
|
||||
: null);
|
||||
details: details,
|
||||
stopReason: stoppedWaiting ? terminalState.StoppedWaitingReason!.Value : null);
|
||||
|
||||
// Codex Finding 2 — off-worker dispatch. Task.Run unblocks the ingress worker
|
||||
// immediately; the worker keeps draining the channel (including events the
|
||||
|
||||
+16
-1
@@ -16,6 +16,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// longer than the configured cap without reaching a terminal stage.
|
||||
/// </summary>
|
||||
MaxLifetimeExceeded,
|
||||
|
||||
/// <summary>
|
||||
/// The DecisionEngine stopped waiting for the user without a verdict — a bounded wait
|
||||
/// expired (<c>DecisionState.StoppedWaitingReason</c>, D-310). Ends the session like
|
||||
/// <see cref="MaxLifetimeExceeded"/> (outcome TimedOut), with the engine's reason.
|
||||
/// </summary>
|
||||
StoppedWaiting,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -60,18 +67,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
public DateTime TerminatedAtUtc { get; }
|
||||
public string? Details { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The engine's reason code for <see cref="EnrollmentTerminationReason.StoppedWaiting"/>
|
||||
/// (e.g. <c>oobe_update_no_sign_in</c>); null for every other reason.
|
||||
/// </summary>
|
||||
public string? StopReason { get; }
|
||||
|
||||
public EnrollmentTerminatedEventArgs(
|
||||
EnrollmentTerminationReason reason,
|
||||
EnrollmentTerminationOutcome outcome,
|
||||
string? stageName,
|
||||
DateTime terminatedAtUtc,
|
||||
string? details = null)
|
||||
string? details = null,
|
||||
string? stopReason = null)
|
||||
{
|
||||
Reason = reason;
|
||||
Outcome = outcome;
|
||||
StageName = stageName;
|
||||
TerminatedAtUtc = terminatedAtUtc;
|
||||
Details = details;
|
||||
StopReason = stopReason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-4
@@ -15,9 +15,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Hosts the <see cref="ImeRegistryAppStateObserver"/> (registry second pillar, audit
|
||||
/// 2026-08-17): one recursive <see cref="RegistryWatcher"/> on the IME root — all three
|
||||
/// observed surfaces (Win32Apps, EspTrackingWin32Apps, SideCarPolicies\StatusServiceReports)
|
||||
/// live under it — debounced into snapshot-and-diff ticks, plus a 60-s periodic fallback
|
||||
/// 2026-08-17) and the <see cref="ImeRegistryScriptResultObserver"/> (D-316): one recursive
|
||||
/// <see cref="RegistryWatcher"/> on the IME root — every observed surface (Win32Apps,
|
||||
/// EspTrackingWin32Apps, SideCarPolicies\StatusServiceReports, Policies) lives under it —
|
||||
/// debounced into snapshot-and-diff ticks, plus a 60-s periodic fallback
|
||||
/// tick. The fallback both evaluates the reconciliation settle-delay when the registry goes
|
||||
/// quiet AND keeps the pillar alive if the watcher fails to arm (poll-only degraded mode,
|
||||
/// announced once via <c>collector_degraded</c>). Always-on observability host, no config
|
||||
@@ -31,6 +32,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private static readonly TimeSpan PeriodicInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly ImeRegistryAppStateObserver _observer;
|
||||
// Platform-script results under the same root (D-316); null when no run register was supplied.
|
||||
private readonly ImeRegistryScriptResultObserver? _scriptObserver;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger? _logger;
|
||||
private readonly string _sessionId;
|
||||
@@ -47,7 +50,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
AgentLogger? logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Func<IReadOnlyList<AppPackageState>>? trackerStateProbe)
|
||||
Func<IReadOnlyList<AppPackageState>>? trackerStateProbe,
|
||||
PlatformScriptRunRegister? scriptRuns = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -56,6 +60,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_logger = logger;
|
||||
_post = new InformationalEventPost(ingress, clock);
|
||||
_observer = new ImeRegistryAppStateObserver(_post, logger, clock, trackerStateProbe);
|
||||
if (scriptRuns != null) _scriptObserver = new ImeRegistryScriptResultObserver(scriptRuns, _post, logger, clock);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -63,6 +68,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Baseline immediately: pre-existing registry state must be captured silently
|
||||
// BEFORE the first change edge, or stale entries would replay as fresh events.
|
||||
_observer.Tick("baseline");
|
||||
_scriptObserver?.Tick("baseline");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -120,6 +126,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
try
|
||||
{
|
||||
_observer.Tick(reason);
|
||||
_scriptObserver?.Tick(reason);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+183
-23
@@ -1,26 +1,41 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Standalone host for the <see cref="WindowsUpdateTracker"/> — watches
|
||||
/// <c>Microsoft-Windows-WindowsUpdateClient/Operational</c> for quality/cumulative update
|
||||
/// activity during OOBE / ESP and forwards it via the shared single-rail ingress.
|
||||
/// Mirrors <see cref="StallProbeHost"/>: builds its own <see cref="InformationalEventPost"/>
|
||||
/// and delegates Start/Stop to the tracker.
|
||||
/// Hosts the OS-update watchers (D-310): the Windows Update client on the System log
|
||||
/// (install events) and on its Operational channel (scan/download events), plus the CBS
|
||||
/// servicing steps on the Setup log. Routes every configured WU EventID to the channel the
|
||||
/// provider manifest assigns it, runs the blind-spot census across all channels, reports the
|
||||
/// counted (non-OS) activity once on stop, and — unless <c>OobeUpdatePhaseMode</c> is Off —
|
||||
/// feeds Windows/.NET update steps into the decision engine as
|
||||
/// <see cref="DecisionSignalKind.OsUpdateActivity"/>.
|
||||
/// </summary>
|
||||
internal sealed class WindowsUpdateWatcherHost : ICollectorHost
|
||||
{
|
||||
public string Name => "WindowsUpdateTracker";
|
||||
|
||||
private readonly WindowsUpdateTracker _tracker;
|
||||
private readonly WindowsUpdateTracker? _systemTracker;
|
||||
private readonly WindowsUpdateTracker? _operationalTracker;
|
||||
private readonly ServicingTracker? _servicingTracker;
|
||||
private readonly UpdateActivityTally _tally = new UpdateActivityTally();
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly ISignalIngressSink _ingress;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private int _summaryEmitted;
|
||||
private int _disposed;
|
||||
|
||||
public WindowsUpdateWatcherHost(
|
||||
@@ -33,36 +48,125 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int backfillLookbackMinutes,
|
||||
string stateDirectory,
|
||||
bool channelCensusEnabled = true,
|
||||
Func<bool>? osBuildChangedProvider = null)
|
||||
Func<bool>? osBuildChangedProvider = null,
|
||||
int[]? operationalEventIds = null,
|
||||
bool servicingWatcherEnabled = true,
|
||||
bool osUpdateSignalsEnabled = false)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = new InformationalEventPost(ingress, clock, logger);
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_tracker = new WindowsUpdateTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: post,
|
||||
logger: logger,
|
||||
targetedEventIds: targetedEventIds,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
channelCensusEnabled: channelCensusEnabled,
|
||||
osBuildChangedProvider: osBuildChangedProvider);
|
||||
var (systemIds, operationalIds) = RouteEventIds(targetedEventIds, operationalEventIds);
|
||||
|
||||
Action<string, string, DateTime>? wuActivity = null;
|
||||
Action<string, string, DateTime>? servicingActivity = null;
|
||||
if (osUpdateSignalsEnabled)
|
||||
{
|
||||
wuActivity = (step, update, at) => PostOsUpdateActivity(step, "wu", update, at);
|
||||
servicingActivity = (step, update, at) => PostOsUpdateActivity(step, "servicing", update, at);
|
||||
}
|
||||
|
||||
if (systemIds.Length > 0)
|
||||
{
|
||||
_systemTracker = new WindowsUpdateTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: _post,
|
||||
logger: logger,
|
||||
targetedEventIds: systemIds,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
channelCensusEnabled: false,
|
||||
channel: WindowsUpdateTracker.SystemChannel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: wuActivity);
|
||||
}
|
||||
|
||||
if (operationalIds.Length > 0)
|
||||
{
|
||||
_operationalTracker = new WindowsUpdateTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: _post,
|
||||
logger: logger,
|
||||
targetedEventIds: operationalIds,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
channelCensusEnabled: channelCensusEnabled,
|
||||
osBuildChangedProvider: osBuildChangedProvider,
|
||||
channel: WindowsUpdateTracker.Channel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: wuActivity);
|
||||
}
|
||||
|
||||
if (servicingWatcherEnabled)
|
||||
{
|
||||
_servicingTracker = new ServicingTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: _post,
|
||||
logger: logger,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: servicingActivity);
|
||||
}
|
||||
}
|
||||
|
||||
// Test seams — expose the trackers so unit tests can drive their ProcessEvent seams.
|
||||
internal WindowsUpdateTracker? SystemTrackerForTest => _systemTracker;
|
||||
internal WindowsUpdateTracker? OperationalTrackerForTest => _operationalTracker;
|
||||
internal ServicingTracker? ServicingTrackerForTest => _servicingTracker;
|
||||
|
||||
/// <summary>
|
||||
/// Splits the configured WU EventIDs by the channel the provider manifest writes them to.
|
||||
/// An ID configured in either list ends up on its real channel, so a misplaced ID still
|
||||
/// works instead of watching a channel that never carries it.
|
||||
/// </summary>
|
||||
internal static (int[] systemIds, int[] operationalIds) RouteEventIds(int[]? targetedEventIds, int[]? operationalEventIds)
|
||||
{
|
||||
var all = (targetedEventIds ?? Array.Empty<int>())
|
||||
.Concat(operationalEventIds ?? Array.Empty<int>())
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToArray();
|
||||
return (
|
||||
all.Where(WindowsUpdateTracker.IsSystemChannelEventId).ToArray(),
|
||||
all.Where(id => !WindowsUpdateTracker.IsSystemChannelEventId(id)).ToArray());
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_tracker.Start();
|
||||
_systemTracker?.Start();
|
||||
_operationalTracker?.Start();
|
||||
_servicingTracker?.Start();
|
||||
|
||||
// The census asks "did ANY channel see the update?" — only answerable after every
|
||||
// backfill ran.
|
||||
_operationalTracker?.EmitChannelCensusIfBlind(
|
||||
otherChannelsEmitted: _systemTracker?.EmittedThisRun ?? 0,
|
||||
otherTargetedEventIds: _systemTracker?.TargetedEventIds);
|
||||
|
||||
_logger.Info("WindowsUpdateWatcherHost: started.");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try { _tracker.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: stop failed: {ex.Message}"); }
|
||||
try { _systemTracker?.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: system watcher stop failed: {ex.Message}"); }
|
||||
try { _operationalTracker?.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: operational watcher stop failed: {ex.Message}"); }
|
||||
try { _servicingTracker?.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: servicing watcher stop failed: {ex.Message}"); }
|
||||
|
||||
EmitActivitySummaryOnce();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -70,5 +174,61 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One summary of everything that was counted instead of emitted. Stop runs twice on the
|
||||
/// termination path (explicit stop, then dispose), hence the once-guard.
|
||||
/// </summary>
|
||||
internal void EmitActivitySummaryOnce()
|
||||
{
|
||||
if (_tally.IsEmpty) return;
|
||||
if (Interlocked.Exchange(ref _summaryEmitted, 1) == 1) return;
|
||||
|
||||
try
|
||||
{
|
||||
var counts = _tally.Snapshot();
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var kv in counts) data[kv.Key] = kv.Value;
|
||||
var total = counts.Sum(kv => kv.Value);
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = SharedConstants.EventTypes.WindowsUpdateActivitySummary,
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = SharedConstants.EventSources.WindowsUpdateWatcher,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Update activity counted instead of listed: {total} event(s) " +
|
||||
"(Store, Defender and other non-OS updates, repeated scan results, servicing of non-OS packages)",
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdateWatcherHost: activity summary failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void PostOsUpdateActivity(string step, string source, string update, DateTime occurredAtUtc)
|
||||
{
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[OsUpdatePayloadKeys.Step] = step,
|
||||
[OsUpdatePayloadKeys.Source] = source,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(update)) payload[OsUpdatePayloadKeys.Update] = update;
|
||||
|
||||
_ingress.Post(
|
||||
kind: DecisionSignalKind.OsUpdateActivity,
|
||||
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||
sourceOrigin: source == "servicing" ? SharedConstants.EventSources.ServicingWatcher : SharedConstants.EventSources.WindowsUpdateWatcher,
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Raw,
|
||||
identifier: $"os-update-activity:{source}",
|
||||
summary: string.IsNullOrEmpty(update) ? step : $"{step}: {update}"),
|
||||
payload: payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
SharedConstants.EventTypes.ImePatternHits,
|
||||
// Bookkeeping of runs that were deliberately not reported as activity.
|
||||
SharedConstants.EventTypes.ScriptRecurrenceSummary,
|
||||
// A correction of an already reported script run from IME's saved result (D-316):
|
||||
// registry observation after the fact, not progress.
|
||||
SharedConstants.EventTypes.ScriptOutputReconciliation,
|
||||
// Same for the update watchers' tally of Store/Defender/other update events (D-310):
|
||||
// emitted on stop, never progress. Windows/.NET update steps stay activity.
|
||||
SharedConstants.EventTypes.WindowsUpdateActivitySummary,
|
||||
// The OOBE update registry snapshot (D-310) is observation at agent start/stop and at
|
||||
// the update page — the page's own records (oobe_update_page) stay activity.
|
||||
SharedConstants.EventTypes.OobeUpdateState,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1263,6 +1263,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
// logged its result line before the deadline). Lets the UI/MCP flag fallback-grounded
|
||||
// platform-script completions. Platform scripts only — null for health scripts.
|
||||
if (!string.IsNullOrEmpty(script.ResultSource)) data["resultSource"] = script.ResultSource!;
|
||||
// The run a later script_output_reconciliation corrects names it by this id (platform only).
|
||||
if (!string.IsNullOrEmpty(script.RunId)) data["runId"] = script.RunId!;
|
||||
if (!string.IsNullOrEmpty(script.ComplianceResult)) data["complianceResult"] = script.ComplianceResult!;
|
||||
if (script.RemediationStatus.HasValue) data["remediationStatus"] = script.RemediationStatus.Value.ToString(culture);
|
||||
if (script.TargetType.HasValue) data["targetType"] = script.TargetType.Value.ToString(culture);
|
||||
|
||||
+20
-9
@@ -599,6 +599,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
["verifiedBytes"] = health.VerifiedBytes,
|
||||
["passMaxMs"] = health.PassMaxMs,
|
||||
["passGapMaxMs"] = health.PassGapMaxMs,
|
||||
["platformOutputVerified"] = health.PlatformOutputVerified,
|
||||
["platformOutputCorrected"] = health.PlatformOutputCorrected,
|
||||
["agentVersion"] = _agentVersion ?? string.Empty,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(health.ImeAgentVersion))
|
||||
@@ -1227,11 +1229,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
case EnrollmentTerminationReason.MaxLifetimeExceeded:
|
||||
reasonTag = "max_lifetime";
|
||||
break;
|
||||
case EnrollmentTerminationReason.StoppedWaiting:
|
||||
// The backend classifies this shutdown honestly, exactly like max_lifetime.
|
||||
reasonTag = "stopped_waiting";
|
||||
break;
|
||||
default:
|
||||
reasonTag = args.Reason.ToString();
|
||||
break;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "reason", reasonTag },
|
||||
{ "outcome", args.Outcome.ToString() },
|
||||
{ "stage", args.StageName ?? string.Empty },
|
||||
{ "uptimeMinutes", uptimeMinutes },
|
||||
{ "agentVersion", _agentVersion },
|
||||
};
|
||||
if (!string.IsNullOrEmpty(args.StopReason)) data["stopReason"] = args.StopReason;
|
||||
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -1240,15 +1256,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Agent shutting down (reason={reasonTag}, outcome={args.Outcome}).",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "reason", reasonTag },
|
||||
{ "outcome", args.Outcome.ToString() },
|
||||
{ "stage", args.StageName ?? string.Empty },
|
||||
{ "uptimeMinutes", uptimeMinutes },
|
||||
{ "agentVersion", _agentVersion },
|
||||
},
|
||||
Message = args.Reason == EnrollmentTerminationReason.StoppedWaiting && !string.IsNullOrEmpty(args.Details)
|
||||
? $"Agent shutting down (reason={reasonTag}, outcome={args.Outcome}): {args.Details}"
|
||||
: $"Agent shutting down (reason={reasonTag}, outcome={args.Outcome}).",
|
||||
Data = data,
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -526,7 +526,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// analytics. WhiteGlove Part-2 resume runs as a fresh Classic
|
||||
// enrollment after PR-A's archive-and-reset, so the engine state is
|
||||
// empty and needs the facts re-seeded — same as a first boot.
|
||||
LifecycleEmitters.PostEnrollmentFactsObserved(orchestrator.IngressSink, agentConfig.HelloWaitTimeoutSeconds, logger);
|
||||
LifecycleEmitters.PostEnrollmentFactsObserved(
|
||||
orchestrator.IngressSink,
|
||||
agentConfig.HelloWaitTimeoutSeconds,
|
||||
logger,
|
||||
remoteConfig.Collectors?.OobeUpdatePhaseMode);
|
||||
|
||||
// V2 parity — post SessionStarted so the reducer establishes the session
|
||||
// anchor (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped only
|
||||
|
||||
@@ -315,7 +315,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// registry-derived enrollment facts (<c>enrollmentType</c> + <c>isHybridJoin</c>)
|
||||
/// so the reducer can seed <see cref="State.EnrollmentScenarioProfile"/> via the
|
||||
/// stage-agnostic <c>HandleEnrollmentFactsObservedV1</c> handler — plus the tenant's
|
||||
/// Hello wait when it extends the engine's built-in hello_safety window.
|
||||
/// Hello wait when it extends the engine's built-in hello_safety window, and the mode of
|
||||
/// the OOBE update phase (D-310).
|
||||
/// <para>
|
||||
/// Posted immediately before <see cref="PostSessionStarted"/> so the Inspector
|
||||
/// timeline reads naturally (facts → anchor) — but the reducer correctness does
|
||||
@@ -331,7 +332,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
public static void PostEnrollmentFactsObserved(
|
||||
ISignalIngressSink ingressSink,
|
||||
int helloWaitTimeoutSeconds,
|
||||
AgentLogger logger)
|
||||
AgentLogger logger,
|
||||
string oobeUpdatePhaseMode = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -364,6 +366,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
helloWaitSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// OOBE update phase mode (remote config) — stamped at every start, the same value
|
||||
// the update watchers were wired with: the engine lets the phase act only while
|
||||
// every start of the session said Active, so Off or Shadow is a kill switch.
|
||||
var phaseMode = OobeUpdatePhaseModes.Normalize(oobeUpdatePhaseMode);
|
||||
payload[SignalPayloadKeys.OobeUpdatePhaseMode] = phaseMode;
|
||||
|
||||
var evidence = new Evidence(
|
||||
kind: EvidenceKind.Synthetic,
|
||||
identifier: "enrollment_registry_facts_read",
|
||||
@@ -376,7 +384,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
evidence: evidence,
|
||||
payload: payload);
|
||||
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}, helloWaitSeconds={helloWaitSeconds}).");
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}, helloWaitSeconds={helloWaitSeconds}, oobeUpdatePhaseMode={phaseMode}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -117,7 +117,9 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
// Session 4910a5a5 (2026-07-23) added `espAdvisoryFailureResolvedUtc` (the failed
|
||||
// category recovered — lifts the advisory's re-arm/rebase guard exemption) and
|
||||
// `espAdvisoryFailureCategory` (which category the advisory was for) → 20.
|
||||
Assert.Equal(20, facts.Count);
|
||||
// D-310 (2026-10-02) added `stoppedWaitingReason` — the engine stopped waiting for
|
||||
// the user without a verdict (no sign-in after the OOBE update) → 21.
|
||||
Assert.Equal(21, facts.Count);
|
||||
Assert.All(facts.Values, v => Assert.Null(v));
|
||||
}
|
||||
|
||||
@@ -242,9 +244,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
// that vetoes/retracts the policy-disabled Hello skip → 18. Session 4910a5a5
|
||||
// (2026-07-23) added `espAdvisoryFailureResolvedUtc` + `espAdvisoryFailureCategory`
|
||||
// — advisory-recovery facts that lift the AdvisoryCompletion guard exemption → 20.
|
||||
// D-310 (2026-10-02) added `stoppedWaitingReason` — the engine stopped waiting for
|
||||
// the user without a verdict → 21.
|
||||
// If this number ever changes, both the count expectation AND the actual snapshot
|
||||
// output need to evolve in lockstep.
|
||||
Assert.Equal(20, expectedFactKeys.Count);
|
||||
Assert.Equal(21, expectedFactKeys.Count);
|
||||
|
||||
var snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t"));
|
||||
var facts = (Dictionary<string, object?>)snapshot["facts"]!;
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Report 3a2207978e4c (2026-10-01, D-310) — the OOBE quality update. With the ESP setting
|
||||
/// "Install Windows quality updates" Windows installs the update after the Device-ESP page
|
||||
/// closes and before the user signs in: 20–40 minutes plus a restart to the lock screen. The
|
||||
/// IME had logged its AccountSetup phase line before that handoff exit, so the exit armed the
|
||||
/// 30-min <c>advisory_completion</c> window, and the session was failed
|
||||
/// (<c>esp_exit_without_completion_evidence</c>) while the update was still installing.
|
||||
/// <para>
|
||||
/// The OOBE update phase holds the window, every state bounded on its own (user decision
|
||||
/// 2026-10-02): the update runs at most 3 h, then fails as not finished; after the update the
|
||||
/// user has 60 min to sign in, then the engine stops waiting — no verdict, the backend
|
||||
/// classifies; after the sign-in the user's own setup gets one window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only the Active mode acts (measurement first, user decision 2026-10-02). In the Shadow mode
|
||||
/// — the default, and what an unstamped session counts as — the fire reports what the phase
|
||||
/// would decide and today's rule decides; Off evaluates nothing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class OobeUpdatePhaseTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 10, 1, 11, 0, 0, DateTimeKind.Utc);
|
||||
private const string Lcu = "2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)";
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null)
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal OsUpdate(long ordinal, DateTime at, string step, string? update = Lcu)
|
||||
{
|
||||
var payload = new Dictionary<string, string>
|
||||
{
|
||||
[OsUpdatePayloadKeys.Step] = step,
|
||||
[OsUpdatePayloadKeys.Source] = "wu",
|
||||
};
|
||||
if (update != null) payload[OsUpdatePayloadKeys.Update] = update;
|
||||
return MakeSignal(ordinal, DecisionSignalKind.OsUpdateActivity, at, payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal AdvisoryFired(long ordinal, DateTime at) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, at,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.AdvisoryCompletion });
|
||||
|
||||
private static DecisionSignal Reboot(long ordinal, DateTime at) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.SystemRebootObserved, at);
|
||||
|
||||
/// <summary>The agent's mode stamp on <c>EnrollmentFactsObserved</c>, posted at every start.</summary>
|
||||
private static DecisionSignal PhaseMode(long ordinal, DateTime at, string mode) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, at,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.OobeUpdatePhaseMode] = mode });
|
||||
|
||||
private static bool HasTimelineEffect(DecisionStep step, string eventType) =>
|
||||
step.Effects.Any(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == eventType);
|
||||
|
||||
private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
|
||||
state.Deadlines.FirstOrDefault(d => d.Name == name);
|
||||
|
||||
private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) =>
|
||||
step.Effects.Single(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == eventType);
|
||||
|
||||
/// <summary>
|
||||
/// The report's shape up to the arming exit: Classic user-driven, full ESP, DeviceSetup,
|
||||
/// the IME's pre-sign-in AccountSetup line, then the Device-ESP handoff exit at T0+21,
|
||||
/// which arms the window (due T0+51). <paramref name="beforeExit"/> runs before the exit;
|
||||
/// <paramref name="mode"/> is the agent's mode stamp (null: none, an older agent).
|
||||
/// </summary>
|
||||
private static DecisionState HandoffExitArmsTheWindow(
|
||||
DecisionEngine engine,
|
||||
Func<DecisionState, DecisionState>? beforeExit = null,
|
||||
string? mode = OobeUpdatePhaseModes.Active)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-3a220797", "tenant-3a220797", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
if (mode != null) state = engine.Reduce(state, PhaseMode(1, T0, mode)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.SkipUserEsp] = "false",
|
||||
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||
[SignalPayloadKeys.EspAllowContinueAnyway] = "false",
|
||||
})).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(20),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
|
||||
if (beforeExit != null) state = beforeExit(state);
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(30, DecisionSignalKind.EspExiting, T0.AddMinutes(21))).NewState;
|
||||
var armed = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(armed);
|
||||
Assert.Equal(T0.AddMinutes(51), armed!.DueAtUtc);
|
||||
Assert.Null(state.Outcome);
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires the armed advisory deadline at its due time until a fire does not re-arm it;
|
||||
/// returns every step. Proves each re-arm lands on time — the last one on the bound.
|
||||
/// </summary>
|
||||
private static List<DecisionStep> FireUntilResolved(DecisionEngine engine, DecisionState state, long firstOrdinal)
|
||||
{
|
||||
var steps = new List<DecisionStep>();
|
||||
var ordinal = firstOrdinal;
|
||||
while (true)
|
||||
{
|
||||
var deadline = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(deadline);
|
||||
var step = engine.Reduce(state, AdvisoryFired(ordinal++, deadline!.DueAtUtc));
|
||||
steps.Add(step);
|
||||
state = step.NewState;
|
||||
if (FindDeadline(state, DeadlineNames.AdvisoryCompletion) == null) return steps;
|
||||
Assert.True(steps.Count < 50, "the phase never resolved");
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================= handler ====
|
||||
|
||||
[Fact]
|
||||
public void ActivityAfterTheHandoffExit_AnchorsThePhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
var first = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted));
|
||||
Assert.True(first.Transition.Taken);
|
||||
Assert.Equal("OsUpdateActivity:OobeUpdatePhaseStarted", first.Transition.Trigger);
|
||||
Assert.Equal(state.Stage, first.NewState.Stage);
|
||||
Assert.Empty(first.Effects);
|
||||
|
||||
var facts = first.NewState.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(22), facts.PostExitActivityUtc!.Value);
|
||||
Assert.Equal(40, facts.PostExitActivityUtc.SourceSignalOrdinal);
|
||||
Assert.True(DecisionEngine.IsInOobeUpdatePhase(first.NewState));
|
||||
|
||||
var second = engine.Reduce(first.NewState, OsUpdate(41, T0.AddMinutes(35), OsUpdateSteps.InstallStarted));
|
||||
Assert.Equal("OsUpdateActivity", second.Transition.Trigger);
|
||||
facts = second.NewState.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(22), facts.PostExitActivityUtc!.Value);
|
||||
Assert.Equal(T0.AddMinutes(35), facts.LastActivityUtc!.Value);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, facts.LastStep!.Value);
|
||||
Assert.Equal(Lcu, facts.LastUpdate!.Value);
|
||||
Assert.Equal(2, facts.ActivityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebootRequiredAndFailure_AreRecorded()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(30), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(31), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(42, T0.AddMinutes(32), OsUpdateSteps.Failed, update: null)).NewState;
|
||||
|
||||
var facts = state.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(30), facts.RebootRequiredUtc!.Value); // earliest
|
||||
Assert.Equal(T0.AddMinutes(32), facts.FailedUtc!.Value);
|
||||
Assert.Equal("KB5099999", facts.LastUpdate!.Value); // a step without an update keeps the last one
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityWithoutStep_IsADeadEnd()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(40, DecisionSignalKind.OsUpdateActivity, T0.AddMinutes(22),
|
||||
new Dictionary<string, string> { [OsUpdatePayloadKeys.Source] = "wu" }));
|
||||
|
||||
Assert.False(step.Transition.Taken);
|
||||
Assert.Equal("os_update_activity_without_step", step.Transition.DeadEndReason);
|
||||
Assert.Equal(0, step.NewState.OsUpdateFacts.ActivityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityBeforeTheExit_IsNoPhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, beforeExit: s =>
|
||||
engine.Reduce(s, OsUpdate(25, T0.AddMinutes(15), OsUpdateSteps.Installed)).NewState);
|
||||
|
||||
Assert.NotNull(state.OsUpdateFacts.FirstActivityUtc);
|
||||
Assert.Null(state.OsUpdateFacts.PostExitActivityUtc);
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledActivityThatPredatesTheExit_IsNoPhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(18), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
Assert.Null(state.OsUpdateFacts.PostExitActivityUtc);
|
||||
Assert.Equal(1, state.OsUpdateFacts.ActivityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityAfterTheUserSignedIn_IsNoPhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, MakeSignal(35, DecisionSignalKind.HelloWizardStarted, T0.AddMinutes(25))).NewState;
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(30), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
Assert.Null(state.OsUpdateFacts.PostExitActivityUtc);
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
}
|
||||
|
||||
// ============================================================ restart facts ====
|
||||
|
||||
[Fact]
|
||||
public void RestartDuringThePhase_IsRecorded()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
state = engine.Reduce(state, Reboot(50, T0.AddMinutes(53))).NewState;
|
||||
|
||||
Assert.Equal(T0.AddMinutes(53), state.OsUpdateFacts.RestartUtc!.Value);
|
||||
Assert.Equal(50, state.OsUpdateFacts.RestartUtc.SourceSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartBeforeThePhase_OrAfterTheSignIn_IsNotTheUpdatesRestart()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, Reboot(35, T0.AddMinutes(21.5))).NewState; // no anchor yet
|
||||
Assert.Null(state.OsUpdateFacts.RestartUtc);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(45, DecisionSignalKind.HelloWizardStarted, T0.AddMinutes(30))).NewState;
|
||||
state = engine.Reduce(state, Reboot(50, T0.AddMinutes(40))).NewState; // user already there
|
||||
|
||||
Assert.Null(state.OsUpdateFacts.RestartUtc);
|
||||
}
|
||||
|
||||
// ======================================================= advisory window ====
|
||||
|
||||
[Fact]
|
||||
public void Report3a2207978e4c_FireDuringTheUpdate_RearmsInsteadOfFailing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(35), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
var fireAt = T0.AddMinutes(51);
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, fireAt));
|
||||
|
||||
Assert.True(step.Transition.Taken);
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotEqual(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdatePhase", step.Transition.Trigger);
|
||||
|
||||
var rearmed = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.Equal(fireAt.AddMinutes(30), rearmed!.DueAtUtc);
|
||||
var schedule = step.Effects.Single(e => e.Kind == DecisionEffectKind.ScheduleDeadline);
|
||||
Assert.Equal(DeadlineNames.AdvisoryCompletion, schedule.Deadline!.Name);
|
||||
|
||||
var waiting = SingleTimelineEffect(step, "completion_waiting");
|
||||
Assert.Contains("installing an update in OOBE", waiting.Parameters!["message"]);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, waiting.Parameters!["osUpdateLastStep"]);
|
||||
Assert.Equal(Lcu, waiting.Parameters!["osUpdate"]);
|
||||
Assert.Equal(rearmed.DueAtUtc.ToString("o"), waiting.Parameters!["resolutionDeadlineDueAtUtc"]);
|
||||
// The update may run 3 h from its first activity.
|
||||
Assert.Equal(T0.AddMinutes(22).AddHours(3).ToString("o"), waiting.Parameters!["oobeUpdateHoldsUntilUtc"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutUpdateActivity_TheFireStillFails()
|
||||
{
|
||||
// Mutation proof: the guard keys on observed update activity, never on its absence.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityOnlyBeforeTheExit_TheFireStillFails()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, beforeExit: s =>
|
||||
engine.Reduce(s, OsUpdate(25, T0.AddMinutes(15), OsUpdateSteps.Installed)).NewState);
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateStillRunningAtTheCap_FailsAsNotFinished()
|
||||
{
|
||||
// A hanging update: the last step says it still runs, and no restart ever comes.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(30), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
var steps = FireUntilResolved(engine, state, firstOrdinal: 100);
|
||||
|
||||
Assert.All(steps.Take(steps.Count - 1), s =>
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdatePhase", s.Transition.Trigger));
|
||||
var cap = T0.AddMinutes(22).AddHours(3);
|
||||
// The last re-arm lands on the cap, not up to 30 min after it.
|
||||
Assert.Equal(cap, FindDeadline(steps[steps.Count - 2].NewState, DeadlineNames.AdvisoryCompletion)!.DueAtUtc);
|
||||
|
||||
var resolved = steps.Last();
|
||||
Assert.Equal(SessionStage.Failed, resolved.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, resolved.NewState.Outcome);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateNotFinished", resolved.Transition.Trigger);
|
||||
var failed = SingleTimelineEffect(resolved, "enrollment_failed");
|
||||
Assert.Equal("oobe_update_not_finished", failed.Parameters!["reason"]);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, failed.Parameters!["osUpdateLastStep"]);
|
||||
Assert.Equal("3", failed.Parameters!["oobeUpdateCapHours"]);
|
||||
Assert.Null(resolved.NewState.StoppedWaitingReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateWithRestart_WaitsSixtyMinutesForTheSignIn_ThenStopsWaiting()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(40), OsUpdateSteps.InstallStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(42, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
|
||||
var beforeRestart = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateAwaitingSignIn", beforeRestart.Transition.Trigger);
|
||||
state = beforeRestart.NewState;
|
||||
|
||||
// The update page restarts the device; the lock screen waits for the user.
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
Assert.Equal(T0.AddMinutes(83), FindDeadline(state, DeadlineNames.AdvisoryCompletion)!.DueAtUtc); // re-based
|
||||
|
||||
var steps = FireUntilResolved(engine, state, firstOrdinal: 100);
|
||||
|
||||
var waiting = steps.Take(steps.Count - 1).ToList();
|
||||
Assert.All(waiting, s => Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateAwaitingSignIn", s.Transition.Trigger));
|
||||
Assert.Contains("waiting for the user to sign in",
|
||||
SingleTimelineEffect(waiting[0], "completion_waiting").Parameters!["message"]);
|
||||
|
||||
var stopped = steps.Last();
|
||||
Assert.Equal(T0.AddMinutes(53).AddMinutes(60), stopped.Transition.OccurredAtUtc);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateSignInWaitExpired", stopped.Transition.Trigger);
|
||||
Assert.True(stopped.Transition.Taken);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, stopped.NewState.StoppedWaitingReason!.Value);
|
||||
// No verdict: the stage stays, nothing is failed, nothing is armed any more.
|
||||
Assert.Equal(state.Stage, stopped.NewState.Stage);
|
||||
Assert.Null(stopped.NewState.Outcome);
|
||||
Assert.Empty(stopped.NewState.Deadlines);
|
||||
|
||||
var expired = SingleTimelineEffect(stopped, "completion_wait_expired");
|
||||
Assert.Equal("Warning", expired.Parameters!["severity"]);
|
||||
Assert.Equal("true", expired.Parameters!["immediateUpload"]);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, expired.Parameters!["reason"]);
|
||||
Assert.Equal("60", expired.Parameters!["signInWindowMinutes"]);
|
||||
Assert.Equal(T0.AddMinutes(53).ToString("o"), expired.Parameters!["oobeUpdateEndedUtc"]);
|
||||
Assert.Contains("Not a failure", expired.Parameters!["message"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateEndingWithoutRestart_SignInWindowStartsAtItsLastStep()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(40), OsUpdateSteps.Installed)).NewState;
|
||||
|
||||
var steps = FireUntilResolved(engine, state, firstOrdinal: 100);
|
||||
|
||||
var stopped = steps.Last();
|
||||
Assert.Equal(T0.AddMinutes(100), stopped.Transition.OccurredAtUtc);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, stopped.NewState.StoppedWaitingReason!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledOlderStepAfterTheRestart_DoesNotReopenTheUpdate()
|
||||
{
|
||||
// The watchers backfill after the restart: a record written before it arrives late.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(70, T0.AddMinutes(45), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, state.OsUpdateFacts.LastStep!.Value);
|
||||
var phase = DecisionEngine.EvaluateOobeUpdatePhase(state, T0.AddMinutes(83));
|
||||
Assert.Equal(DecisionEngine.OobeUpdatePhaseKind.AwaitingSignIn, phase.Kind);
|
||||
Assert.Equal(T0.AddMinutes(113), phase.BoundUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewerUpdateStepAfterTheRestart_ReopensTheUpdate_BoundedByTheCap()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(70, T0.AddMinutes(60), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var phase = DecisionEngine.EvaluateOobeUpdatePhase(state, T0.AddMinutes(83));
|
||||
Assert.Equal(DecisionEngine.OobeUpdatePhaseKind.Updating, phase.Kind);
|
||||
Assert.Equal(T0.AddMinutes(22).AddHours(3), phase.BoundUtc); // the cap counts from the first activity
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserSignIn_GetsOneFullWindow_ThenTheFireResolvesNormally()
|
||||
{
|
||||
// The transition to AwaitingHello does not cancel the window. Without the grace a fire
|
||||
// right after the sign-in would fail the session inside the Hello wizard.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51))).NewState; // due T0+81
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.HelloWizardStarted, T0.AddMinutes(75))).NewState;
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
|
||||
// 6 minutes after the sign-in — inside the grace, which ends at T0+105.
|
||||
var grace = engine.Reduce(state, AdvisoryFired(70, T0.AddMinutes(81)));
|
||||
Assert.Null(grace.NewState.Outcome);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdatePhaseUserArrived", grace.Transition.Trigger);
|
||||
Assert.Contains("signed in after the OOBE update", SingleTimelineEffect(grace, "completion_waiting").Parameters!["message"]);
|
||||
Assert.Equal(T0.AddMinutes(105), FindDeadline(grace.NewState, DeadlineNames.AdvisoryCompletion)!.DueAtUtc);
|
||||
|
||||
// One full window after the sign-in, still no completion evidence — resolved the normal way.
|
||||
var resolved = engine.Reduce(grace.NewState, AdvisoryFired(80, T0.AddMinutes(105)));
|
||||
Assert.Equal(SessionStage.Failed, resolved.NewState.Stage);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(resolved, "enrollment_failed").Parameters!["reason"]);
|
||||
Assert.Null(resolved.NewState.StoppedWaitingReason);
|
||||
// Active decides itself — no shadow report.
|
||||
Assert.False(HasTimelineEffect(resolved, "oobe_update_phase_shadow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvisoryVariant_IsExempt()
|
||||
{
|
||||
// Mutation proof: an unresolved ESP terminal failure does not un-happen because
|
||||
// Windows installs an update afterwards.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
Assert.True(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
|
||||
var builder = state.ToBuilder();
|
||||
builder.EspAdvisoryFailureRecordedUtc = new SignalFact<DateTime>(T0.AddMinutes(21), 30);
|
||||
state = builder.Build();
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("esp_terminal_failure", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoPhase_WithoutAnAnchor()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
Assert.Equal(DecisionEngine.OobeUpdatePhaseKind.None,
|
||||
DecisionEngine.EvaluateOobeUpdatePhase(state, T0.AddMinutes(51)).Kind);
|
||||
}
|
||||
|
||||
// ================================================= serialization + census ====
|
||||
|
||||
[Fact]
|
||||
public void SnapshotRoundTrip_PreservesTheUpdateFacts_AndTheStop()
|
||||
{
|
||||
// The update ends with a restart; the agent restores the engine state from its
|
||||
// snapshot, so the phase — and a decided stop — must survive it.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
var stopped = FireUntilResolved(engine, state, firstOrdinal: 100).Last().NewState;
|
||||
|
||||
var roundtripped = StateSerializer.Deserialize(StateSerializer.Serialize(stopped));
|
||||
|
||||
var facts = roundtripped.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(22), facts.PostExitActivityUtc!.Value);
|
||||
Assert.Equal(T0.AddMinutes(48), facts.RebootRequiredUtc!.Value);
|
||||
Assert.Equal(T0.AddMinutes(53), facts.RestartUtc!.Value);
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, facts.LastStep!.Value);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, roundtripped.StoppedWaitingReason!.Value);
|
||||
Assert.Equal(OobeUpdatePhaseModes.Active, roundtripped.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacySnapshot_WithoutTheNewFacts_DeserializesToEmpty()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var legacy = JObject.Parse(StateSerializer.Serialize(state));
|
||||
foreach (var prop in legacy.Properties()
|
||||
.Where(p => p.Name.IndexOf("OsUpdateFacts", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| p.Name.IndexOf("StoppedWaitingReason", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
.ToList())
|
||||
{
|
||||
prop.Remove();
|
||||
}
|
||||
|
||||
var roundtripped = StateSerializer.Deserialize(legacy.ToString());
|
||||
Assert.Same(OsUpdateFacts.Empty, roundtripped.OsUpdateFacts);
|
||||
Assert.Null(roundtripped.StoppedWaitingReason);
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(roundtripped));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_SurfacesThePhase_AndTheStop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
Assert.DoesNotContain("oobe_update_phase", DecisionStateSignalCensus.Build(state).SignalsSeen);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.Installed)).NewState;
|
||||
var census = DecisionStateSignalCensus.Build(state);
|
||||
Assert.Contains("oobe_update_phase", census.SignalsSeen);
|
||||
Assert.Equal(T0.AddMinutes(22).ToString("o"), census.SignalTimestamps["oobeUpdatePhase"]);
|
||||
Assert.DoesNotContain("stopped_waiting", census.SignalsSeen);
|
||||
|
||||
var stopped = FireUntilResolved(engine, state, firstOrdinal: 100).Last().NewState;
|
||||
Assert.Contains("stopped_waiting", DecisionStateSignalCensus.Build(stopped).SignalsSeen);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OobeUpdatePhaseModes.Active, "Active")]
|
||||
[InlineData(OobeUpdatePhaseModes.Shadow, "Shadow")]
|
||||
[InlineData(null, "Shadow")] // never stamped
|
||||
public void Census_NamesTheModeThePhaseRanIn(string? mode, string expected)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: mode);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var evidence = (IDictionary<string, object>)DecisionStateSignalCensus.Build(state).SignalEvidence["oobeUpdatePhase"];
|
||||
Assert.Equal(expected, evidence["mode"]);
|
||||
}
|
||||
|
||||
// ======================================================= measurement mode ====
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_ReportsWhatThePhaseWouldDecide_AndTodaysRuleDecides()
|
||||
{
|
||||
// Report 3a2207978e4c in the Shadow mode: the fire mid-update fails the session as
|
||||
// before, and says that the phase would have kept waiting.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(35), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("DeadlineFired:advisory_completion", step.Transition.Trigger);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
|
||||
var shadow = SingleTimelineEffect(step, "oobe_update_phase_shadow");
|
||||
var parameters = shadow.Parameters!;
|
||||
Assert.Equal("Debug", parameters["severity"]);
|
||||
Assert.Equal(OobeUpdatePhaseModes.Shadow, parameters["phaseMode"]);
|
||||
Assert.Equal("update_running", parameters["phaseState"]);
|
||||
Assert.Equal("hold", parameters["wouldDecide"]);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", parameters["decidedReason"]);
|
||||
Assert.Equal(T0.AddMinutes(22).AddHours(3).ToString("o"), parameters["phaseBoundUtc"]);
|
||||
Assert.Equal(T0.AddMinutes(22).ToString("o"), parameters["oobeUpdatePhaseStartedUtc"]);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, parameters["osUpdateLastStep"]);
|
||||
Assert.Equal(Lcu, parameters["osUpdate"]);
|
||||
Assert.Contains("still installing an update", parameters["message"]);
|
||||
// The report precedes the verdict on the timeline.
|
||||
Assert.True(step.Effects.ToList().IndexOf(shadow) < step.Effects.ToList().IndexOf(SingleTimelineEffect(step, "enrollment_failed")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_AfterTheUpdatesRestart_ReportsTheSignInWait()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState; // re-bases the window to T0+83
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(70, T0.AddMinutes(83)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
var parameters = SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!;
|
||||
Assert.Equal("awaiting_sign_in", parameters["phaseState"]);
|
||||
Assert.Equal("hold", parameters["wouldDecide"]);
|
||||
Assert.Equal(T0.AddMinutes(53).ToString("o"), parameters["oobeUpdateEndedUtc"]);
|
||||
Assert.Equal(T0.AddMinutes(113).ToString("o"), parameters["phaseBoundUtc"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_HangingUpdate_ReportsTheNotFinishedFailure()
|
||||
{
|
||||
// A fire after the cap (a shadow session lives this long only when another guard held
|
||||
// it, e.g. enforcement progress) reports the not-finished failure the phase would give.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.InstallStarted)).NewState;
|
||||
var afterCap = T0.AddMinutes(22).AddHours(3).AddMinutes(5);
|
||||
var armed = FindDeadline(state, DeadlineNames.AdvisoryCompletion)!;
|
||||
state = state.ToBuilder()
|
||||
.CancelDeadline(DeadlineNames.AdvisoryCompletion)
|
||||
.AddDeadline(new ActiveDeadline(armed.Name, afterCap, armed.FiresSignalKind, armed.FiresPayload))
|
||||
.Build();
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, afterCap));
|
||||
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
var parameters = SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!;
|
||||
Assert.Equal("update_not_finished", parameters["phaseState"]);
|
||||
Assert.Equal("fail_oobe_update_not_finished", parameters["wouldDecide"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnstampedSession_CountsAsShadow()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: null);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("update_running", SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!["phaseState"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OffMode_EvaluatesNothing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Off);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.False(HasTimelineEffect(step, "oobe_update_phase_shadow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_WithoutPhase_ReportsNothing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.False(HasTimelineEffect(step, "oobe_update_phase_shadow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeStamp_ActiveCanOnlyBeTakenAway_NeverGranted()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var initial = engine.Reduce(DecisionState.CreateInitial("s", "t", T0), MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
// A later agent start in Shadow or Off takes Active away (kill switch) ...
|
||||
var state = engine.Reduce(initial, PhaseMode(1, T0, "active")).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Active, state.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
state = engine.Reduce(state, PhaseMode(2, T0.AddMinutes(60), OobeUpdatePhaseModes.Active)).NewState;
|
||||
Assert.Equal(1, state.ScenarioObservations.OobeUpdatePhaseMode!.SourceSignalOrdinal);
|
||||
state = engine.Reduce(state, PhaseMode(3, T0.AddMinutes(90), OobeUpdatePhaseModes.Off)).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Off, state.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
Assert.Equal(3, state.ScenarioObservations.OobeUpdatePhaseMode.SourceSignalOrdinal);
|
||||
|
||||
// ... and switching it on mid-session applies from the next session only.
|
||||
state = engine.Reduce(state, PhaseMode(4, T0.AddMinutes(120), OobeUpdatePhaseModes.Active)).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Off, state.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
|
||||
var shadow = engine.Reduce(initial, PhaseMode(1, T0, "unknown-mode")).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Shadow, shadow.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
shadow = engine.Reduce(shadow, PhaseMode(2, T0.AddMinutes(60), OobeUpdatePhaseModes.Active)).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Shadow, shadow.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveTakenAwayAfterTheRestart_TheFireFallsBackToTodaysRule()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine); // Active
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
// The agent comes back after the update's restart with the phase switched back to Shadow.
|
||||
state = engine.Reduce(state, PhaseMode(61, T0.AddMinutes(54), OobeUpdatePhaseModes.Shadow)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(70, T0.AddMinutes(83)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("awaiting_sign_in", SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!["phaseState"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -90,6 +90,11 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
lastClassifierVerdictId: "verdict-" + seed));
|
||||
}
|
||||
if (type == typeof(RealmJoinFacts)) return RealmJoinFacts.Empty.WithDetected(BaseUtc.AddMinutes(seed), ordinal);
|
||||
if (type == typeof(OsUpdateFacts))
|
||||
{
|
||||
return OsUpdateFacts.Empty.WithActivity(
|
||||
BaseUtc.AddMinutes(seed), ordinal, OsUpdateSteps.RebootRequired, "KB-" + seed, isPostExit: true);
|
||||
}
|
||||
|
||||
throw new Xunit.Sdk.XunitException(
|
||||
$"No value factory for builder property type {type}. A new DecisionState field " +
|
||||
|
||||
@@ -455,6 +455,25 @@ public class AuthFunctionTests
|
||||
Assert.Equal(Constants.TenantRoles.Operator, (string)body.role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoAdmin_NotTriggered_ForClaimDerivedViewer_WhenNoTableMembers()
|
||||
{
|
||||
// Same guard for the read-only claim role: an Entra app-role Viewer in a claim-only tenant
|
||||
// stays a Viewer and is never written into the table as Admin.
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: ViewerRole(), // came from the "roles" claim
|
||||
mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: false,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.NeedsAutoAdmin);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.False((bool)body.isTenantAdmin);
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, (string)body.role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoAdmin_NotTriggered_WhenUserAlreadyAdmin()
|
||||
{
|
||||
|
||||
@@ -422,6 +422,31 @@ public class EnrollmentTimeoutClassifierTests
|
||||
Assert.Equal(ClassifierRules.R1bWhiteGloveAwaiting, wgRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_engine_stopped_waiting_after_the_oobe_update_is_never_failed()
|
||||
{
|
||||
// D-310: nobody signed in within the sign-in window after the OOBE update, the engine
|
||||
// stopped waiting and the agent ended the session. Same honest classification as the
|
||||
// watchdog — user-driven terminalizes as Incomplete without naming the watchdog, a
|
||||
// WhiteGlove Part 2 stays AwaitingUser.
|
||||
var stopped = Evt("agent_shutting_down", data: new Dictionary<string, object>
|
||||
{
|
||||
["reason"] = "stopped_waiting",
|
||||
["stopReason"] = "oobe_update_no_sign_in",
|
||||
});
|
||||
|
||||
var (status, reason) = Classify(new[] { Esp(DeviceSetup44), stopped }, hoursSinceStart: 2);
|
||||
Assert.Equal(SessionStatus.Incomplete, status);
|
||||
Assert.Contains("stopped waiting for the user", reason);
|
||||
Assert.DoesNotContain("max-lifetime", reason);
|
||||
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), stopped });
|
||||
var (wgStatus, _, wgRule) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, Start.AddHours(2), 72, isPreProvisioned: true, resumedAt: Start.AddHours(1));
|
||||
Assert.Equal(SessionStatus.AwaitingUser, wgStatus);
|
||||
Assert.Equal(ClassifierRules.R1bWhiteGloveAwaiting, wgRule);
|
||||
}
|
||||
|
||||
// -------- Rule 5a: completed (assumed) — calibration read 2026-08-27 --------
|
||||
|
||||
[Fact]
|
||||
@@ -429,12 +454,21 @@ public class EnrollmentTimeoutClassifierTests
|
||||
{
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("app_install_failed") }).HasAppInstallFailure);
|
||||
var timeout = Evt("enrollment_failed", data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).HasAgentMaxLifetimeTimeout);
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).AgentStoppedForGood);
|
||||
var shutdown = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "max_lifetime" });
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).HasAgentMaxLifetimeTimeout);
|
||||
// Other shutdown reasons say nothing about max lifetime.
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).AgentStoppedForGood);
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).AgentStoppedWaiting);
|
||||
// D-310: the engine's bounded wait expired — the agent is gone just the same.
|
||||
var stopped = Evt("agent_shutting_down", data: new Dictionary<string, object>
|
||||
{
|
||||
["reason"] = "stopped_waiting",
|
||||
["stopReason"] = "oobe_update_no_sign_in",
|
||||
});
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { stopped }).AgentStoppedForGood);
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { stopped }).AgentStoppedWaiting);
|
||||
// Other shutdown reasons say nothing about the agent being gone for good.
|
||||
var ctrlC = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "ctrl_c" });
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { ctrlC }).HasAgentMaxLifetimeTimeout);
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { ctrlC }).AgentStoppedForGood);
|
||||
// A real failure event does not raise the app flag.
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_failed") }).HasAppInstallFailure);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,37 @@ public class EntraAppRoleResolverTests
|
||||
[Fact]
|
||||
public void MapClaimRole_UnknownRole_ReturnsNull()
|
||||
{
|
||||
Assert.Null(EntraAppRoleResolver.MapClaimRole(new[] { "Viewer", "SomethingElse" }));
|
||||
// Neither a foreign value nor an application permission ever becomes a member role.
|
||||
Assert.Null(EntraAppRoleResolver.MapClaimRole(new[] { "SomethingElse", "access_as_application", "GlobalAdmin" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapClaimRole_Viewer_IsReadOnlyWithoutBootstrapPermission()
|
||||
{
|
||||
var role = EntraAppRoleResolver.MapClaimRole(new[] { "SomethingElse", Constants.TenantRoles.Viewer });
|
||||
|
||||
Assert.NotNull(role);
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, role!.Role);
|
||||
Assert.False(role.CanManageBootstrapTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapClaimRole_OperatorOutranksViewer_WhenBothPresent()
|
||||
{
|
||||
var role = EntraAppRoleResolver.MapClaimRole(
|
||||
new[] { Constants.TenantRoles.Viewer, Constants.TenantRoles.Operator });
|
||||
|
||||
Assert.Equal(Constants.TenantRoles.Operator, role!.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapClaimRole_AdminOutranksViewer_WhenBothPresent()
|
||||
{
|
||||
var role = EntraAppRoleResolver.MapClaimRole(
|
||||
new[] { Constants.TenantRoles.Viewer, Constants.TenantRoles.Admin });
|
||||
|
||||
Assert.Equal(Constants.TenantRoles.Admin, role!.Role);
|
||||
Assert.True(role.CanManageBootstrapTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -63,6 +93,7 @@ public class EntraAppRoleResolverTests
|
||||
var role = EntraAppRoleResolver.MapClaimRole(new[] { "admin" });
|
||||
|
||||
Assert.Equal(Constants.TenantRoles.Admin, role!.Role);
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, EntraAppRoleResolver.MapClaimRole(new[] { " viewer " })!.Role);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -106,14 +137,43 @@ public class EntraAppRoleResolverTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoTableRow_IgnoresClaim_WhenTenantFlagDisabled()
|
||||
public void Resolve_NoTableRow_FallsBackToViewerClaim_WhenEnabled()
|
||||
{
|
||||
var result = EntraAppRoleResolver.Resolve(
|
||||
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: false);
|
||||
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Viewer }, appRolesEnabled: true);
|
||||
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, result!.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_DisabledTableRow_IgnoresViewerClaim()
|
||||
{
|
||||
var result = EntraAppRoleResolver.Resolve(
|
||||
TableMemberState.Disabled, null, new[] { Constants.TenantRoles.Viewer }, appRolesEnabled: true);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_EnabledTableViewer_WinsOverHigherClaim()
|
||||
{
|
||||
var tableRole = new MemberRoleInfo { Role = Constants.TenantRoles.Viewer };
|
||||
|
||||
var result = EntraAppRoleResolver.Resolve(
|
||||
TableMemberState.Enabled, tableRole, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: true);
|
||||
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, result!.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoTableRow_IgnoresClaim_WhenTenantFlagDisabled()
|
||||
{
|
||||
Assert.Null(EntraAppRoleResolver.Resolve(
|
||||
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: false));
|
||||
Assert.Null(EntraAppRoleResolver.Resolve(
|
||||
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Viewer }, appRolesEnabled: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoTableRow_NoClaim_ReturnsNull()
|
||||
{
|
||||
|
||||
@@ -79,6 +79,37 @@ public class IngestMaxLifetimeShutdownTests
|
||||
Assert.False(EventIngestProcessor.IsMaxLifetimeAgentShutdown(null));
|
||||
}
|
||||
|
||||
// -------- stopped_waiting (D-310) --------
|
||||
// The engine stopped waiting for the user (no sign-in after the OOBE update) and the agent
|
||||
// ended the session like its watchdog: same contract, classified through the same path.
|
||||
|
||||
[Fact]
|
||||
public void StoppedWaitingReason_IsDetected_AsGaveUp_ButNotAsMaxLifetime()
|
||||
{
|
||||
var stopped = Shutdown("stopped_waiting");
|
||||
|
||||
Assert.True(EventIngestProcessor.IsStoppedWaitingAgentShutdown(stopped));
|
||||
Assert.True(EventIngestProcessor.IsAgentGaveUpShutdown(stopped));
|
||||
Assert.False(EventIngestProcessor.IsMaxLifetimeAgentShutdown(stopped));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxLifetimeReason_IsAlsoGaveUp()
|
||||
{
|
||||
Assert.True(EventIngestProcessor.IsAgentGaveUpShutdown(Shutdown("max_lifetime")));
|
||||
Assert.False(EventIngestProcessor.IsStoppedWaitingAgentShutdown(Shutdown("max_lifetime")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("decision_terminal")]
|
||||
[InlineData("ctrl_c")]
|
||||
[InlineData(null)]
|
||||
public void OtherShutdowns_AreNotGaveUp(string? reason)
|
||||
{
|
||||
Assert.False(EventIngestProcessor.IsAgentGaveUpShutdown(Shutdown(reason)));
|
||||
Assert.False(EventIngestProcessor.IsAgentGaveUpShutdown(null));
|
||||
}
|
||||
|
||||
// -------- IsAgentTimeoutFailure (misclassification audit 2026-07-16) --------
|
||||
// The V1-parity companion: enrollment_failed(failureType=agent_timeout) emitted by
|
||||
// LifecycleEmitters.CreateMaxLifetimeEmitter. Same semantics as the shutdown shape —
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The versioned platform-stats file is served immutable for a year, so every publish must write
|
||||
/// a new name. Maintenance publishes every 2 hours; the per-day name it used to write was rewritten
|
||||
/// up to 12 times a day, and caches kept the first copy of the day.
|
||||
/// </summary>
|
||||
public class PlatformStatsPublishTests
|
||||
{
|
||||
private static readonly DateTime Publish = new(2026, 10, 3, 6, 0, 4, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Versioned_file_name_carries_the_publish_instant_to_the_second()
|
||||
{
|
||||
Assert.Equal("platform-stats.2026-10-03T060004Z.json", MaintenanceService.PlatformStatsVersionedFileName(Publish));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_publishes_of_one_day_get_different_names()
|
||||
{
|
||||
var early = MaintenanceService.PlatformStatsVersionedFileName(new DateTime(2026, 10, 3, 2, 0, 3, DateTimeKind.Utc));
|
||||
var later = MaintenanceService.PlatformStatsVersionedFileName(new DateTime(2026, 10, 3, 4, 0, 3, DateTimeKind.Utc));
|
||||
|
||||
Assert.NotEqual(early, later);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Versioned_file_name_ignores_the_current_culture()
|
||||
{
|
||||
var previous = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
// th-TH counts years in the Buddhist era (2569 for 2026).
|
||||
CultureInfo.CurrentCulture = new CultureInfo("th-TH");
|
||||
Assert.Equal("platform-stats.2026-10-03T060004Z.json", MaintenanceService.PlatformStatsVersionedFileName(Publish));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Progress;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Progress Portal's tenant status: when an organization reads as unused (no session, signed up
|
||||
/// longer than <see cref="ProgressTenantStatusFunction.UnusedAfter"/> ago), and that a failed read never
|
||||
/// does.
|
||||
/// </summary>
|
||||
public class ProgressTenantStatusFunctionTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly DateTime Now = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TenantConfiguration Config(DateTime? onboardedAt)
|
||||
=> new() { TenantId = TenantId, OnboardedAt = onboardedAt };
|
||||
|
||||
// ── Policy catalog ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Route_is_AuthenticatedUser_scoped_to_the_JWT_tenant()
|
||||
{
|
||||
// Members without a role are the audience, so the tier must not require membership; the route
|
||||
// takes no tenant parameter, so no caller can ask about another organization.
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "progress/tenant-status");
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(EndpointPolicy.AuthenticatedUser, entry!.Policy);
|
||||
Assert.Equal(TenantScoping.Jwt, entry.TenantScoping);
|
||||
}
|
||||
|
||||
// ── Evaluate ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void No_session_and_signed_up_a_month_ago_is_unused()
|
||||
{
|
||||
var signedUp = Now.AddDays(-30);
|
||||
|
||||
var status = ProgressTenantStatusFunction.Evaluate(Config(signedUp), hasAnySession: false, Now);
|
||||
|
||||
Assert.True(status.Success);
|
||||
Assert.True(status.Unused);
|
||||
Assert.Equal(signedUp, status.SignedUpAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Any_session_means_in_use()
|
||||
{
|
||||
var status = ProgressTenantStatusFunction.Evaluate(Config(Now.AddDays(-300)), hasAnySession: true, Now);
|
||||
|
||||
Assert.False(status.Unused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_organization_still_being_set_up_is_not_unused()
|
||||
{
|
||||
var status = ProgressTenantStatusFunction.Evaluate(Config(Now.AddDays(-13)), hasAnySession: false, Now);
|
||||
|
||||
Assert.False(status.Unused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_closes_after_exactly_fourteen_days()
|
||||
{
|
||||
var status = ProgressTenantStatusFunction.Evaluate(
|
||||
Config(Now - ProgressTenantStatusFunction.UnusedAfter), hasAnySession: false, Now);
|
||||
|
||||
Assert.True(status.Unused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_row_without_a_signup_date_is_old_and_reports_no_date()
|
||||
{
|
||||
var status = ProgressTenantStatusFunction.Evaluate(Config(onboardedAt: null), hasAnySession: false, Now);
|
||||
|
||||
Assert.True(status.Unused);
|
||||
Assert.Null(status.SignedUpAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_a_configuration_row_nothing_is_unused()
|
||||
{
|
||||
var status = ProgressTenantStatusFunction.Evaluate(config: null, hasAnySession: false, Now);
|
||||
|
||||
Assert.True(status.Success);
|
||||
Assert.False(status.Unused);
|
||||
Assert.Null(status.SignedUpAt);
|
||||
}
|
||||
|
||||
// ── HTTP ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static (ProgressTenantStatusFunction Sut, Mock<ISessionRepository> Sessions) Build(TenantConfiguration? row)
|
||||
{
|
||||
var configService = new Mock<TenantConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), Mock.Of<ILogger<TenantConfigurationService>>(), Mock.Of<IMemoryCache>())
|
||||
{ CallBase = false };
|
||||
configService.Setup(s => s.GetConfigurationIfExistsAsync(TenantId)).ReturnsAsync(row);
|
||||
|
||||
var sessions = new Mock<ISessionRepository>(MockBehavior.Strict);
|
||||
var sut = new ProgressTenantStatusFunction(
|
||||
Mock.Of<ILogger<ProgressTenantStatusFunction>>(), configService.Object, sessions.Object);
|
||||
return (sut, sessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Answers_for_the_callers_own_tenant()
|
||||
{
|
||||
var (sut, sessions) = Build(Config(DateTime.UtcNow.AddDays(-60)));
|
||||
sessions.Setup(s => s.HasAnySessionStrictAsync(TenantId)).ReturnsAsync(false);
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.GetTenantStatus(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
Assert.True(doc.RootElement.GetProperty("unused").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_session_read_is_an_error_never_unused()
|
||||
{
|
||||
var (sut, sessions) = Build(Config(DateTime.UtcNow.AddDays(-60)));
|
||||
sessions.Setup(s => s.HasAnySessionStrictAsync(TenantId))
|
||||
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.GetTenantStatus(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -137,4 +137,27 @@ public class ProgressWireParityTests
|
||||
Events = events
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/progress/tenant-status — typed from the start; the anonymous side is the literal the
|
||||
// portal reads.
|
||||
[Fact]
|
||||
public void ProgressTenantStatus_unused_with_signup_date()
|
||||
{
|
||||
var signedUpAt = new DateTime(2026, 3, 12, 8, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, unused = true, signedUpAt = signedUpAt },
|
||||
new ProgressTenantStatusResponse { Success = true, Unused = true, SignedUpAt = signedUpAt });
|
||||
}
|
||||
|
||||
// An unknown signup date drops the key on both sides (WhenWritingNull).
|
||||
[Fact]
|
||||
public void ProgressTenantStatus_without_signup_date_omits_the_key()
|
||||
{
|
||||
DateTime? signedUpAt = null;
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, unused = false, signedUpAt = signedUpAt },
|
||||
new ProgressTenantStatusResponse { Success = true, Unused = false, SignedUpAt = signedUpAt });
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// D-316: the agent corrects an already reported platform run from IME's saved result with a
|
||||
/// script_output_reconciliation. It is observation of an earlier run: it never heals a Stalled session and never
|
||||
/// counts as another script run.
|
||||
/// </summary>
|
||||
public class ScriptOutputReconciliationClassificationTests
|
||||
{
|
||||
private static readonly DateTime At = new(2026, 10, 3, 20, 59, 8, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void A_correction_alone_is_no_real_activity_and_no_script_run()
|
||||
{
|
||||
var classification = EventIngestProcessor.ClassifyEvents(new List<EnrollmentEvent>
|
||||
{
|
||||
Event(Constants.EventTypes.ScriptOutputReconciliation, new Dictionary<string, object> { ["scriptType"] = "platform", ["runId"] = "run-1", ["outcome"] = "foreign" }),
|
||||
});
|
||||
|
||||
Assert.False(classification.HasNonPeriodicRealEvent);
|
||||
Assert.Equal(0, classification.PlatformScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_corrected_run_counts_once_as_a_platform_script()
|
||||
{
|
||||
var classification = EventIngestProcessor.ClassifyEvents(new List<EnrollmentEvent>
|
||||
{
|
||||
Event("script_failed", new Dictionary<string, object> { ["scriptType"] = "platform", ["runId"] = "run-1", ["exitCode"] = "1" }),
|
||||
Event(Constants.EventTypes.ScriptOutputReconciliation, new Dictionary<string, object> { ["scriptType"] = "platform", ["runId"] = "run-1", ["outcome"] = "foreign" }),
|
||||
});
|
||||
|
||||
Assert.True(classification.HasNonPeriodicRealEvent);
|
||||
Assert.Equal(1, classification.PlatformScriptCount);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent Event(string eventType, Dictionary<string, object> data) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
Timestamp = At,
|
||||
EventType = eventType,
|
||||
Source = "RegistryScriptResult",
|
||||
Data = data,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Backfilled environment observations never set the session start (D-315). Since agent 2.0.1473
|
||||
/// the update and servicing watchers deliver rows with their original event time up to 60 min
|
||||
/// before the agent started; anchoring on them stretched StartedAt by 22 min at the median
|
||||
/// (p90 57 min). Source strings are the persisted wire values, spelled out on purpose.
|
||||
/// </summary>
|
||||
public class SessionAnchorSourceTests
|
||||
{
|
||||
private static readonly DateTime AgentStart = new(2026, 10, 3, 9, 6, 46, DateTimeKind.Utc);
|
||||
|
||||
[Theory]
|
||||
[InlineData("SystemTimelineWatcher")]
|
||||
[InlineData("WindowsUpdateWatcher")]
|
||||
[InlineData("ServicingWatcher")]
|
||||
public void Backfill_sources_are_not_anchor_eligible(string source)
|
||||
{
|
||||
Assert.False(Constants.EventSources.IsSessionAnchorEligible(source));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ImeLogTracker")]
|
||||
[InlineData("Agent")]
|
||||
[InlineData("MdmRebootPolicyWatcher")]
|
||||
[InlineData("windowsupdatewatcher")] // ordinal: a differently cased source is not the watcher
|
||||
[InlineData(null)] // legacy row without a source
|
||||
public void Activity_sources_are_anchor_eligible(string? source)
|
||||
{
|
||||
Assert.True(Constants.EventSources.IsSessionAnchorEligible(source));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_batch_anchors_on_the_first_activity_not_on_backfilled_update_rows()
|
||||
{
|
||||
// Shape of session 0f1d1f58: update scans and CBS servicing backfilled from before the
|
||||
// IME replay, a clock step even earlier, then IME replay, agent start, a live update row.
|
||||
var firstImeLine = AgentStart.AddMinutes(-10);
|
||||
var liveUpdate = AgentStart.AddMinutes(25);
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Event(AgentStart.AddMinutes(-16), "system_clock_changed", "SystemTimelineWatcher"),
|
||||
Event(AgentStart.AddMinutes(-15), "windows_update_started", "WindowsUpdateWatcher"),
|
||||
Event(AgentStart.AddMinutes(-14), "windows_update_servicing", "ServicingWatcher"),
|
||||
Event(firstImeLine, "ime_agent_version", "ImeLogTracker"),
|
||||
Event(AgentStart, "agent_started", "Agent"),
|
||||
Event(liveUpdate, "windows_update_started", "WindowsUpdateWatcher"),
|
||||
};
|
||||
|
||||
var classification = EventIngestProcessor.ClassifyEvents(events);
|
||||
|
||||
Assert.Equal(firstImeLine, classification.EarliestEventTimestamp);
|
||||
// The latest timestamp still counts every row: a live update row is a real agent report.
|
||||
Assert.Equal(liveUpdate, classification.LatestEventTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_batch_of_only_backfilled_rows_carries_no_anchor()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Event(AgentStart.AddMinutes(-50), "windows_update_started", "WindowsUpdateWatcher"),
|
||||
Event(AgentStart.AddMinutes(-40), "windows_update_servicing", "ServicingWatcher"),
|
||||
};
|
||||
|
||||
var classification = EventIngestProcessor.ClassifyEvents(events);
|
||||
|
||||
Assert.Null(classification.EarliestEventTimestamp);
|
||||
Assert.Equal(AgentStart.AddMinutes(-40), classification.LatestEventTimestamp);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent Event(DateTime timestamp, string eventType, string source) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
Timestamp = timestamp,
|
||||
EventType = eventType,
|
||||
Source = source,
|
||||
};
|
||||
}
|
||||
+28
-1
@@ -104,10 +104,14 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
// =========================== system-timeline rows are not session anchors ====
|
||||
|
||||
[Fact]
|
||||
public void IsSessionAnchorEligible_excludes_system_timeline_watcher_rows()
|
||||
public void IsSessionAnchorEligible_excludes_backfilled_environment_rows()
|
||||
{
|
||||
Assert.False(TableStorageService.IsSessionAnchorEligible(
|
||||
new TableEntity("p", "r") { ["Source"] = "SystemTimelineWatcher" }));
|
||||
Assert.False(TableStorageService.IsSessionAnchorEligible(
|
||||
new TableEntity("p", "r") { ["Source"] = "WindowsUpdateWatcher" }));
|
||||
Assert.False(TableStorageService.IsSessionAnchorEligible(
|
||||
new TableEntity("p", "r") { ["Source"] = "ServicingWatcher" }));
|
||||
Assert.True(TableStorageService.IsSessionAnchorEligible(
|
||||
new TableEntity("p", "r") { ["Source"] = "ImeLogTracker" }));
|
||||
Assert.True(TableStorageService.IsSessionAnchorEligible(
|
||||
@@ -140,6 +144,29 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
Assert.Equal((int)(lastEvent - firstActivity).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_duration_skips_backfilled_update_rows_and_anchors_on_activity()
|
||||
{
|
||||
// D-315: update scans and CBS servicing backfilled with their event time up to 60 min
|
||||
// before the agent started were the partition's earliest rows and re-anchored StartedAt.
|
||||
var firstActivity = StartedAt.AddMinutes(-5);
|
||||
var lastEvent = StartedAt.AddMinutes(70);
|
||||
var harness = new Harness(SessionRow(), eventRows: new[]
|
||||
{
|
||||
EventRow(StartedAt.AddMinutes(-55), sequence: 27, source: "WindowsUpdateWatcher"),
|
||||
EventRow(StartedAt.AddMinutes(-50), sequence: 34, source: "ServicingWatcher"),
|
||||
EventRow(firstActivity, sequence: 2, source: "ImeLogTracker"),
|
||||
});
|
||||
|
||||
var ok = await harness.Sut.UpdateSessionStatusAsync(
|
||||
TenantId, SessionId, SessionStatus.Pending, VerdictPaths.AgentWhiteGlovePending,
|
||||
latestEventTimestamp: lastEvent);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.Equal((int)(lastEvent - firstActivity).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
}
|
||||
|
||||
private static TableEntity EventRow(DateTime occurredUtc, int sequence, string source) =>
|
||||
new($"{TenantId}_{SessionId}", $"{occurredUtc:yyyyMMddHHmmssfff}_{sequence:D10}")
|
||||
{
|
||||
|
||||
@@ -90,6 +90,8 @@ public sealed class SharedManifestParityTests
|
||||
["annotationVerdicts"] = AnnotationVerdicts.All,
|
||||
// Install channels of the app rows (AppInstallSummary.Source and the per-app DTOs).
|
||||
["appInstallSources"] = AppInstallSources.All,
|
||||
// Event sources whose rows never set a session's start; the portal's session duration applies them.
|
||||
["sessionAnchorIneligibleSources"] = Constants.EventSources.SessionAnchorIneligible,
|
||||
// Community rule submissions: kinds, effective statuses (incl. the derived "published"),
|
||||
// attribution modes and reviewer decisions — the MCP tool schemas enumerate these.
|
||||
["ruleSubmissionKinds"] = RuleSubmissionKinds.All,
|
||||
|
||||
@@ -87,4 +87,15 @@ public class TenantMemberRoleResolverTests
|
||||
|
||||
Assert.Equal(Constants.TenantRoles.Operator, role?.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoRow_ViewerClaim_IsAReadOnlyMember_WhenTheTenantOptedIn()
|
||||
{
|
||||
AppRolesEnabled();
|
||||
|
||||
var role = await _sut.ResolveAsync(Tenant, Upn, new[] { Constants.TenantRoles.Viewer });
|
||||
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, role?.Role);
|
||||
Assert.False(role!.CanManageBootstrapTokens);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Golden fixtures for the OOBE quality update in the time attribution (AttributionVersion 4,
|
||||
/// D-314): <c>os_update</c> from the update page start whose visit holds update evidence to the
|
||||
/// last update evidence before the user is back, then — after a restart — <c>awaiting_sign_in</c>
|
||||
/// until the user is back. Sequences follow the field sessions of 2026-10-02 (synthetic times, no
|
||||
/// customer data). EVERY fixture asserts the exact-partition invariant.
|
||||
/// </summary>
|
||||
public class TimeAttributionOsUpdateTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 10, 2, 14, 0, 0, DateTimeKind.Utc);
|
||||
private const string Lcu = "Package_for_RollupFix~31bf3856ad364e35~amd64~~26100.6899.1.7";
|
||||
private const string LcuKb = "Package_for_KB5129195~31bf3856ad364e35~amd64~~26100.6899.1.7";
|
||||
|
||||
private static long _seq;
|
||||
|
||||
private static EnrollmentEvent Evt(
|
||||
DateTime ts, string eventType,
|
||||
EnrollmentPhase phase = EnrollmentPhase.Unknown,
|
||||
Dictionary<string, object>? data = null)
|
||||
=> new()
|
||||
{
|
||||
EventType = eventType,
|
||||
Timestamp = ts,
|
||||
Sequence = ++_seq,
|
||||
Phase = phase,
|
||||
Data = data ?? new Dictionary<string, object>(),
|
||||
};
|
||||
|
||||
private static EnrollmentEvent PageStart(DateTime ts, string page) =>
|
||||
Evt(ts, "oobe_update_page", data: new() { ["cxhEvent"] = "page_started", ["page"] = page });
|
||||
|
||||
private static EnrollmentEvent PageStop(DateTime ts, string page, string result) =>
|
||||
Evt(ts, "oobe_update_page", data: new() { ["cxhEvent"] = "page_stopped", ["page"] = page, ["result"] = result });
|
||||
|
||||
private static EnrollmentEvent PageName(DateTime ts, string name, string? value = null)
|
||||
{
|
||||
var data = new Dictionary<string, object> { ["cxhEvent"] = "event_name", ["name"] = name };
|
||||
if (value != null) data["value"] = value;
|
||||
return Evt(ts, "oobe_update_page", data: data);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent Servicing(DateTime ts, string step, string package = LcuKb, string targetState = "Installed") =>
|
||||
Evt(ts, "windows_update_servicing", data: new()
|
||||
{
|
||||
["step"] = step,
|
||||
["package"] = package,
|
||||
["client"] = "UpdateAgentLCU",
|
||||
["targetState"] = targetState,
|
||||
});
|
||||
|
||||
/// <summary>A restart: the agent's next run detects it; the gap runs from the last event before the boot to the first after.</summary>
|
||||
private static EnrollmentEvent RebootDetected(DateTime detectedAt, DateTime lastBoot) =>
|
||||
Evt(detectedAt, "system_reboot_detected", data: new() { ["lastBootUtc"] = lastBoot.ToString("o") });
|
||||
|
||||
private static TimeAttributionInput Input(
|
||||
List<EnrollmentEvent> events, DateTime completedAt, int durationSeconds,
|
||||
string status = "Succeeded", bool wg = false, DateTime? resumedAt = null)
|
||||
=> new()
|
||||
{
|
||||
TenantId = "00000000-0000-0000-0000-0000000000t1",
|
||||
SessionId = "00000000-0000-0000-0000-0000000000s1",
|
||||
Status = status,
|
||||
StartedAt = T0,
|
||||
CompletedAt = completedAt,
|
||||
DurationSeconds = durationSeconds,
|
||||
IsPreProvisioned = wg,
|
||||
ResumedAt = resumedAt,
|
||||
Events = events,
|
||||
};
|
||||
|
||||
private static void AssertExactPartition(SessionTimeBreakdown b)
|
||||
{
|
||||
Assert.Equal(b.WallClockSeconds, b.Segments.Sum(s => s.Seconds) + b.UnattributedSeconds);
|
||||
Assert.True(b.UnattributedSeconds >= 0, "unattributed must never be negative");
|
||||
}
|
||||
|
||||
private static int Total(SessionTimeBreakdown b, string segment) =>
|
||||
b.GetSegmentTotals().TryGetValue(segment, out var seconds) ? seconds : 0;
|
||||
|
||||
private static DateTime At(int minutes, int seconds = 0) => T0.AddMinutes(minutes).AddSeconds(seconds);
|
||||
|
||||
// ── the device ESP and the update page, shared by the fixtures ──────────
|
||||
|
||||
private static List<EnrollmentEvent> DeviceEsp() => new()
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(At(20), "esp_exiting"),
|
||||
};
|
||||
|
||||
// ── golden: long update, three restarts, second page visit (field: 83 min) ──
|
||||
|
||||
private static List<EnrollmentEvent> LongUpdateWithSecondVisit()
|
||||
{
|
||||
var events = DeviceEsp();
|
||||
events.AddRange(new[]
|
||||
{
|
||||
PageStart(At(20, 5), "OobeNDUP"),
|
||||
PageName(At(20, 10), "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true"),
|
||||
// The agent declares AccountSetup while the page still runs — no evidence of the user.
|
||||
Evt(At(22, 36), "esp_phase_changed", EnrollmentPhase.AccountSetup),
|
||||
Servicing(At(43, 35), "initiating", targetState: "Staged"),
|
||||
Servicing(At(63), "state_reached", targetState: "Staged"),
|
||||
Servicing(At(63, 10), "initiating"),
|
||||
Servicing(At(86, 46), "reboot_required"),
|
||||
PageName(At(102, 39), "ExpeditedUpdate_downloadInstallFailureHelper"),
|
||||
PageStart(At(103, 11), "RebootNDUP"),
|
||||
RebootDetected(At(104, 43), lastBoot: At(104)),
|
||||
Evt(At(106), "agent_shutting_down"),
|
||||
RebootDetected(At(108), lastBoot: At(107)),
|
||||
Evt(At(110), "agent_shutting_down"),
|
||||
RebootDetected(At(112), lastBoot: At(111)),
|
||||
Servicing(At(119, 54), "state_reached"),
|
||||
PageStart(At(120, 6), "OobeNDUP"),
|
||||
PageStop(At(120, 36), "OobeNDUP", "success"),
|
||||
Evt(At(122, 8), "desktop_arrived"),
|
||||
Evt(At(125), "enrollment_complete"),
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongUpdate_IsOneUpdateSegment_FromThePageStartToTheSecondVisit_ThenTheWait()
|
||||
{
|
||||
var b = TimeAttributionCalculator.Compute(Input(LongUpdateWithSecondVisit(), At(125), 7500))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.Equal(4, b.AttributionVersion);
|
||||
Assert.Equal(120, Total(b, TimeAttributionSegments.DevicePrep));
|
||||
Assert.Equal(18 * 60 + 5, Total(b, TimeAttributionSegments.EspApps)); // +2m → page start
|
||||
Assert.Equal(100 * 60 + 31, Total(b, TimeAttributionSegments.OsUpdate)); // page start → second visit ends
|
||||
Assert.Equal(92, Total(b, TimeAttributionSegments.AwaitingSignIn)); // → desktop
|
||||
Assert.Equal(0, Total(b, TimeAttributionSegments.IdentityHello)); // the early AccountSetup lay inside the update
|
||||
Assert.Equal(172, Total(b, TimeAttributionSegments.DesktopHandoff));
|
||||
|
||||
var update = Assert.Single(b.OsUpdates);
|
||||
Assert.Equal(At(20, 5), update.StartUtc);
|
||||
Assert.Equal(At(120, 36), update.EndUtc);
|
||||
Assert.Equal(100 * 60 + 31, update.Seconds);
|
||||
Assert.Equal(new[] { "KB5129195" }, update.Kbs);
|
||||
Assert.Equal(3, update.RebootCount);
|
||||
|
||||
// The update's restarts sit in its own segment.
|
||||
Assert.Equal(3, b.RebootSpans.Count);
|
||||
Assert.All(b.RebootSpans, r => Assert.Equal(TimeAttributionSegments.OsUpdate, r.SegmentKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongUpdate_WasIdentityHelloBeforeV4()
|
||||
{
|
||||
// Same stream without the update signals = what v3 saw: the AccountSetup declaration
|
||||
// took the whole update.
|
||||
var withoutUpdate = LongUpdateWithSecondVisit()
|
||||
.Where(e => e.EventType != "oobe_update_page" && e.EventType != "windows_update_servicing")
|
||||
.ToList();
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(Input(withoutUpdate, At(125), 7500))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.Equal(0, Total(b, TimeAttributionSegments.OsUpdate));
|
||||
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn));
|
||||
Assert.Equal((122 * 60 + 8) - (22 * 60 + 36), Total(b, TimeAttributionSegments.IdentityHello));
|
||||
Assert.Empty(b.OsUpdates);
|
||||
}
|
||||
|
||||
// ── golden: update inside the apps phase, long wait with standby (field: 48 min) ──
|
||||
|
||||
[Fact]
|
||||
public void UpdateBeforeAccountSetup_TakesItsTimeFromTheAppsPhase_AndTheWaitHoldsTheStandby()
|
||||
{
|
||||
var events = DeviceEsp();
|
||||
events.AddRange(new[]
|
||||
{
|
||||
PageStart(At(20, 4), "OobeNDUP"),
|
||||
PageName(At(21), "ExpeditedUpdate_startWUScanStarted"),
|
||||
Servicing(At(30), "initiating", package: Lcu, targetState: "Staged"),
|
||||
Servicing(At(38, 50), "reboot_required", package: Lcu),
|
||||
PageStart(At(38, 55), "RebootNDUP"),
|
||||
RebootDetected(At(40, 10), lastBoot: At(39, 30)),
|
||||
Evt(At(41), "agent_shutting_down"),
|
||||
RebootDetected(At(42, 30), lastBoot: At(42)),
|
||||
Servicing(At(43), "state_reached", package: Lcu),
|
||||
// The user is away: the device sleeps 45 minutes at the lock screen.
|
||||
Evt(At(89), "system_sleep_episode", data: new()
|
||||
{
|
||||
["enteredAt"] = At(44).ToString("o"),
|
||||
["exitedAt"] = At(89).ToString("o"),
|
||||
["kind"] = "modern_standby",
|
||||
}),
|
||||
Evt(At(90, 50), "esp_phase_changed", EnrollmentPhase.AccountSetup),
|
||||
Evt(At(91), "hello_wizard_started"),
|
||||
Evt(At(91), "esp_phase_changed", EnrollmentPhase.FinalizingSetup),
|
||||
Evt(At(93), "desktop_arrived"),
|
||||
Evt(At(95), "enrollment_complete"),
|
||||
});
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(Input(events, At(95), 5700))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.Equal(18 * 60 + 4, Total(b, TimeAttributionSegments.EspApps)); // +2m → page start only
|
||||
Assert.Equal(22 * 60 + 56, Total(b, TimeAttributionSegments.OsUpdate)); // → servicing reports Installed
|
||||
// The wait ends at the first evidence of the user: the Hello wizard, not the AccountSetup row.
|
||||
Assert.Equal((91 - 43) * 60, Total(b, TimeAttributionSegments.AwaitingSignIn));
|
||||
Assert.Equal(120, Total(b, TimeAttributionSegments.IdentityHello)); // Hello → desktop
|
||||
|
||||
var update = Assert.Single(b.OsUpdates);
|
||||
Assert.Empty(update.Kbs); // RollupFix names no KB
|
||||
Assert.Equal(2, update.RebootCount);
|
||||
var sleep = Assert.Single(b.SleepSpans);
|
||||
Assert.Equal(TimeAttributionSegments.AwaitingSignIn, sleep.SegmentKey);
|
||||
}
|
||||
|
||||
// ── no update, no segment ───────────────────────────────────────────────
|
||||
|
||||
private static List<EnrollmentEvent> WithAccountPhase(IEnumerable<EnrollmentEvent> updatePart)
|
||||
{
|
||||
var events = DeviceEsp();
|
||||
events.AddRange(updatePart);
|
||||
events.AddRange(new[]
|
||||
{
|
||||
Evt(At(21), "esp_phase_changed", EnrollmentPhase.AccountSetup),
|
||||
Evt(At(26), "hello_wizard_started"),
|
||||
Evt(At(26), "esp_phase_changed", EnrollmentPhase.FinalizingSetup),
|
||||
Evt(At(28), "desktop_arrived"),
|
||||
Evt(At(30), "enrollment_complete"),
|
||||
});
|
||||
// Canonical order = event time here: the update part was created first.
|
||||
var ordered = events.OrderBy(e => e.Timestamp).ToList();
|
||||
foreach (var evt in ordered) evt.Sequence = ++_seq;
|
||||
return ordered;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelledPage_LeavesThePhasePartitionUnchanged()
|
||||
{
|
||||
// AllowOOBEUpdates=0: the page checks the CSP gate and cancels within seconds.
|
||||
var cancelled = WithAccountPhase(new[]
|
||||
{
|
||||
PageStart(At(20, 2), "OobeNDUP"),
|
||||
PageName(At(20, 3), "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "false"),
|
||||
PageStop(At(20, 5), "OobeNDUP", "cancel"),
|
||||
PageStart(At(20, 6), "RebootNDUP"),
|
||||
});
|
||||
var plain = WithAccountPhase(Array.Empty<EnrollmentEvent>());
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(Input(cancelled, At(30), 1800))!;
|
||||
var v3 = TimeAttributionCalculator.Compute(Input(plain, At(30), 1800))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.Empty(b.OsUpdates);
|
||||
Assert.Equal(v3.Segments.Select(s => (s.SegmentKey, s.Seconds)), b.Segments.Select(s => (s.SegmentKey, s.Seconds)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanThatFoundNothing_IsNoUpdate()
|
||||
{
|
||||
// Every visit logs the same init names — with "Install" and "Download" in them.
|
||||
var events = WithAccountPhase(new[]
|
||||
{
|
||||
PageStart(At(20, 2), "OobeNDUP"),
|
||||
PageName(At(20, 3), "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true"),
|
||||
PageName(At(20, 4), "SdxWebAppCloudNDUP_initialize_NDUPInstallCanceledInOptOut", "false"),
|
||||
PageName(At(20, 4), "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", "0"),
|
||||
PageName(At(20, 9), "ExpeditedUpdate_startWUScanStarted"),
|
||||
PageName(At(20, 58), "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
|
||||
PageStop(At(20, 59), "OobeNDUP", "cancel"),
|
||||
});
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.Empty(b.OsUpdates);
|
||||
Assert.Equal(0, Total(b, TimeAttributionSegments.OsUpdate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServicingBesideACancelledPage_IsSomeOtherUpdate()
|
||||
{
|
||||
// The page was seen and did not update; a servicing step during the account phase is
|
||||
// not the OOBE update.
|
||||
var events = WithAccountPhase(new[]
|
||||
{
|
||||
PageStart(At(20, 2), "OobeNDUP"),
|
||||
PageStop(At(20, 5), "OobeNDUP", "cancel"),
|
||||
Servicing(At(23), "initiating", targetState: "Staged"),
|
||||
});
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.Empty(b.OsUpdates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAfterTheDesktop_StaysATimelineEvent()
|
||||
{
|
||||
var events = WithAccountPhase(new[]
|
||||
{
|
||||
Servicing(At(28, 30), "initiating", targetState: "Staged"),
|
||||
Servicing(At(29, 30), "state_reached", targetState: "Staged"),
|
||||
});
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.Empty(b.OsUpdates);
|
||||
Assert.Equal(120, Total(b, TimeAttributionSegments.DesktopHandoff));
|
||||
}
|
||||
|
||||
// ── update without a restart: no wait ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UpdateWithoutRestart_HasNoWaitForSignIn()
|
||||
{
|
||||
// Without a restart the user who started the enrollment is still signed in.
|
||||
var events = WithAccountPhase(new[]
|
||||
{
|
||||
PageStart(At(20, 2), "OobeNDUP"),
|
||||
PageName(At(20, 40), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
|
||||
PageStop(At(20, 50), "OobeNDUP", "success"),
|
||||
});
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
var update = Assert.Single(b.OsUpdates);
|
||||
Assert.Equal(0, update.RebootCount);
|
||||
Assert.Equal(48, Total(b, TimeAttributionSegments.OsUpdate)); // page start → page stop
|
||||
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn));
|
||||
}
|
||||
|
||||
// ── the page start was not observed ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WithoutAnObservedPageStart_TheFirstServicingStepAfterTheEspExitBeginsTheUpdate()
|
||||
{
|
||||
var events = DeviceEsp();
|
||||
events.AddRange(new[]
|
||||
{
|
||||
Servicing(At(10), "initiating", targetState: "Staged"), // during the ESP: not the OOBE update
|
||||
Servicing(At(25), "initiating", targetState: "Staged"),
|
||||
Servicing(At(35), "reboot_required"),
|
||||
PageStart(At(35, 10), "RebootNDUP"),
|
||||
RebootDetected(At(37), lastBoot: At(36)),
|
||||
Servicing(At(38), "state_reached"),
|
||||
Evt(At(40), "desktop_arrived"),
|
||||
Evt(At(42), "enrollment_complete"),
|
||||
});
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(Input(events, At(42), 2520))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
var update = Assert.Single(b.OsUpdates);
|
||||
Assert.Equal(At(25), update.StartUtc);
|
||||
Assert.Equal(At(38), update.EndUtc);
|
||||
Assert.Equal(120, Total(b, TimeAttributionSegments.AwaitingSignIn));
|
||||
Assert.Equal(23 * 60, Total(b, TimeAttributionSegments.EspApps)); // the hole until the first step stays the phase's
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted", true)]
|
||||
[InlineData("SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded", true)]
|
||||
[InlineData("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired_lcu", true)]
|
||||
[InlineData("SdxWebAppCloudNDUP_updateUSOProgressBar_DownloadPhase_progress100", true)]
|
||||
[InlineData("SdxWebAppCloudNDUP_downloadInstallFailureHelper", true)]
|
||||
[InlineData("SdxWebAppCloudNDUP_rebootCountdown_starting", true)]
|
||||
[InlineData("SdxWebAppCloudNDUP_initialize_NDUPInstallCanceledInOptOut", false)]
|
||||
[InlineData("SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", false)]
|
||||
[InlineData("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededNoReboot", false)]
|
||||
[InlineData("SdxWebAppCloudNDUP_handleInstallHelper", false)]
|
||||
[InlineData("ExpeditedUpdate_getUpdateResultsSucceeded", false)]
|
||||
[InlineData(null, false)]
|
||||
public void UpdateActivityNames_AreTheOnesOnlyAnUpdateWrites(string? name, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, TimeAttributionCalculator.IsUpdateActivityName(name));
|
||||
}
|
||||
|
||||
// ── restarts ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ClockSyncAtBoot_DoesNotShortenTheRestart()
|
||||
{
|
||||
// The hardware clock sync is logged 0.1 s before lastBootUtc; the restart began with
|
||||
// the last real event before it.
|
||||
var events = DeviceEsp();
|
||||
events.AddRange(new[]
|
||||
{
|
||||
PageStart(At(20, 2), "OobeNDUP"),
|
||||
Servicing(At(25), "initiating", targetState: "Staged"),
|
||||
Servicing(At(30), "reboot_required"),
|
||||
PageStart(At(30, 5), "RebootNDUP"),
|
||||
Evt(At(31).AddMilliseconds(-140), "system_clock_changed"),
|
||||
RebootDetected(At(31, 20), lastBoot: At(31)),
|
||||
Servicing(At(32), "state_reached"),
|
||||
Evt(At(34), "desktop_arrived"),
|
||||
Evt(At(35), "enrollment_complete"),
|
||||
});
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(Input(events, At(35), 2100))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
var restart = Assert.Single(b.RebootSpans);
|
||||
Assert.Equal(At(30, 5), restart.StartUtc); // not the clock sync
|
||||
Assert.Equal(At(31, 20), restart.EndUtc);
|
||||
Assert.Equal(75, restart.Seconds);
|
||||
Assert.Equal(TimeAttributionSegments.OsUpdate, restart.SegmentKey);
|
||||
}
|
||||
|
||||
// ── robustness ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BackfillDuplicates_ChangeNothing()
|
||||
{
|
||||
var once = LongUpdateWithSecondVisit();
|
||||
var twice = LongUpdateWithSecondVisit();
|
||||
// An agent restart re-sends the page records it read before, with their event times.
|
||||
twice.AddRange(twice.Where(e => e.EventType == "oobe_update_page").Select(e => Evt(e.Timestamp, e.EventType, data: e.Data)).ToList());
|
||||
|
||||
var a = TimeAttributionCalculator.Compute(Input(once, At(125), 7500))!;
|
||||
var b = TimeAttributionCalculator.Compute(Input(twice, At(125), 7500))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.Equal(a.Segments.Select(s => (s.SegmentKey, s.Seconds)), b.Segments.Select(s => (s.SegmentKey, s.Seconds)));
|
||||
Assert.Equal(a.OsUpdates.Single().Seconds, b.OsUpdates.Single().Seconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedDeclaration_EndsTheUpdateToo()
|
||||
{
|
||||
var events = DeviceEsp();
|
||||
events.AddRange(new[]
|
||||
{
|
||||
PageStart(At(20, 2), "OobeNDUP"),
|
||||
Servicing(At(25), "initiating", targetState: "Staged"),
|
||||
Evt(At(50), "enrollment_failed", EnrollmentPhase.Failed),
|
||||
Servicing(At(55), "state_reached", targetState: "Staged"),
|
||||
});
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(Input(events, At(60), 3600, status: "Failed"))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
var update = Assert.Single(b.OsUpdates);
|
||||
Assert.Equal(At(25), update.EndUtc); // the step after the failure is not attributed
|
||||
Assert.Equal(4 * 60 + 58, Total(b, TimeAttributionSegments.OsUpdate));
|
||||
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn));
|
||||
Assert.True(b.UnattributedSeconds >= 10 * 60); // the post-failure tail stays unattributed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGlove_UpdateInTheUserPart_StaysInsideItsWindow()
|
||||
{
|
||||
var part1End = At(30);
|
||||
var resumedAt = T0.AddDays(2);
|
||||
var completedAt = resumedAt.AddMinutes(40);
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(part1End, "whiteglove_part1_complete"),
|
||||
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(resumedAt.AddMinutes(3), "esp_exiting"),
|
||||
PageStart(resumedAt.AddMinutes(3).AddSeconds(4), "OobeNDUP"),
|
||||
Servicing(resumedAt.AddMinutes(10), "initiating", targetState: "Staged"),
|
||||
Servicing(resumedAt.AddMinutes(20), "reboot_required"),
|
||||
PageStart(resumedAt.AddMinutes(20).AddSeconds(5), "RebootNDUP"),
|
||||
RebootDetected(resumedAt.AddMinutes(23), lastBoot: resumedAt.AddMinutes(22)),
|
||||
Servicing(resumedAt.AddMinutes(24), "state_reached"),
|
||||
Evt(resumedAt.AddMinutes(30), "esp_phase_changed", EnrollmentPhase.AccountSetup),
|
||||
Evt(resumedAt.AddMinutes(32), "hello_wizard_started"),
|
||||
Evt(resumedAt.AddMinutes(38), "desktop_arrived"),
|
||||
Evt(completedAt, "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt, durationSeconds: 30 * 60 + 40 * 60, wg: true, resumedAt: resumedAt))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
var update = Assert.Single(b.OsUpdates);
|
||||
Assert.Equal(resumedAt.AddMinutes(3).AddSeconds(4), update.StartUtc);
|
||||
Assert.Equal(resumedAt.AddMinutes(24), update.EndUtc);
|
||||
Assert.Equal(8 * 60, Total(b, TimeAttributionSegments.AwaitingSignIn)); // → Hello wizard
|
||||
Assert.All(b.Segments, s => Assert.True(s.EndUtc <= part1End || s.StartUtc >= resumedAt, "no span in the pause"));
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -51,6 +51,10 @@ public class TimeAttributionPersistenceAndAggregationTests
|
||||
{
|
||||
new() { StartUtc = T0.AddMinutes(20), EndUtc = T0.AddMinutes(26), Seconds = 360, SegmentKey = TimeAttributionSegments.EspApps, Kind = "modern_standby" },
|
||||
},
|
||||
OsUpdates = new List<OsUpdateSpan>
|
||||
{
|
||||
new() { StartUtc = T0.AddMinutes(9), EndUtc = T0.AddMinutes(19), Seconds = 600, Kbs = new List<string> { "KB5129195", "KB5054156" }, RebootCount = 2 },
|
||||
},
|
||||
BlockingApps = new List<BlockingAppInterval>
|
||||
{
|
||||
new() { AppId = AppX, AppName = "App X", StartUtc = T0.AddMinutes(6), EndUtc = T0.AddMinutes(16), Seconds = 600 },
|
||||
@@ -89,12 +93,30 @@ public class TimeAttributionPersistenceAndAggregationTests
|
||||
Assert.Equal(T0.AddMinutes(20), sleep.StartUtc);
|
||||
Assert.Equal(360, sleep.Seconds);
|
||||
|
||||
var update = Assert.Single(mapped.OsUpdates);
|
||||
Assert.Equal(T0.AddMinutes(9), update.StartUtc);
|
||||
Assert.Equal(T0.AddMinutes(19), update.EndUtc);
|
||||
Assert.Equal(600, update.Seconds);
|
||||
Assert.Equal(new[] { "KB5129195", "KB5054156" }, update.Kbs);
|
||||
Assert.Equal(2, update.RebootCount);
|
||||
|
||||
var app = Assert.Single(mapped.BlockingApps);
|
||||
Assert.Equal(AppX, app.AppId);
|
||||
Assert.Equal("App X", app.AppName);
|
||||
Assert.Equal(600, app.Seconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BreakdownEntity_WrittenBeforeOsUpdates_MapsToAnEmptyList()
|
||||
{
|
||||
// v3 rows have no OsUpdatesJson column; the sweep recomputes them, reads must not fail.
|
||||
var entity = TableStorageService.BuildSessionTimeBreakdownEntity(
|
||||
new SessionTimeBreakdown { TenantId = TenantA, SessionId = "s-1", WallClockSeconds = 600 });
|
||||
entity.Remove("OsUpdatesJson");
|
||||
|
||||
Assert.Empty(TableStorageService.MapToSessionTimeBreakdown(entity).OsUpdates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BreakdownEntity_OccupancyTriState_AbsentColumnMapsToNull_NeverZero()
|
||||
{
|
||||
@@ -376,9 +398,12 @@ public class TimeAttributionPersistenceAndAggregationTests
|
||||
pairs, Array.Empty<SessionSummary>(), T0)
|
||||
.Single(a => a.TenantId == TenantA);
|
||||
|
||||
Assert.Equal(6, aggregate.SegmentStats.Count); // 5 canonical + unattributed — the full stack
|
||||
Assert.Equal(8, aggregate.SegmentStats.Count); // 7 canonical + unattributed — the full stack
|
||||
// The fixture has no user_esp span → honest 0, not absent.
|
||||
Assert.Equal(0, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.UserEsp).MedianSeconds);
|
||||
// A session without an OOBE update contributes 0 to the update and the wait.
|
||||
Assert.Equal(0, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.OsUpdate).MedianSeconds);
|
||||
Assert.Equal(0, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.AwaitingSignIn).MedianSeconds);
|
||||
Assert.Equal(42, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.Unattributed).MedianSeconds);
|
||||
}
|
||||
|
||||
|
||||
@@ -283,6 +283,18 @@ public class VerdictCalibrationRadarTests
|
||||
Assert.Equal((14, 21, 56, 84), gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoppedWaiting_counts_as_a_classifier_verdict_but_never_as_silence()
|
||||
{
|
||||
// D-310: the engine deliberately stopped waiting for the user — not an agent that went
|
||||
// quiet, so the agent-liveness share must not see it.
|
||||
var rows = Horizon(w => new[] { ("sweep:r6", "Incomplete", 2), ("stopwait:r5_incomplete", "Incomplete", 1), ("agent:complete", "Succeeded", 17) });
|
||||
var silence = VerdictCalibrationRadar.CurrentSums(new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.SilenceShareRegression }, rows, Target);
|
||||
Assert.Equal((14, 140, 56, 560), silence);
|
||||
var gap = VerdictCalibrationRadar.CurrentSums(new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.EvidenceGap }, rows, Target);
|
||||
Assert.Equal((14, 21, 56, 84), gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_outside_the_horizon_are_ignored()
|
||||
{
|
||||
|
||||
@@ -62,6 +62,8 @@ public class VerdictPathTests
|
||||
Assert.True(VerdictPaths.IsClassifierPath(path));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("sweep:r5_assumed"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("maxlife:r1"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("stopwait:r5_incomplete"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("stopwait:r1b_awaiting"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("late:r4"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("retro:r6"));
|
||||
Assert.False(VerdictPaths.IsClassifierPath(VerdictPaths.AgentComplete));
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Windows Update during OOBE — pins the firing behaviour of the built-in analyze rules
|
||||
/// that surface update activity captured by the agent's WindowsUpdateTracker:
|
||||
/// - ANALYZE-DEV-004 (high): a quality/cumulative update FAILED during enrollment.
|
||||
/// - ANALYZE-DEV-004 (high): a quality/cumulative update FAILED to install or download during
|
||||
/// enrollment (wuPhase install/download — never an update-scan failure).
|
||||
/// - ANALYZE-DEV-005 (info): a quality/cumulative update INSTALLED during enrollment.
|
||||
/// - ANALYZE-DEV-006 (info): the OS build changed across a mid-enrollment reboot —
|
||||
/// deterministic corroboration that works even when the WU channel showed nothing
|
||||
@@ -51,6 +52,62 @@ public class WindowsUpdateRuleTests
|
||||
Assert.Equal("WU_E_ALL_UPDATES_FAILED", AsString(symbol["value"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_fires_on_a_failed_download()
|
||||
{
|
||||
// EventID 31 from the Operational channel (D-310): the update never finished downloading.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-004");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
WindowsUpdateFailed("2026-09 Cumulative Update for Windows 11 (KB5099999)", "0x80248007", "WU_E_DS_NODATA",
|
||||
wuEventId: 31, wuPhase: "download"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("0x80248007", AsString(AsDict(result.MatchedConditions["wu_hresult"])["value"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_does_not_fire_on_an_update_scan_failure()
|
||||
{
|
||||
// EventID 25 names no update: a failed scan (often a transient network or proxy error) is
|
||||
// not a failed update and must not raise a high-severity finding.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-004");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
WindowsUpdateScanFailed("0x8024401C", "WU_E_PT_HTTP_STATUS_REQUEST_TIMEOUT"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_takes_its_evidence_from_the_install_failure_not_the_scan_failure()
|
||||
{
|
||||
// The phase filter applies to every evidence condition: the explanation must name the
|
||||
// failed update and its error, never the error of an earlier scan.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-004");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
WindowsUpdateScanFailed("0x8024401C", "WU_E_PT_HTTP_STATUS_REQUEST_TIMEOUT"),
|
||||
WindowsUpdateFailed("2026-09 Cumulative Update for Windows 11 (KB5099999)", "0x800F0922", "CBS_E_INSTALLERS_FAILED"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("0x800F0922", AsString(AsDict(result.MatchedConditions["wu_hresult"])["value"]));
|
||||
Assert.Equal("CBS_E_INSTALLERS_FAILED", AsString(AsDict(result.MatchedConditions["wu_hresult_symbol"])["value"]));
|
||||
Assert.Equal("2026-09 Cumulative Update for Windows 11 (KB5099999)",
|
||||
AsString(AsDict(result.MatchedConditions["wu_update_title"])["value"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_does_not_fire_without_a_failed_update()
|
||||
{
|
||||
@@ -217,11 +274,12 @@ public class WindowsUpdateRuleTests
|
||||
["wuClientCensus"] = "21=1,25=3",
|
||||
["updateOrchestratorCensus"] = "200=2",
|
||||
["lookbackMinutes"] = 60,
|
||||
["targetedEventIds"] = "19,20,43,44",
|
||||
["targetedEventIds"] = "19,20,25,26,31,41,43,44",
|
||||
}
|
||||
};
|
||||
|
||||
private static EnrollmentEvent WindowsUpdateFailed(string title, string hresult, string hresultSymbol) => new()
|
||||
private static EnrollmentEvent WindowsUpdateFailed(
|
||||
string title, string hresult, string hresultSymbol, int wuEventId = 20, string wuPhase = "install") => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
@@ -231,7 +289,10 @@ public class WindowsUpdateRuleTests
|
||||
Sequence = 42,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["wuEventId"] = 20,
|
||||
["wuEventId"] = wuEventId,
|
||||
["wuChannel"] = wuEventId == 20 ? "system" : "operational",
|
||||
["wuPhase"] = wuPhase,
|
||||
["updateClass"] = "os",
|
||||
["updateTitle"] = title,
|
||||
["updateGuid"] = "{8b1c8726-1111-2222-3333-444455556666}",
|
||||
["hresult"] = hresult,
|
||||
@@ -240,6 +301,25 @@ public class WindowsUpdateRuleTests
|
||||
}
|
||||
};
|
||||
|
||||
private static EnrollmentEvent WindowsUpdateScanFailed(string hresult, string hresultSymbol) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "windows_update_failed",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 41,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["wuEventId"] = 25,
|
||||
["wuChannel"] = "operational",
|
||||
["wuPhase"] = "scan",
|
||||
["hresult"] = hresult,
|
||||
["hresultSymbol"] = hresultSymbol,
|
||||
["backfilled"] = false,
|
||||
}
|
||||
};
|
||||
|
||||
private static EnrollmentEvent WindowsUpdateSucceeded(string title) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
@@ -251,6 +331,9 @@ public class WindowsUpdateRuleTests
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["wuEventId"] = 19,
|
||||
["wuChannel"] = "system",
|
||||
["wuPhase"] = "installed",
|
||||
["updateClass"] = "os",
|
||||
["updateTitle"] = title,
|
||||
["updateGuid"] = "{8b1c8726-1111-2222-3333-444455556666}",
|
||||
["backfilled"] = false,
|
||||
|
||||
+3
@@ -57,6 +57,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<RawPage<SessionSummary>> GetSessionsPageAsync(string tenantId, int? days, int pageSize, string? continuation)
|
||||
=> _storage.GetSessionsPageAsync(tenantId, days, pageSize, continuation);
|
||||
|
||||
public Task<bool> HasAnySessionStrictAsync(string tenantId)
|
||||
=> _storage.HasAnySessionStrictAsync(tenantId);
|
||||
|
||||
public Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation, IReadOnlyCollection<string>? allowedTenantIds = null, IEnumerable<string>? select = null, CancellationToken cancellationToken = default)
|
||||
=> _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation, allowedTenantIds, select, cancellationToken);
|
||||
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Progress;
|
||||
|
||||
/// <summary>
|
||||
/// Tells the Progress Portal whether the caller's own organization looks unused. A member without a
|
||||
/// role only ever sees the portal; without this hint they never learn that their organization already
|
||||
/// runs the product, or that nobody looks after it anymore. Reveals no session data: the JWT tenant
|
||||
/// only, a flag and the signup date.
|
||||
/// </summary>
|
||||
public class ProgressTenantStatusFunction
|
||||
{
|
||||
/// <summary>A younger organization is still being set up, not abandoned.</summary>
|
||||
internal static readonly TimeSpan UnusedAfter = TimeSpan.FromDays(14);
|
||||
|
||||
private readonly ILogger<ProgressTenantStatusFunction> _logger;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
|
||||
public ProgressTenantStatusFunction(
|
||||
ILogger<ProgressTenantStatusFunction> logger,
|
||||
TenantConfigurationService tenantConfigService,
|
||||
ISessionRepository sessionRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_sessionRepo = sessionRepo;
|
||||
}
|
||||
|
||||
/// <summary>GET /api/progress/tenant-status — the caller's own tenant (catalog: TenantScoping.Jwt).</summary>
|
||||
[Function("ProgressGetTenantStatus")]
|
||||
public async Task<HttpResponseData> GetTenantStatus(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "progress/tenant-status")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = req.GetRequestContext().TenantId;
|
||||
|
||||
// Both reads are strict: a storage failure must surface as 500, never as "unused".
|
||||
var configTask = _tenantConfigService.GetConfigurationIfExistsAsync(tenantId);
|
||||
var hasSessionTask = _sessionRepo.HasAnySessionStrictAsync(tenantId);
|
||||
await Task.WhenAll(configTask, hasSessionTask);
|
||||
|
||||
return await req.OkAsync(Evaluate(configTask.Result, hasSessionTask.Result, DateTime.UtcNow));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "ProgressGetTenantStatus");
|
||||
}
|
||||
}
|
||||
|
||||
internal static ProgressTenantStatusResponse Evaluate(TenantConfiguration? config, bool hasAnySession, DateTime utcNow)
|
||||
{
|
||||
// No configuration row: not an onboarded organization, nothing to call unused.
|
||||
if (config == null)
|
||||
return new ProgressTenantStatusResponse { Success = true };
|
||||
|
||||
// A row from before OnboardedAt existed is old by definition.
|
||||
var signedUpAt = config.OnboardedAt;
|
||||
var settled = signedUpAt == null || utcNow - signedUpAt.Value >= UnusedAfter;
|
||||
|
||||
return new ProgressTenantStatusResponse
|
||||
{
|
||||
Success = true,
|
||||
Unused = !hasAnySession && settled,
|
||||
SignedUpAt = signedUpAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
@@ -65,6 +66,11 @@ public sealed class TimeAttributionInput
|
||||
/// <item><b>Reboot spans from event gaps</b> around the <c>lastBootUtc</c> payload — the
|
||||
/// <c>system_reboot_detected</c> event is detection-time-stamped by the next run and never used
|
||||
/// as the reboot moment (audit Q7). Reboots are a cross-cutting annotation, not a segment.</item>
|
||||
/// <item><b>The OOBE quality update takes its time from the phase segments</b> (v4, D-314):
|
||||
/// <c>os_update</c> from the start of the update page that installed an update to the last
|
||||
/// update evidence before the user is back, then — after a restart — <c>awaiting_sign_in</c>
|
||||
/// until the first evidence of the user. Without update evidence (the page cancelled or found
|
||||
/// nothing) the partition is the phase partition unchanged.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class TimeAttributionCalculator
|
||||
@@ -74,7 +80,9 @@ public static class TimeAttributionCalculator
|
||||
// pre-enrollment state on disk are now flagged and excluded from fleet segment stats.
|
||||
// v3: SleepSpans — completed sleep/standby episodes (system_sleep_episode) as cross-cutting
|
||||
// annotations mirroring RebootSpans; the maintenance sweep recomputes older rows.
|
||||
public const int CurrentVersion = 3;
|
||||
// v4: os_update + awaiting_sign_in — the OOBE quality update and the wait for the user after
|
||||
// its restart, cut out of the phase segment they overlap (D-314), with OsUpdates naming the KBs.
|
||||
public const int CurrentVersion = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Enrollment class for fleet aggregation — classes are NEVER mixed in one aggregate (a
|
||||
@@ -142,6 +150,12 @@ public static class TimeAttributionCalculator
|
||||
var anchors = BuildAnchors(events, ref flags);
|
||||
var spans = BuildSegmentSpans(windows, anchors);
|
||||
|
||||
// D-310: the OOBE quality update and the wait for the user after its restart take their
|
||||
// time from the phase segment they overlap — the ESP is over, the device is updating.
|
||||
var rebootGaps = FindRebootGaps(events);
|
||||
var osUpdates = BuildOsUpdates(events, windows, anchors, rebootGaps);
|
||||
spans = ApplyOsUpdates(spans, windows, osUpdates);
|
||||
|
||||
var attributedSeconds = spans.Sum(s => s.Seconds);
|
||||
var unattributed = wallClock - attributedSeconds;
|
||||
|
||||
@@ -169,7 +183,7 @@ public static class TimeAttributionCalculator
|
||||
occupancy = ComputeOccupancySeconds(blockingAppSegments, espAppsSpans);
|
||||
}
|
||||
|
||||
var rebootSpans = BuildRebootSpans(events, windows, spans);
|
||||
var rebootSpans = BuildRebootSpans(rebootGaps, windows, spans);
|
||||
var sleepSpans = BuildSleepSpans(events, windows, spans);
|
||||
|
||||
if (events.Any(e => e.EventType == Constants.EventTypes.AgentLateStart))
|
||||
@@ -196,6 +210,16 @@ public static class TimeAttributionCalculator
|
||||
RebootSpans = rebootSpans,
|
||||
SleepSeconds = sleepSpans.Sum(s => s.Seconds),
|
||||
SleepSpans = sleepSpans,
|
||||
OsUpdates = osUpdates
|
||||
.Select(u => new OsUpdateSpan
|
||||
{
|
||||
StartUtc = u.Start,
|
||||
EndUtc = u.End,
|
||||
Seconds = ClampToWindows(u.Start, u.End, windows)?.Seconds ?? 0,
|
||||
Kbs = u.Kbs,
|
||||
RebootCount = u.RebootCount,
|
||||
})
|
||||
.ToList(),
|
||||
BlockingApps = blockingApps
|
||||
.OrderByDescending(a => a.Seconds)
|
||||
.ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase)
|
||||
@@ -642,16 +666,17 @@ public static class TimeAttributionCalculator
|
||||
// ── reboot spans ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// One span per distinct observed boot: the event-stream gap bracketing <c>lastBootUtc</c>
|
||||
/// One gap per distinct observed boot: the event-stream gap bracketing <c>lastBootUtc</c>
|
||||
/// (last event before the boot → first event at/after it). Fallback without a usable
|
||||
/// payload: previous event → the detection event itself (which is genuinely post-boot —
|
||||
/// usable as an observation bound, never as the boot moment). Spans are clipped to the
|
||||
/// observation hull, so a WhiteGlove-pause reboot contributes nothing.
|
||||
/// usable as an observation bound, never as the boot moment). Unclipped; the update interval
|
||||
/// and <see cref="BuildRebootSpans"/> read the same gaps. A <c>system_clock_changed</c> row is
|
||||
/// no bound (v4): the hardware clock sync at boot is logged a fraction of a second before
|
||||
/// <c>lastBootUtc</c> and shrank the outage to seconds (field: 4–10 s for restarts of a minute).
|
||||
/// </summary>
|
||||
private static List<RebootSpan> BuildRebootSpans(
|
||||
List<EnrollmentEvent> events, List<Window> windows, List<TimeAttributionSpan> spans)
|
||||
private static List<(DateTime Start, DateTime End)> FindRebootGaps(List<EnrollmentEvent> events)
|
||||
{
|
||||
var result = new List<RebootSpan>();
|
||||
var result = new List<(DateTime Start, DateTime End)>();
|
||||
var seenBoots = new HashSet<DateTime>();
|
||||
|
||||
for (var i = 0; i < events.Count; i++)
|
||||
@@ -677,6 +702,7 @@ public static class TimeAttributionCalculator
|
||||
if (!seenBoots.Add(lastBoot.Value)) continue; // duplicate detection of the same boot
|
||||
foreach (var e in events)
|
||||
{
|
||||
if (e.EventType == Constants.EventTypes.SystemClockChanged) continue;
|
||||
if (e.Timestamp < lastBoot.Value)
|
||||
{
|
||||
if (!gapStart.HasValue || e.Timestamp > gapStart.Value) gapStart = e.Timestamp;
|
||||
@@ -694,8 +720,23 @@ public static class TimeAttributionCalculator
|
||||
}
|
||||
|
||||
if (!gapStart.HasValue || !gapEnd.HasValue || gapEnd.Value <= gapStart.Value) continue;
|
||||
result.Add((gapStart.Value, gapEnd.Value));
|
||||
}
|
||||
|
||||
var clamped = ClampToWindows(gapStart.Value, gapEnd.Value, windows);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One span per reboot gap, clipped to the observation hull, so a WhiteGlove-pause reboot
|
||||
/// contributes nothing.
|
||||
/// </summary>
|
||||
private static List<RebootSpan> BuildRebootSpans(
|
||||
List<(DateTime Start, DateTime End)> gaps, List<Window> windows, List<TimeAttributionSpan> spans)
|
||||
{
|
||||
var result = new List<RebootSpan>();
|
||||
foreach (var gap in gaps)
|
||||
{
|
||||
var clamped = ClampToWindows(gap.Start, gap.End, windows);
|
||||
if (!clamped.HasValue) continue;
|
||||
// In-window seconds — a reboot gap bracketing the WhiteGlove pause contributes
|
||||
// only its in-window flanks, never the pause itself.
|
||||
@@ -714,6 +755,296 @@ public static class TimeAttributionCalculator
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── OOBE quality update (D-310) ─────────────────────────────────────────
|
||||
|
||||
/// <summary>CXID of the OOBE update page; <see cref="UpdateRestartPage"/> follows it and restarts for the update.</summary>
|
||||
internal const string UpdatePage = "OobeNDUP";
|
||||
internal const string UpdateRestartPage = "RebootNDUP";
|
||||
|
||||
// "Package_for_KB5129195~31bf…": the KB follows an underscore, so no \b before it.
|
||||
private static readonly Regex KbPattern = new(
|
||||
@"(?<![A-Za-z0-9])KB(\d{6,8})(?![0-9])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private sealed class OsUpdate
|
||||
{
|
||||
public DateTime Start;
|
||||
public DateTime End;
|
||||
|
||||
/// <summary>End of the wait for the user after the update's restart; null without a restart.</summary>
|
||||
public DateTime? AwaitingSignInEnd;
|
||||
|
||||
public List<string> Kbs = new();
|
||||
public int RebootCount;
|
||||
}
|
||||
|
||||
private readonly struct PageRecord
|
||||
{
|
||||
public PageRecord(DateTime at, string cxhEvent, string? page, string? name)
|
||||
{
|
||||
At = at;
|
||||
CxhEvent = cxhEvent;
|
||||
Page = page;
|
||||
Name = name;
|
||||
}
|
||||
|
||||
public DateTime At { get; }
|
||||
public string CxhEvent { get; }
|
||||
public string? Page { get; }
|
||||
public string? Name { get; }
|
||||
|
||||
public bool IsStartOf(string page) => CxhEvent == "page_started" && string.Equals(Page, page, StringComparison.OrdinalIgnoreCase);
|
||||
public bool IsStopOf(string page) => CxhEvent == "page_stopped" && string.Equals(Page, page, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Page names that only an update in progress writes (field data 2026-10-02). Every visit
|
||||
/// logs init names such as <c>initialize_NDUPInstallCanceledInOptOut</c> and
|
||||
/// <c>initialize_NDUPDownloadInstallPreviousFailureCount</c>, a scan that found nothing
|
||||
/// included, and <c>installSucceededNoReboot</c> follows the language check of visits whose
|
||||
/// update came later — so "Download"/"Install" in a name proves nothing.
|
||||
/// </summary>
|
||||
private static readonly string[] UpdateActivityMarkers =
|
||||
{
|
||||
"commitExpeditionDownloadInstall", // the page commits the download and install
|
||||
"downloadSucceeded",
|
||||
"installSucceededRebootRequired",
|
||||
"DownloadPhase", // the progress bars of the download …
|
||||
"installPhase", // … and of the install
|
||||
"downloadInstallFailureHelper", // a download or install failed
|
||||
"rebootCountdown", // the page restarts for the update
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The update page's own record of a download or an install — evidence of an update next to
|
||||
/// the servicing steps (an agent caps the page names per run, so this is the weaker of the two).
|
||||
/// </summary>
|
||||
internal static bool IsUpdateActivityName(string? name) =>
|
||||
name != null &&
|
||||
UpdateActivityMarkers.Any(marker => name.IndexOf(marker, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
|
||||
/// <summary>
|
||||
/// At most one update per observation window. <b>Begin:</b> the start of the update page
|
||||
/// (<see cref="UpdatePage"/>) whose visit holds update evidence — a servicing step or an
|
||||
/// update-activity name before the visit ends (its stop, the <see cref="UpdateRestartPage"/>
|
||||
/// start or the next visit). Only when no page start was observed in the window: the first
|
||||
/// servicing step after the device-ESP exit. Never after the user is back. <b>Bound:</b> the first evidence of the
|
||||
/// user (user ESP apps, desktop, Hello wizard, a Complete declaration), the end of
|
||||
/// attribution (Failed) or the window end. <b>End:</b> the latest update evidence before the
|
||||
/// bound — page record, servicing step, or the end of a restart that began in the interval.
|
||||
/// With a restart in the interval the user is signed out: <c>awaiting_sign_in</c> runs from
|
||||
/// the end to the bound.
|
||||
/// </summary>
|
||||
private static List<OsUpdate> BuildOsUpdates(
|
||||
List<EnrollmentEvent> events, List<Window> windows, List<Anchor> anchors,
|
||||
List<(DateTime Start, DateTime End)> rebootGaps)
|
||||
{
|
||||
var result = new List<OsUpdate>();
|
||||
|
||||
var pages = new List<PageRecord>();
|
||||
var servicing = new List<(DateTime At, string? Package)>();
|
||||
foreach (var evt in events)
|
||||
{
|
||||
if (evt.EventType == Constants.EventTypes.OobeUpdatePage && evt.Data != null)
|
||||
{
|
||||
pages.Add(new PageRecord(evt.Timestamp,
|
||||
DataString(evt.Data, "cxhEvent") ?? string.Empty,
|
||||
DataString(evt.Data, "page"),
|
||||
DataString(evt.Data, "name")));
|
||||
}
|
||||
else if (evt.EventType == Constants.EventTypes.WindowsUpdateServicing)
|
||||
{
|
||||
servicing.Add((evt.Timestamp, evt.Data != null ? DataString(evt.Data, "package") : null));
|
||||
}
|
||||
}
|
||||
if (servicing.Count == 0 && !pages.Any(p => IsUpdateActivityName(p.Name))) return result;
|
||||
|
||||
pages = pages.OrderBy(p => p.At).ToList();
|
||||
servicing = servicing.OrderBy(c => c.At).ToList();
|
||||
var evidence = servicing.Select(c => c.At)
|
||||
.Concat(pages.Where(p => IsUpdateActivityName(p.Name)).Select(p => p.At))
|
||||
.OrderBy(t => t)
|
||||
.ToList();
|
||||
|
||||
// The user is back (or the enrollment declared itself complete): from here on the time
|
||||
// is not the update's. A Failed declaration ends attribution altogether.
|
||||
var userBack = anchors
|
||||
.Where(a => a.Bucket == TimeAttributionSegments.UserEsp || a.Bucket == TimeAttributionSegments.DesktopHandoff)
|
||||
.Select(a => a.Ts)
|
||||
.Concat(events.Where(e => e.EventType == Constants.EventTypes.HelloWizardStarted).Select(e => e.Timestamp))
|
||||
.OrderBy(t => t)
|
||||
.ToList();
|
||||
var attributionEnd = anchors.FirstOrDefault(a => a.Bucket == null)?.Ts;
|
||||
var deviceEspExit = events.FirstOrDefault(e => e.EventType == Constants.EventTypes.EspExiting)?.Timestamp;
|
||||
|
||||
foreach (var window in windows)
|
||||
{
|
||||
var begin = FindUpdateBegin(window, pages, evidence, servicing, deviceEspExit, userBack);
|
||||
if (!begin.HasValue) continue;
|
||||
|
||||
var bound = window.End;
|
||||
if (attributionEnd.HasValue && attributionEnd.Value < bound) bound = attributionEnd.Value;
|
||||
foreach (var t in userBack)
|
||||
{
|
||||
if (t <= begin.Value) continue;
|
||||
if (t < bound) bound = t;
|
||||
break;
|
||||
}
|
||||
if (bound <= begin.Value) continue;
|
||||
|
||||
var update = new OsUpdate { Start = begin.Value, End = begin.Value };
|
||||
foreach (var page in pages)
|
||||
{
|
||||
if (page.At > update.End && page.At < bound) update.End = page.At;
|
||||
}
|
||||
foreach (var (at, package) in servicing)
|
||||
{
|
||||
if (at < begin.Value || at >= bound) continue;
|
||||
if (at > update.End) update.End = at;
|
||||
var kb = ExtractKb(package);
|
||||
if (kb != null && !update.Kbs.Contains(kb)) update.Kbs.Add(kb);
|
||||
}
|
||||
foreach (var (gapStart, gapEnd) in rebootGaps)
|
||||
{
|
||||
if (gapStart < begin.Value || gapStart >= bound) continue;
|
||||
update.RebootCount++;
|
||||
var restartEnd = gapEnd < bound ? gapEnd : bound;
|
||||
if (restartEnd > update.End) update.End = restartEnd;
|
||||
}
|
||||
|
||||
// The update's restart signed the user out; until the user is back the time is
|
||||
// waiting, not updating.
|
||||
if (update.RebootCount > 0 && bound > update.End)
|
||||
update.AwaitingSignInEnd = bound;
|
||||
|
||||
result.Add(update);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DateTime? FindUpdateBegin(
|
||||
Window window, List<PageRecord> pages, List<DateTime> evidence,
|
||||
List<(DateTime At, string? Package)> servicing, DateTime? deviceEspExit, List<DateTime> userBack)
|
||||
{
|
||||
// An update after the user is back runs beside the enrollment, not instead of it.
|
||||
bool UserBackBy(DateTime t) => userBack.Any(u => u > window.Start && u <= t);
|
||||
|
||||
var pageStartSeen = false;
|
||||
for (var i = 0; i < pages.Count; i++)
|
||||
{
|
||||
var start = pages[i];
|
||||
if (!start.IsStartOf(UpdatePage) || start.At < window.Start || start.At >= window.End) continue;
|
||||
pageStartSeen = true;
|
||||
if (UserBackBy(start.At)) continue;
|
||||
|
||||
// The visit ends with its stop, the restart page or the next visit — whichever comes
|
||||
// first; an update page that restarts the device writes no stop of its own.
|
||||
var visitEnd = window.End;
|
||||
for (var j = i + 1; j < pages.Count; j++)
|
||||
{
|
||||
var next = pages[j];
|
||||
if (next.IsStopOf(UpdatePage) || next.IsStartOf(UpdateRestartPage) || next.IsStartOf(UpdatePage))
|
||||
{
|
||||
visitEnd = next.At;
|
||||
break;
|
||||
}
|
||||
}
|
||||
foreach (var t in userBack)
|
||||
{
|
||||
if (t <= start.At) continue;
|
||||
if (t < visitEnd) visitEnd = t;
|
||||
break;
|
||||
}
|
||||
|
||||
if (evidence.Any(t => t >= start.At && t <= visitEnd)) return start.At;
|
||||
}
|
||||
|
||||
// A page that was seen and did not update (it cancelled, or found nothing) is the answer;
|
||||
// a servicing step beside it is some other update.
|
||||
if (pageStartSeen) return null;
|
||||
|
||||
// Fallback: the page start was not observed (the agent started after it). The first
|
||||
// servicing step after the device-ESP exit — without the download before it.
|
||||
if (!deviceEspExit.HasValue) return null;
|
||||
foreach (var (at, _) in servicing)
|
||||
{
|
||||
if (at < deviceEspExit.Value || at < window.Start || at >= window.End) continue;
|
||||
return UserBackBy(at) ? null : at;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractKb(string? package)
|
||||
{
|
||||
if (string.IsNullOrEmpty(package)) return null;
|
||||
try
|
||||
{
|
||||
var match = KbPattern.Match(package);
|
||||
return match.Success ? "KB" + match.Groups[1].Value : null;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? DataString(Dictionary<string, object> data, string key) =>
|
||||
data.TryGetValue(key, out var value) ? value?.ToString() : null;
|
||||
|
||||
/// <summary>
|
||||
/// Cuts the update and the wait for the user out of the phase spans and adds them as their
|
||||
/// own spans, clipped to the windows — a hole inside the interval is filled, the WhiteGlove
|
||||
/// pause never. Sessions without an update keep their phase partition unchanged.
|
||||
/// </summary>
|
||||
private static List<TimeAttributionSpan> ApplyOsUpdates(
|
||||
List<TimeAttributionSpan> spans, List<Window> windows, List<OsUpdate> updates)
|
||||
{
|
||||
if (updates.Count == 0) return spans;
|
||||
|
||||
var overrides = new List<(DateTime Start, DateTime End, string Key)>();
|
||||
foreach (var update in updates)
|
||||
{
|
||||
overrides.Add((update.Start, update.End, TimeAttributionSegments.OsUpdate));
|
||||
if (update.AwaitingSignInEnd.HasValue)
|
||||
overrides.Add((update.End, update.AwaitingSignInEnd.Value, TimeAttributionSegments.AwaitingSignIn));
|
||||
}
|
||||
|
||||
var result = new List<TimeAttributionSpan>();
|
||||
foreach (var span in spans)
|
||||
{
|
||||
var pieces = new List<(DateTime Start, DateTime End)> { (span.StartUtc, span.EndUtc) };
|
||||
foreach (var cut in overrides)
|
||||
{
|
||||
var next = new List<(DateTime Start, DateTime End)>();
|
||||
foreach (var (start, end) in pieces)
|
||||
{
|
||||
if (cut.End <= start || cut.Start >= end)
|
||||
{
|
||||
next.Add((start, end));
|
||||
continue;
|
||||
}
|
||||
if (cut.Start > start) next.Add((start, cut.Start));
|
||||
if (cut.End < end) next.Add((cut.End, end));
|
||||
}
|
||||
pieces = next;
|
||||
}
|
||||
foreach (var (start, end) in pieces)
|
||||
AddSpan(result, span.SegmentKey, start, end);
|
||||
}
|
||||
|
||||
foreach (var (start, end, key) in overrides)
|
||||
{
|
||||
foreach (var window in windows)
|
||||
{
|
||||
var clippedStart = start > window.Start ? start : window.Start;
|
||||
var clippedEnd = end < window.End ? end : window.End;
|
||||
AddSpan(result, key, clippedStart, clippedEnd);
|
||||
}
|
||||
}
|
||||
|
||||
return result.OrderBy(sp => sp.StartUtc).ToList();
|
||||
}
|
||||
|
||||
// ── sleep spans ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -256,6 +256,9 @@ public static class EndpointAccessPolicyCatalog
|
||||
// ProgressPortalFunction; regression history c4dabeee).
|
||||
new("GET", "progress/sessions/lookup", EndpointPolicy.AuthenticatedUserWithRole, TenantScoping.QueryParam),
|
||||
new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser, TenantScoping.QueryParam),
|
||||
// The caller's own (JWT) tenant only, and no session data: a "looks unused" flag plus the signup
|
||||
// date, so the portal can tell a member without a role whom to ask for access.
|
||||
new("GET", "progress/tenant-status", EndpointPolicy.AuthenticatedUser, TenantScoping.Jwt),
|
||||
// AuthenticatedUserWithRole (not plain AuthenticatedUser): the activating user is still
|
||||
// roleless during first-touch signup, so the tier must admit non-members — but the address is
|
||||
// tenant-shared state feeding the welcome/farewell mails, so the function re-gates on the
|
||||
|
||||
@@ -36,16 +36,16 @@ public enum TableMemberState
|
||||
/// <see cref="MemberRoleInfo.CanManageBootstrapTokens"/>, or to revoke access) by adding an
|
||||
/// explicit table row for that user.
|
||||
///
|
||||
/// Only Admin and Operator are mappable from claims; Viewer and the platform-wide GlobalAdmin
|
||||
/// role are intentionally never derived from claims.
|
||||
/// The tenant roles Admin, Operator and Viewer are mappable from claims; the platform-wide
|
||||
/// GlobalAdmin role is intentionally never derived from claims.
|
||||
/// </summary>
|
||||
public static class EntraAppRoleResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps raw Entra app-role values to a <see cref="MemberRoleInfo"/>. Admin outranks Operator
|
||||
/// when both are present. Returns null when no mappable role exists.
|
||||
/// Maps raw Entra app-role values to a <see cref="MemberRoleInfo"/>. The highest role wins when
|
||||
/// several are present: Admin, then Operator, then Viewer. Returns null when no mappable role exists.
|
||||
/// A claim-derived Admin implicitly gets <see cref="MemberRoleInfo.CanManageBootstrapTokens"/>;
|
||||
/// a claim Operator does not (granular bootstrap permission is a table-only override).
|
||||
/// a claim Operator or Viewer does not (granular bootstrap permission is a table-only override).
|
||||
/// </summary>
|
||||
public static MemberRoleInfo? MapClaimRole(IEnumerable<string>? appRoles)
|
||||
{
|
||||
@@ -63,6 +63,9 @@ public static class EntraAppRoleResolver
|
||||
if (roles.Any(r => string.Equals(r, Constants.TenantRoles.Operator, StringComparison.OrdinalIgnoreCase)))
|
||||
return new MemberRoleInfo { Role = Constants.TenantRoles.Operator, CanManageBootstrapTokens = false };
|
||||
|
||||
if (roles.Any(r => string.Equals(r, Constants.TenantRoles.Viewer, StringComparison.OrdinalIgnoreCase)))
|
||||
return new MemberRoleInfo { Role = Constants.TenantRoles.Viewer, CanManageBootstrapTokens = false };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
bool RealmJoinDetected,
|
||||
bool RealmJoinResolved,
|
||||
bool HasAppInstallFailure,
|
||||
bool HasAgentMaxLifetimeTimeout);
|
||||
// The agent is gone for good: its max-lifetime watchdog fired, or its decision engine
|
||||
// stopped waiting for the user (D-310, AgentStoppedWaiting).
|
||||
bool AgentStoppedForGood,
|
||||
bool AgentStoppedWaiting = false);
|
||||
|
||||
/// <summary>
|
||||
/// Walk a session's events and distill the ESP rollup. Tolerant of missing/empty input
|
||||
@@ -117,7 +120,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
int acctBestN = 0, acctBestM = 0;
|
||||
bool acctFallbackAll = false;
|
||||
bool hasFailure = false, hasComplete = false, hasEnrollmentComplete = false, hasEmergencyBreak = false;
|
||||
bool hasAppInstallFailure = false, hasMaxLifetimeTimeout = false;
|
||||
bool hasAppInstallFailure = false, agentStoppedForGood = false, agentStoppedWaiting = false;
|
||||
bool desktopArrived = false, helloResolved = false;
|
||||
bool realmJoinDetected = false, realmJoinResolved = false;
|
||||
bool sawHelloPolicyEnabled = false, sawHelloPolicyDisabled = false;
|
||||
@@ -138,15 +141,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (!Eq(TryGetDataString(evt, "failureType") ?? string.Empty, "agent_timeout"))
|
||||
hasFailure = true;
|
||||
else
|
||||
hasMaxLifetimeTimeout = true;
|
||||
agentStoppedForGood = true;
|
||||
}
|
||||
// The V2 watchdog shape of the same fact: the agent shut itself down at its
|
||||
// max-lifetime cap. Either shape proves the agent is gone for good — rule 5 uses
|
||||
// that to skip the AwaitingUser grace (a wait nothing can ever end).
|
||||
// max-lifetime cap — or its decision engine stopped waiting for the user (D-310).
|
||||
// Every shape proves the agent is gone for good — rule 5 uses that to skip the
|
||||
// AwaitingUser grace (a wait nothing can ever end).
|
||||
else if (Eq(type, "agent_shutting_down"))
|
||||
{
|
||||
if (Eq(TryGetDataString(evt, "reason") ?? string.Empty, "max_lifetime"))
|
||||
hasMaxLifetimeTimeout = true;
|
||||
var reason = TryGetDataString(evt, "reason") ?? string.Empty;
|
||||
if (Eq(reason, "max_lifetime"))
|
||||
agentStoppedForGood = true;
|
||||
else if (Eq(reason, "stopped_waiting"))
|
||||
agentStoppedForGood = agentStoppedWaiting = true;
|
||||
}
|
||||
else if (Eq(type, "app_install_failed"))
|
||||
hasAppInstallFailure = true;
|
||||
@@ -247,7 +254,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
RealmJoinDetected: realmJoinDetected,
|
||||
RealmJoinResolved: realmJoinResolved,
|
||||
HasAppInstallFailure: hasAppInstallFailure,
|
||||
HasAgentMaxLifetimeTimeout: hasMaxLifetimeTimeout);
|
||||
AgentStoppedForGood: agentStoppedForGood,
|
||||
AgentStoppedWaiting: agentStoppedWaiting);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -436,12 +444,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
var elapsedHours = (nowUtc - startedAtUtc).TotalHours;
|
||||
// The grace only buys time for a LIVE agent to come back with real evidence. Once
|
||||
// the agent's own max-lifetime watchdog has fired it has cleaned up and exited —
|
||||
// the agent stopped for good (its max-lifetime watchdog, or its engine stopped
|
||||
// waiting for the user — D-310) it has cleaned up and exited —
|
||||
// parking such a session AwaitingUser is a countdown, not a wait (all 5 observed
|
||||
// maxlife:r5_awaiting episodes expired unhealed; calibration read 2026-08-27), so
|
||||
// it skips straight to the terminal fork. Nothing is lost by deciding now: late
|
||||
// straggler telemetry still heals a terminal verdict (TryLateTelemetryReconcileAsync).
|
||||
if (elapsedHours < graceHours && !rollup.HasAgentMaxLifetimeTimeout)
|
||||
if (elapsedHours < graceHours && !rollup.AgentStoppedForGood)
|
||||
{
|
||||
var acct = rollup.AccountSetupTotal > 0
|
||||
? $" (Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal})"
|
||||
@@ -471,11 +480,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var acctDetail = rollup.AccountSetupTotal > 0
|
||||
? $"last Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal}"
|
||||
: "Account Setup progress never observed";
|
||||
// Inside the grace the only way here is the max-lifetime short-circuit — the
|
||||
// "within {grace}h" wording would be false, so name the watchdog instead.
|
||||
// Inside the grace the only way here is the agent-gone short-circuit — the
|
||||
// "within {grace}h" wording would be false, so name what stopped the agent instead.
|
||||
// The engine's own stop (D-310) states its reason in the verdict suffix.
|
||||
if (elapsedHours < graceHours)
|
||||
return (SessionStatus.Incomplete,
|
||||
$"Agent max-lifetime watchdog fired — agent gone without completion after Device Setup completed ({acctDetail})",
|
||||
rollup.AgentStoppedWaiting
|
||||
? $"Device Setup completed ({acctDetail}); the agent stopped waiting for the user."
|
||||
: $"Agent max-lifetime watchdog fired — agent gone without completion after Device Setup completed ({acctDetail})",
|
||||
ClassifierRules.R5DeviceSetupIncomplete);
|
||||
return (SessionStatus.Incomplete,
|
||||
$"No completion signal within {graceHours}h grace after Device Setup completed ({acctDetail})",
|
||||
|
||||
+78
-45
@@ -1,3 +1,5 @@
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -8,19 +10,18 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
public sealed partial class EventIngestProcessor
|
||||
{
|
||||
private EventClassification ClassifyEvents(List<EnrollmentEvent> storedEvents)
|
||||
internal static EventClassification ClassifyEvents(List<EnrollmentEvent> storedEvents)
|
||||
{
|
||||
var classification = new EventClassification();
|
||||
|
||||
foreach (var evt in storedEvents)
|
||||
{
|
||||
// System-timeline-watcher rows (clock steps, sleep episodes) are backfilled
|
||||
// from the Windows event log with original timestamps up to 24h before the
|
||||
// agent started — environment observation, not enrollment activity. They must
|
||||
// not pull the session anchor toward pre-enrollment OOBE idle time (mirrors
|
||||
// TableStorageService.IsSessionAnchorEligible on the Events-table probe;
|
||||
// field case d0c5b672).
|
||||
var anchorEligible = evt.Source != "SystemTimelineWatcher";
|
||||
// Backfilled environment observations (clock steps, sleep, update and servicing
|
||||
// activity from before the agent started) are timeline rows, not enrollment
|
||||
// activity: they must not pull the session anchor into pre-enrollment time
|
||||
// (Constants.EventSources.SessionAnchorIneligible; the Events-table probe applies
|
||||
// the same rule).
|
||||
var anchorEligible = Constants.EventSources.IsSessionAnchorEligible(evt.Source);
|
||||
if (anchorEligible &&
|
||||
(!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value))
|
||||
classification.EarliestEventTimestamp = evt.Timestamp;
|
||||
@@ -92,11 +93,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
classification.HasCompletionEvidenceCandidate = true;
|
||||
break;
|
||||
case "agent_shutting_down":
|
||||
// Every shutdown ends this agent run's observation; the max-lifetime
|
||||
// flavour additionally carries the verdict mapping below.
|
||||
// Every shutdown ends this agent run's observation; the gave-up flavours
|
||||
// (max-lifetime watchdog, engine stopped waiting) additionally carry the
|
||||
// verdict mapping below.
|
||||
classification.AgentShutdownEvent = evt;
|
||||
if (IsMaxLifetimeAgentShutdown(evt))
|
||||
classification.AgentMaxLifetimeShutdownEvent = evt;
|
||||
if (IsAgentGaveUpShutdown(evt))
|
||||
classification.AgentGaveUpShutdownEvent = evt;
|
||||
break;
|
||||
case "system_reboot_detected":
|
||||
// Per-batch incremental reboot count (live value during enrollment).
|
||||
@@ -177,14 +179,30 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static bool IsMaxLifetimeAgentShutdown(EnrollmentEvent? evt)
|
||||
internal static bool IsMaxLifetimeAgentShutdown(EnrollmentEvent? evt) =>
|
||||
string.Equals(AgentShutdownReason(evt), "max_lifetime", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// True for an <c>agent_shutting_down</c> event whose <c>Data.reason</c> is
|
||||
/// <c>stopped_waiting</c>: the agent's decision engine stopped waiting for the user — a
|
||||
/// bounded wait expired (D-310, <c>Data.stopReason</c> names it, e.g. no sign-in after the
|
||||
/// OOBE update). Same contract as <see cref="IsMaxLifetimeAgentShutdown"/>: the last event
|
||||
/// of the session, no enrollment verdict — the backend classifies it honestly.
|
||||
/// </summary>
|
||||
internal static bool IsStoppedWaitingAgentShutdown(EnrollmentEvent? evt) =>
|
||||
string.Equals(AgentShutdownReason(evt), "stopped_waiting", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>The agent gave up waiting for good — either shutdown shape above.</summary>
|
||||
internal static bool IsAgentGaveUpShutdown(EnrollmentEvent? evt) =>
|
||||
IsMaxLifetimeAgentShutdown(evt) || IsStoppedWaitingAgentShutdown(evt);
|
||||
|
||||
private static string? AgentShutdownReason(EnrollmentEvent? evt)
|
||||
{
|
||||
if (evt == null || !string.Equals(evt.EventType, "agent_shutting_down", StringComparison.Ordinal))
|
||||
return false;
|
||||
var reason = evt.Data != null && evt.Data.ContainsKey("reason")
|
||||
return null;
|
||||
return evt.Data != null && evt.Data.ContainsKey("reason")
|
||||
? evt.Data["reason"]?.ToString()
|
||||
: null;
|
||||
return string.Equals(reason, "max_lifetime", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -227,20 +245,24 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared verdict path for both max-lifetime shapes (enrollment_failed/agent_timeout and
|
||||
/// agent_shutting_down/max_lifetime): classify the session honestly via
|
||||
/// <see cref="EnrollmentTimeoutClassifier"/> instead of hard-failing it. The watchdog
|
||||
/// firing only proves the agent stopped waiting — the enrollment itself may be fully
|
||||
/// Shared verdict path for every "the agent gave up waiting" shape — the max-lifetime
|
||||
/// watchdog (enrollment_failed/agent_timeout, agent_shutting_down/max_lifetime) and the
|
||||
/// engine's bounded wait (agent_shutting_down/stopped_waiting, D-310): classify the session
|
||||
/// honestly via <see cref="EnrollmentTimeoutClassifier"/> instead of hard-failing it. The
|
||||
/// agent stopping only proves it stopped waiting — the enrollment itself may be fully
|
||||
/// provisioned with the user simply absent (AwaitingUser), silently dead (Incomplete),
|
||||
/// or even provably finished (Succeeded reconcile). Mirrors the maintenance sweep's
|
||||
/// stage-2 classification (misclassification audit 2026-07-16).
|
||||
/// Returns (transitioned, failureReason) where failureReason is only set for the
|
||||
/// terminal non-success outcomes so downstream failure notifications stay accurate.
|
||||
/// </summary>
|
||||
private async Task<(bool transitioned, string? failureReason)> ApplyMaxLifetimeVerdictAsync(
|
||||
private async Task<(bool transitioned, string? failureReason)> ApplyAgentGaveUpVerdictAsync(
|
||||
IngestEventsRequest request, string sessionPrefix, EventClassification c,
|
||||
EnrollmentEvent triggerEvent, SessionStatus? preFetchedStatus)
|
||||
{
|
||||
var stoppedWaiting = IsStoppedWaitingAgentShutdown(triggerEvent);
|
||||
var trigger = stoppedWaiting ? "stopped_waiting" : "max_lifetime";
|
||||
|
||||
var session = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId);
|
||||
var currentStatus = session?.Status ?? preFetchedStatus;
|
||||
|
||||
@@ -249,7 +271,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (currentStatus == SessionStatus.Pending)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"{SessionPrefix} max_lifetime verdict skipped — session is Pending (WhiteGlove)", sessionPrefix);
|
||||
"{SessionPrefix} {Trigger} verdict skipped — session is Pending (WhiteGlove)", sessionPrefix, trigger);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
@@ -266,7 +288,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
catch (Exception readEx)
|
||||
{
|
||||
_logger.LogWarning(readEx,
|
||||
"{SessionPrefix} failed to read events for max_lifetime classification; proceeding with batch only", sessionPrefix);
|
||||
"{SessionPrefix} failed to read events for {Trigger} classification; proceeding with batch only", sessionPrefix, trigger);
|
||||
sessionEvents = new List<EnrollmentEvent>();
|
||||
}
|
||||
|
||||
@@ -279,7 +301,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
catch (Exception cfgEx)
|
||||
{
|
||||
_logger.LogWarning(cfgEx, "{SessionPrefix} failed to read tenant config for max_lifetime classification; using defaults", sessionPrefix);
|
||||
_logger.LogWarning(cfgEx, "{SessionPrefix} failed to read tenant config for {Trigger} classification; using defaults", sessionPrefix, trigger);
|
||||
}
|
||||
var graceHours = EnrollmentTimeoutClassifier.ResolveGraceHours(tenantGraceHours, absoluteMaxHours);
|
||||
|
||||
@@ -289,7 +311,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var effectiveStart = session?.ResumedAt ?? session?.StartedAt ?? triggerEvent.Timestamp;
|
||||
|
||||
// The trigger event itself may not be persisted yet when this read runs — append it so
|
||||
// the rollup always carries the max-lifetime fact (rule 5 skips the AwaitingUser grace
|
||||
// the rollup always carries the agent-gone fact (rule 5 skips the AwaitingUser grace
|
||||
// on it: the agent is provably gone). The rollup folds booleans, so a stored duplicate
|
||||
// is harmless.
|
||||
sessionEvents.Add(triggerEvent);
|
||||
@@ -300,14 +322,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
isPreProvisioned: session?.IsPreProvisioned == true, resumedAt: session?.ResumedAt,
|
||||
isSelfDeployingProfile: session?.IsSelfDeployingProfile == true);
|
||||
|
||||
// Keep the max-lifetime trigger visible: the Succeeded reasons already carry their own
|
||||
// silence-transparency clause, everything else gets the watchdog context appended.
|
||||
// Keep the trigger visible: the Succeeded reasons already carry their own
|
||||
// silence-transparency clause, everything else names what stopped the agent.
|
||||
if (targetStatus != SessionStatus.Succeeded)
|
||||
reason += " Verdict triggered by the agent's max-lifetime watchdog shutdown.";
|
||||
{
|
||||
reason += stoppedWaiting
|
||||
? " " + StoppedWaitingReasons.Describe(triggerEvent.Data != null && triggerEvent.Data.ContainsKey("stopReason")
|
||||
? triggerEvent.Data["stopReason"]?.ToString()
|
||||
: null)
|
||||
: " Verdict triggered by the agent's max-lifetime watchdog shutdown.";
|
||||
}
|
||||
|
||||
if (targetStatus == currentStatus)
|
||||
{
|
||||
_logger.LogInformation("{SessionPrefix} max_lifetime verdict {Status} equals current status — no transition", sessionPrefix, targetStatus);
|
||||
_logger.LogInformation("{SessionPrefix} {Trigger} verdict {Status} equals current status — no transition", sessionPrefix, trigger, targetStatus);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
@@ -319,20 +347,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
try { snapshotJson = FailureSnapshotBuilder.Build(sessionEvents, now); }
|
||||
catch (Exception snapEx)
|
||||
{
|
||||
_logger.LogWarning(snapEx, "{SessionPrefix} failed to build failure snapshot for max_lifetime verdict", sessionPrefix);
|
||||
_logger.LogWarning(snapEx, "{SessionPrefix} failed to build failure snapshot for {Trigger} verdict", sessionPrefix, trigger);
|
||||
}
|
||||
}
|
||||
|
||||
var origin = stoppedWaiting ? VerdictPaths.OriginStoppedWaiting : VerdictPaths.OriginMaxLifetime;
|
||||
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||||
request.TenantId, request.SessionId, targetStatus, VerdictPaths.Compose(VerdictPaths.OriginMaxLifetime, rule),
|
||||
request.TenantId, request.SessionId, targetStatus, VerdictPaths.Compose(origin, rule),
|
||||
triggerEvent.Phase, reason,
|
||||
completedAt: isTerminalNonSuccess ? triggerEvent.Timestamp : (DateTime?)null,
|
||||
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
|
||||
failureSource: isTerminalNonSuccess ? "max_lifetime_watchdog" : null,
|
||||
failureSource: isTerminalNonSuccess ? (stoppedWaiting ? "stopped_waiting" : "max_lifetime_watchdog") : null,
|
||||
failureSnapshotJson: snapshotJson);
|
||||
|
||||
_logger.LogWarning("{SessionPrefix} Status: {Status} via max_lifetime honest classification - {Reason} (transitioned={Transitioned})",
|
||||
sessionPrefix, targetStatus, reason, transitioned);
|
||||
_logger.LogWarning("{SessionPrefix} Status: {Status} via {Trigger} honest classification - {Reason} (transitioned={Transitioned})",
|
||||
sessionPrefix, targetStatus, trigger, reason, transitioned);
|
||||
|
||||
return (transitioned, isTerminalNonSuccess ? reason : null);
|
||||
}
|
||||
@@ -451,6 +480,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// observation too — a device that slept through its stall must stay Stalled.
|
||||
"system_clock_changed" => true,
|
||||
"system_sleep_episode" => true,
|
||||
// A platform run corrected after the fact from IME's saved result (D-316): registry
|
||||
// observation of an earlier run, not progress — a stalled session stays Stalled.
|
||||
Constants.EventTypes.ScriptOutputReconciliation => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
@@ -495,7 +527,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// in (misclassification audit 2026-07-16, tenant a53e67ec: honest verdict
|
||||
// AwaitingUser). Route through the same honest classification the maintenance
|
||||
// sweep uses instead.
|
||||
(statusTransitioned, failureReason) = await ApplyMaxLifetimeVerdictAsync(
|
||||
(statusTransitioned, failureReason) = await ApplyAgentGaveUpVerdictAsync(
|
||||
request, sessionPrefix, c, c.FailureEvent, preFetchedStatus);
|
||||
}
|
||||
else
|
||||
@@ -608,10 +640,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_logger.LogInformation("{SessionPrefix} WhiteGlove resumed skipped, session already {Status}", sessionPrefix, currentSession?.Status);
|
||||
}
|
||||
}
|
||||
else if (c.AgentMaxLifetimeShutdownEvent != null)
|
||||
else if (c.AgentGaveUpShutdownEvent != null)
|
||||
{
|
||||
// Lowest-priority status writer (session 8bc1180f): the V2 max-lifetime
|
||||
// watchdog stops the agent permanently without a session verdict — this
|
||||
// watchdog — or the engine's bounded wait for the user (D-310, stopped_waiting) —
|
||||
// stops the agent permanently without a session verdict — this
|
||||
// shutdown event is the last one the session ever sends, so without this
|
||||
// mapping the session stays InProgress forever. Any genuine terminal event
|
||||
// in the same batch wins via the else-if chain above; an already-terminal
|
||||
@@ -621,8 +654,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// verdict (misclassification audit 2026-07-16). Pending (WhiteGlove) sessions
|
||||
// are skipped inside the helper — they are deliberately long-lived and resume
|
||||
// via re-registration.
|
||||
(statusTransitioned, failureReason) = await ApplyMaxLifetimeVerdictAsync(
|
||||
request, sessionPrefix, c, c.AgentMaxLifetimeShutdownEvent, preFetchedStatus);
|
||||
(statusTransitioned, failureReason) = await ApplyAgentGaveUpVerdictAsync(
|
||||
request, sessionPrefix, c, c.AgentGaveUpShutdownEvent, preFetchedStatus);
|
||||
}
|
||||
|
||||
if (c.WhiteGloveStartedEvent != null)
|
||||
@@ -660,7 +693,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var batchWroteStatus = c.CompletionEvent != null || c.FailureEvent != null
|
||||
|| c.EspFailureEvent != null || c.GatherCompletionEvent != null
|
||||
|| c.WhiteGloveEvent != null || c.WhiteGloveResumedEvent != null
|
||||
|| c.AgentMaxLifetimeShutdownEvent != null;
|
||||
|| c.AgentGaveUpShutdownEvent != null;
|
||||
var currentStatus = !batchWroteStatus && preFetchedStatus.HasValue
|
||||
? preFetchedStatus
|
||||
: (await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId))?.Status;
|
||||
@@ -717,12 +750,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
public EnrollmentEvent? AgentShutdownEvent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>agent_shutting_down</c> with <c>Data.reason == "max_lifetime"</c> — the V2
|
||||
/// watchdog shutdown that deliberately carries no enrollment verdict. Mapped to a
|
||||
/// terminal Failed status as the lowest-priority status writer so the session does
|
||||
/// not stay InProgress forever (session 8bc1180f).
|
||||
/// <c>agent_shutting_down</c> with <c>Data.reason</c> <c>max_lifetime</c> (the V2
|
||||
/// watchdog, session 8bc1180f) or <c>stopped_waiting</c> (the engine's bounded wait,
|
||||
/// D-310) — shutdowns that deliberately carry no enrollment verdict. Classified honestly
|
||||
/// as the lowest-priority status writer so the session does not stay InProgress forever.
|
||||
/// </summary>
|
||||
public EnrollmentEvent? AgentMaxLifetimeShutdownEvent { get; set; }
|
||||
public EnrollmentEvent? AgentGaveUpShutdownEvent { get; set; }
|
||||
public bool HasNonPeriodicRealEvent { get; set; }
|
||||
public EnrollmentEvent? DeviceLocationEvent { get; set; }
|
||||
public DateTime? EarliestEventTimestamp { get; set; }
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
// A failure alert requires an actual failure-ish verdict: an agent_timeout
|
||||
// enrollment_failed can honestly classify to AwaitingUser or even Succeeded
|
||||
// (ApplyMaxLifetimeVerdictAsync), in which case failureReason stays null and no
|
||||
// (ApplyAgentGaveUpVerdictAsync), in which case failureReason stays null and no
|
||||
// failure notification must go out for a session that did not fail.
|
||||
var failureVerdictApplies = c.FailureEvent != null && failureReason != null;
|
||||
if (statusTransitioned && (c.CompletionEvent != null || failureVerdictApplies))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
@@ -405,7 +406,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// contributes no per-app intervals); excluded and missing ones are counted, never
|
||||
/// silently dropped (rule 7).
|
||||
/// Rows are written even below the ≥20 UI gate — the UI needs the n (rule 4). Segment
|
||||
/// stats always carry the five canonical segments + unattributed (a session without a
|
||||
/// stats always carry the seven canonical segments + unattributed (a session without a
|
||||
/// span of a segment contributes 0 — the honest "per enrollment of this class" answer).
|
||||
/// Per-app rows gate at ≥5 sessions, order by median interval, cap 20.
|
||||
/// </summary>
|
||||
@@ -468,6 +469,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
TimeAttributionSegments.DevicePrep,
|
||||
TimeAttributionSegments.EspApps,
|
||||
TimeAttributionSegments.OsUpdate,
|
||||
TimeAttributionSegments.AwaitingSignIn,
|
||||
TimeAttributionSegments.IdentityHello,
|
||||
TimeAttributionSegments.UserEsp,
|
||||
TimeAttributionSegments.DesktopHandoff,
|
||||
@@ -793,7 +796,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var containerClient = new BlobContainerClient(new Uri(containerSasUrl));
|
||||
var generatedAtUtc = DateTime.UtcNow;
|
||||
var versionedFileName = $"platform-stats.{generatedAtUtc:yyyy-MM-dd}.json";
|
||||
var versionedFileName = PlatformStatsVersionedFileName(generatedAtUtc);
|
||||
|
||||
var versionedPayload = new
|
||||
{
|
||||
@@ -829,19 +832,34 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Name of the versioned stats file of one publish. The file is served immutable for a year
|
||||
/// (<see cref="PlatformStatsVersionedCacheControl"/>), so a name must never be written twice:
|
||||
/// maintenance publishes every 2 hours, and the per-day name of the once-a-night schedule was
|
||||
/// rewritten up to 12 times a day under that header — caches kept the first copy of the day.
|
||||
/// Browsers revalidate only the manifest, which names the current file.
|
||||
/// </summary>
|
||||
internal static string PlatformStatsVersionedFileName(DateTime generatedAtUtc) =>
|
||||
$"platform-stats.{generatedAtUtc.ToString("yyyy-MM-dd'T'HHmmss'Z'", CultureInfo.InvariantCulture)}.json";
|
||||
|
||||
private async Task UploadJsonBlobAsync(BlobContainerClient containerClient, string blobName, object payload, string cacheControl)
|
||||
{
|
||||
var blobClient = containerClient.GetBlobClient(blobName);
|
||||
var json = JsonConvert.SerializeObject(payload);
|
||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
await blobClient.UploadAsync(stream, overwrite: true);
|
||||
await blobClient.SetHttpHeadersAsync(new BlobHttpHeaders
|
||||
// Headers and tier travel with the upload, which still overwrites (the manifest relies on
|
||||
// it). Set in follow-up calls, the blob was briefly served without Cache-Control and
|
||||
// content type, and stayed that way when a follow-up call failed.
|
||||
await blobClient.UploadAsync(stream, new BlobUploadOptions
|
||||
{
|
||||
ContentType = "application/json; charset=utf-8",
|
||||
CacheControl = cacheControl
|
||||
HttpHeaders = new BlobHttpHeaders
|
||||
{
|
||||
ContentType = "application/json; charset=utf-8",
|
||||
CacheControl = cacheControl
|
||||
},
|
||||
AccessTier = AccessTier.Hot
|
||||
});
|
||||
await blobClient.SetAccessTierAsync(AccessTier.Hot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -60,6 +60,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
private const string PlatformStatsAliasFileName = "platform-stats.json";
|
||||
private const string PlatformStatsAliasCacheControl = "public, max-age=300, stale-while-revalidate=86400";
|
||||
// Holds only because every publish writes a new name (PlatformStatsVersionedFileName).
|
||||
private const string PlatformStatsVersionedCacheControl = "public, max-age=31536000, immutable";
|
||||
|
||||
public MaintenanceService(
|
||||
|
||||
@@ -1144,6 +1144,31 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return new RawPage<SessionSummary>(page.Sessions, page.HasMore ? page.NextCursor : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key-only existence probe: the tenant's SessionsIndex partition first, then the primary
|
||||
/// table (the page read's pre-migration fallback). No catch — a failed read must never
|
||||
/// answer "no sessions".
|
||||
/// </summary>
|
||||
public async Task<bool> HasAnySessionStrictAsync(string tenantId)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
var filter = $"PartitionKey eq '{tenantId}'";
|
||||
var keysOnly = new[] { "PartitionKey", "RowKey" };
|
||||
foreach (var table in new[] { Constants.TableNames.SessionsIndex, Constants.TableNames.Sessions })
|
||||
{
|
||||
var rows = _tableServiceClient.GetTableClient(table)
|
||||
.QueryAsync<TableEntity>(filter: filter, maxPerPage: 1, select: keysOnly)
|
||||
.GetAsyncEnumerator();
|
||||
await using (rows)
|
||||
{
|
||||
if (await rows.MoveNextAsync())
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cross-tenant paged variant of <see cref="GetAllSessionsAsync"/>.
|
||||
/// <paramref name="tenantIdFilter"/> optionally restricts to one tenant.
|
||||
@@ -3515,18 +3540,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event sources whose rows never anchor the session. The system timeline watcher
|
||||
/// backfills environment observations (clock steps, sleep episodes) from the Windows
|
||||
/// event log with their ORIGINAL timestamps — up to 24h before the agent started.
|
||||
/// They belong on the timeline, but they are not enrollment activity: anchoring on
|
||||
/// them silently stretches StartedAt/DurationSeconds toward pre-enrollment OOBE idle
|
||||
/// time (field case d0c5b672: a pre-agent clock correction pulled StartedAt ~14min
|
||||
/// before the first real activity; the 2h backward-shift guard only caps, not
|
||||
/// prevents, this). User decision 2026-08-21: the session anchor stays on real
|
||||
/// enrollment activity.
|
||||
/// Whether an event row may anchor the session (<see cref="Constants.EventSources.SessionAnchorIneligible"/>).
|
||||
/// Backfilled environment observations carry their ORIGINAL event time, up to 24h before
|
||||
/// the agent started; anchoring on them silently stretches StartedAt/DurationSeconds into
|
||||
/// pre-enrollment time (field case d0c5b672: a pre-agent clock correction, ~14 min; the 2h
|
||||
/// backward-shift guard only caps this). User decision 2026-08-21: the session anchor
|
||||
/// stays on real enrollment activity.
|
||||
/// </summary>
|
||||
internal static bool IsSessionAnchorEligible(TableEntity eventRow)
|
||||
=> eventRow.GetString("Source") != "SystemTimelineWatcher";
|
||||
=> Constants.EventSources.IsSessionAnchorEligible(eventRow.GetString("Source"));
|
||||
|
||||
/// <summary>
|
||||
/// Returns the earliest ANCHOR-ELIGIBLE event timestamp persisted for a session, if
|
||||
|
||||
@@ -123,6 +123,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["SegmentsJson"] = JsonSerializer.Serialize(b.Segments),
|
||||
["RebootSpansJson"] = JsonSerializer.Serialize(b.RebootSpans),
|
||||
["SleepSpansJson"] = JsonSerializer.Serialize(b.SleepSpans),
|
||||
["OsUpdatesJson"] = JsonSerializer.Serialize(b.OsUpdates),
|
||||
["BlockingAppsJson"] = JsonSerializer.Serialize(b.BlockingApps),
|
||||
["ComputedAt"] = DateTime.UtcNow,
|
||||
};
|
||||
@@ -151,6 +152,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
Segments = DeserializeJsonColumn<TimeAttributionSpan>(entity.GetString("SegmentsJson")),
|
||||
RebootSpans = DeserializeJsonColumn<RebootSpan>(entity.GetString("RebootSpansJson")),
|
||||
SleepSpans = DeserializeJsonColumn<SleepSpan>(entity.GetString("SleepSpansJson")),
|
||||
OsUpdates = DeserializeJsonColumn<OsUpdateSpan>(entity.GetString("OsUpdatesJson")),
|
||||
BlockingApps = DeserializeJsonColumn<BlockingAppInterval>(entity.GetString("BlockingAppsJson")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadDocsCorpus, parseFrontmatter, docUrl, slugify, docSections } from '../docs-corpus.js';
|
||||
import { loadDocsCorpus, parseFrontmatter, docUrl, slugify, docSections, summaryFolderSlugs } from '../docs-corpus.js';
|
||||
|
||||
async function withBundle<T>(
|
||||
files: Record<string, string>,
|
||||
@@ -60,9 +60,50 @@ describe('parseFrontmatter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const SUMMARY = [
|
||||
'# Table of contents',
|
||||
'',
|
||||
'* [Introduction](README.md)',
|
||||
'',
|
||||
'## Concepts',
|
||||
'',
|
||||
'* [Roles & Permissions](concepts/roles-and-permissions.md)',
|
||||
'',
|
||||
'## Trust & Security',
|
||||
'',
|
||||
'* [Security & Privacy FAQ](trust/security-faq.md)',
|
||||
'',
|
||||
'## Troubleshooting & Support',
|
||||
'',
|
||||
'* [FAQ](troubleshooting/faq.md)',
|
||||
'* [How to Purchase](troubleshooting-and-support/how-to-purchase/README.md)',
|
||||
' * [Cleverbridge](troubleshooting-and-support/how-to-purchase/cleverbridge.md)',
|
||||
].join('\r\n');
|
||||
|
||||
describe('summaryFolderSlugs', () => {
|
||||
it('maps only the folders GitBook publishes under a different group slug', () => {
|
||||
expect(summaryFolderSlugs(SUMMARY)).toEqual(
|
||||
new Map([
|
||||
['trust', 'trust-and-security'],
|
||||
['troubleshooting', 'troubleshooting-and-support'],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('docUrl', () => {
|
||||
it('maps a page path to its published URL', () => {
|
||||
expect(docUrl('trust/security-faq.md')).toBe('https://docs.autopilotmonitor.com/trust/security-faq');
|
||||
expect(docUrl('concepts/roles-and-permissions.md')).toBe('https://docs.autopilotmonitor.com/concepts/roles-and-permissions');
|
||||
});
|
||||
|
||||
it('publishes a page under its SUMMARY group slug, not its folder', () => {
|
||||
// The folder URL of a page created after the group rename is a 404 (measured 2026-10-02).
|
||||
const slugs = summaryFolderSlugs(SUMMARY);
|
||||
expect(docUrl('troubleshooting/faq.md', slugs)).toBe('https://docs.autopilotmonitor.com/troubleshooting-and-support/faq');
|
||||
expect(docUrl('trust/security-faq.md', slugs)).toBe('https://docs.autopilotmonitor.com/trust-and-security/security-faq');
|
||||
expect(docUrl('troubleshooting-and-support/how-to-purchase/README.md', slugs)).toBe(
|
||||
'https://docs.autopilotmonitor.com/troubleshooting-and-support/how-to-purchase',
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses README.md to its folder', () => {
|
||||
@@ -84,6 +125,22 @@ describe('slugify', () => {
|
||||
describe('loadDocsCorpus', () => {
|
||||
const page = (fm: string, body: string) => `---\n${fm}\n---\n\n${body}`;
|
||||
|
||||
it('cites the URL GitBook publishes, read from SUMMARY.md', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'SUMMARY.md': SUMMARY,
|
||||
'troubleshooting/faq.md': page('type: Troubleshooting', ['# FAQ', '', '## Who is my admin?', '', 'Ask the team that runs Intune or device enrollment in your organization.'].join('\n')),
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
|
||||
expect(chunks.map((c) => c.metadata.url)).toContain(
|
||||
'https://docs.autopilotmonitor.com/troubleshooting-and-support/faq#who-is-my-admin',
|
||||
);
|
||||
// The section filter keeps the folder taxonomy.
|
||||
expect(chunks[0].metadata.section).toBe('troubleshooting');
|
||||
});
|
||||
|
||||
it('splits on h2/h3 headings and carries a breadcrumb', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* D-316: a platform run whose script_failed came from another script's end block is corrected by a later
|
||||
* script_output_reconciliation (outcome "foreign", IME result Success). get_session_summary must not count the
|
||||
* stored script_failed as an error, must not rank it as one and must not attach the foreign exit code's catalog
|
||||
* text; the correction itself stays a Warning key event.
|
||||
*
|
||||
* Pure unit tests: fetch is stubbed and the registered tool handler is invoked directly.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { registerTools } from '../tools.js';
|
||||
import { runWithCaller } from '../client.js';
|
||||
import { foreignSuccessRunIds, isCorrectedForeignFailure } from '../tools/shared.js';
|
||||
|
||||
type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }>;
|
||||
|
||||
const SESSION = 'e259c121-1234-4abc-9def-0123456789ab';
|
||||
const GA = { token: 'ga', isGlobalAdmin: true };
|
||||
const extra = { signal: new AbortController().signal };
|
||||
|
||||
function summaryHandler(): ToolHandler {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, undefined, true, true, false);
|
||||
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
|
||||
return registry['get_session_summary'].handler;
|
||||
}
|
||||
|
||||
function stubBackend(events: unknown[]): void {
|
||||
const body = {
|
||||
success: true,
|
||||
session: { sessionId: SESSION, tenantId: 't', status: 'Succeeded', startedAt: '2026-10-03T20:49:00Z' },
|
||||
events,
|
||||
results: [],
|
||||
annotations: [],
|
||||
count: events.length,
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) }) as unknown as Response));
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const foreignFailure = {
|
||||
timestamp: '2026-10-03T20:59:04Z',
|
||||
eventType: 'script_failed',
|
||||
severity: 'Error',
|
||||
source: 'ImeLogTracker',
|
||||
message: 'Platform script d94468af: Success (exit: 1)',
|
||||
data: { scriptType: 'platform', result: 'Success', exitCode: '1', runId: 'run-1' },
|
||||
};
|
||||
|
||||
const correction = (data: Record<string, unknown>) => ({
|
||||
timestamp: '2026-10-03T20:59:08Z',
|
||||
eventType: 'script_output_reconciliation',
|
||||
severity: 'Warning',
|
||||
source: 'RegistryScriptResult',
|
||||
message: "Platform script d94468af: output corrected from IME's saved result",
|
||||
data: { scriptType: 'platform', outcome: 'foreign', result: 'Success', runId: 'run-1', ...data },
|
||||
});
|
||||
|
||||
async function summary(events: unknown[]): Promise<Record<string, any>> {
|
||||
stubBackend(events);
|
||||
const result = await runWithCaller(GA, () => summaryHandler()({ sessionId: SESSION }, extra));
|
||||
return JSON.parse((result.content ?? []).map((c) => c.text ?? '').join(''));
|
||||
}
|
||||
|
||||
describe('get_session_summary with a corrected platform run', () => {
|
||||
it('does not count the foreign script_failed as an error and drops its exit-code text', async () => {
|
||||
const body = await summary([foreignFailure, correction({})]);
|
||||
|
||||
expect(body.stats.errorCount).toBe(0);
|
||||
expect(body.stats.warningCount).toBe(1);
|
||||
const failed = body.keyEvents.find((e: { eventType: string }) => e.eventType === 'script_failed');
|
||||
expect(failed.correctedByImeSavedResult).toBe(true);
|
||||
expect(failed.errorCode).toBeUndefined();
|
||||
expect(body.keyEvents.some((e: { eventType: string }) => e.eventType === 'script_output_reconciliation')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps counting a failure IME itself reported, and an uncorrected one', async () => {
|
||||
expect((await summary([foreignFailure, correction({ result: 'Failed' })])).stats.errorCount).toBe(1);
|
||||
expect((await summary([foreignFailure, correction({ outcome: 'repaired' })])).stats.errorCount).toBe(1);
|
||||
expect((await summary([foreignFailure, correction({ runId: 'another-run' })])).stats.errorCount).toBe(1);
|
||||
expect((await summary([foreignFailure])).stats.errorCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('foreignSuccessRunIds / isCorrectedForeignFailure', () => {
|
||||
it('names only foreign corrections whose IME result is Success, and only script_failed rows', () => {
|
||||
const runs = foreignSuccessRunIds([correction({}), correction({ runId: 'r2', result: 'Failed' }), correction({ runId: 'r3', outcome: 'repaired' })]);
|
||||
expect([...runs]).toEqual(['run-1']);
|
||||
expect(isCorrectedForeignFailure('script_failed', { runId: 'run-1' }, runs)).toBe(true);
|
||||
expect(isCorrectedForeignFailure('script_completed', { runId: 'run-1' }, runs)).toBe(false);
|
||||
expect(isCorrectedForeignFailure('script_failed', { runId: 'r2' }, runs)).toBe(false);
|
||||
expect(isCorrectedForeignFailure('script_failed', undefined, runs)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -188,21 +188,49 @@ export function slugify(text: string): string {
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level folder → URL segment, read from SUMMARY.md. GitBook publishes a page under
|
||||
* the slug of the `## Group` heading that lists it, not under its folder:
|
||||
* `troubleshooting/faq.md` below "Troubleshooting & Support" is
|
||||
* `/troubleshooting-and-support/faq`. The folder URL of a page that predates a group
|
||||
* rename still redirects; for a newer page it is a 404. Only folders whose slug differs
|
||||
* are returned.
|
||||
*/
|
||||
export function summaryFolderSlugs(summary: string): Map<string, string> {
|
||||
const slugs = new Map<string, string>();
|
||||
let group: string | undefined;
|
||||
for (const line of normalizeNewlines(summary).split('\n')) {
|
||||
const heading = /^##\s+(.+)$/.exec(line.trim());
|
||||
if (heading) {
|
||||
group = heading[1].toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
continue;
|
||||
}
|
||||
const link = /\]\(([^)#\s]+\.md)\)/.exec(line);
|
||||
if (!group || !link || !link[1].includes('/')) continue;
|
||||
const folder = link[1].split('/')[0];
|
||||
if (folder !== group) slugs.set(folder, group);
|
||||
}
|
||||
return slugs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public GitBook URL for a bundle-relative path, or undefined when the file is
|
||||
* not published as a page.
|
||||
* not published as a page. `folderSlugs` (from {@link summaryFolderSlugs}) maps a
|
||||
* top-level folder to the segment GitBook publishes it under.
|
||||
*
|
||||
* `index.md` is an OKF reserved file that the bundle deliberately keeps out of
|
||||
* SUMMARY.md, so GitBook never renders it — emitting a link would hand callers a
|
||||
* 404. Its content is still indexed (it is the documentation map); it just has no
|
||||
* citable URL.
|
||||
*/
|
||||
export function docUrl(relPath: string): string | undefined {
|
||||
export function docUrl(relPath: string, folderSlugs: ReadonlyMap<string, string> = new Map()): string | undefined {
|
||||
const posix = relPath.split(sep).join('/');
|
||||
if (posix === 'index.md') return undefined;
|
||||
const withoutExt = posix.replace(/\.md$/, '');
|
||||
const path = withoutExt === 'README' ? '' : withoutExt.replace(/\/README$/, '');
|
||||
return path ? `${DOCS_BASE_URL}/${path}` : DOCS_BASE_URL;
|
||||
const [folder, ...rest] = path.split('/');
|
||||
const published = rest.length > 0 && folderSlugs.has(folder) ? [folderSlugs.get(folder), ...rest].join('/') : path;
|
||||
return published ? `${DOCS_BASE_URL}/${published}` : DOCS_BASE_URL;
|
||||
}
|
||||
|
||||
// ── Sectioning ───────────────────────────────────────────────
|
||||
@@ -407,6 +435,8 @@ export async function loadDocsCorpus(docsRoot: string): Promise<SearchDocument[]
|
||||
const files = (await walkMarkdown(docsRoot)).sort();
|
||||
const docs: SearchDocument[] = [];
|
||||
const usedIds = new Set<string>();
|
||||
// No SUMMARY.md (a partial local bundle): folder URLs, as GitBook would serve an unrenamed group.
|
||||
const folderSlugs = summaryFolderSlugs(await readFile(join(docsRoot, 'SUMMARY.md'), 'utf-8').catch(() => ''));
|
||||
|
||||
for (const relPath of files) {
|
||||
let raw: string;
|
||||
@@ -430,7 +460,7 @@ export async function loadDocsCorpus(docsRoot: string): Promise<SearchDocument[]
|
||||
// Top-level directory doubles as the bundle's section taxonomy
|
||||
// (getting-started, concepts, portal-guide, …). Root files are 'general'.
|
||||
const section = posixPath.includes('/') ? posixPath.split('/')[0] : 'general';
|
||||
const url = docUrl(relPath);
|
||||
const url = docUrl(relPath, folderSlugs);
|
||||
|
||||
// Drop the h1 line itself — it is carried in every chunk's header instead.
|
||||
const withoutH1 = body.replace(/^#\s+.+$/m, '');
|
||||
|
||||
@@ -1079,8 +1079,14 @@ export interface CollectorConfiguration {
|
||||
modernDeploymentBackfillLookbackMinutes: number;
|
||||
/** Master switch for the Windows Update watcher. When enabled the agent subscribes to Microsoft-Windows-WindowsUpdateClient/Operational and forwards update install/failure events (windows_update_succeeded / _failed / _started) so quality and cumulative updates that run DURING enrollment become visible with evidence. Default: true. */
|
||||
windowsUpdateWatcherEnabled: boolean;
|
||||
/** WindowsUpdateClient/Operational EventIDs to capture. Kept configurable so additional IDs can be enabled after real-device validation WITHOUT an agent redeploy. Verified set: 19=install success, 20=install failure (carries HRESULT), 43=install started, 44=download started. IDs whose semantics are still unverified (25/26/31, restart-required 21 vs 41) are intentionally excluded until confirmed on a device trace. Default: [19, 20, 43, 44]. */
|
||||
/** WindowsUpdateClient EventIDs the install watcher captures: 19=install success, 20=install failure (carries HRESULT), 43=install started, 44=download started. The provider manifest writes these to the System log, so the agent routes every ID to the channel the manifest assigns it (System: 16–24, 27, 28, 32, 33, 43, 44, 212–218; Operational: all others). Default: [19, 20, 43, 44]. */
|
||||
windowsUpdateTargetedEventIds: number[];
|
||||
/** WindowsUpdateClient/Operational EventIDs the scan/download watcher captures: 25=scan failed, 26=scan found N updates, 31=download failed, 41=update downloaded. Separate from WindowsUpdateTargetedEventIds on purpose: agents that predate the update classification ignore this field, so they never receive the high-volume Store/Defender download events unthrottled. Default: [25, 26, 31, 41]. */
|
||||
windowsUpdateOperationalEventIds: number[];
|
||||
/** Master switch for the servicing watcher on the Setup log (Microsoft-Windows-Servicing EventIDs 1/2/3/4/6): the CBS package steps of an OS update — staged, installed, reboot required, failed. Default: true. */
|
||||
servicingWatcherEnabled: boolean;
|
||||
/** Mode of the OOBE update phase: "Off", "Shadow" or "Active". Off: Windows/.NET update activity is only shown on the timeline. Shadow: the update watchers also feed it into the decision engine, which records the phase and reports at the post-ESP-exit completion window what the phase would decide; today's rule still decides. Active: the phase holds that window while Windows installs an update in OOBE and waits a bounded time for the sign-in after the update restart instead of failing the session. Any other value counts as Shadow. Default: "Shadow". */
|
||||
oobeUpdatePhaseMode: string;
|
||||
/** Lookback window in minutes for the Windows Update backfill scan on startup. OOBE quality updates run at the early "Getting updates" screen — often BEFORE the agent starts — but the WindowsUpdateClient/Operational .evtx persists across the mid-OOBE reboot, so a generous backfill catches them. Set higher than the ModernDeployment backfill because the update scan/download can begin well before MDM enrollment. 0 = backfill disabled. Default: 60 minutes. */
|
||||
windowsUpdateBackfillLookbackMinutes: number;
|
||||
/** Self-evidencing blind-spot census (session 7443317c: an OOBE quality update installed — OS build changed across the reboot — yet the WindowsUpdateClient channel carried NONE of the targeted EventIDs). When enabled and the OS build changed across an agent restart while zero targeted WU events were captured, the tracker emits a one-shot windows_update_channel_census event with an unfiltered EventID histogram of the WindowsUpdateClient AND UpdateOrchestrator channels over the backfill lookback window — field evidence for which EventIDs/channel to add to WindowsUpdateTargetedEventIds via remote config, without diagnostics upload access. Default: true. */
|
||||
@@ -3048,6 +3054,18 @@ export interface OpsEventListResponse {
|
||||
nextLink?: string;
|
||||
}
|
||||
|
||||
/** One OOBE quality update behind an OsUpdate span (D-310) — the interval and what the evidence names. Annotation like RebootSpan; the partition itself is in Segments. */
|
||||
export interface OsUpdateSpan {
|
||||
startUtc: string;
|
||||
endUtc: string;
|
||||
/** In-window seconds of the interval. */
|
||||
seconds: number;
|
||||
/** KB numbers of the packages Windows serviced in the interval ("KB5129195"), in order of first appearance; empty when no package named one. */
|
||||
kbs: string[];
|
||||
/** Restarts that began inside the interval. */
|
||||
rebootCount: number;
|
||||
}
|
||||
|
||||
/** Body of PATCH global/config: { "fields": { <fieldName>: <value>, ... } } with at least one field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take the field's own JSON type. */
|
||||
export interface PatchAdminConfigurationRequest {
|
||||
fields?: Record<string, unknown>;
|
||||
@@ -3225,6 +3243,15 @@ export interface ProgressLookupSessionResponse {
|
||||
session?: SessionSummary;
|
||||
}
|
||||
|
||||
/** Response of GET /api/progress/tenant-status — whether the caller's own organization looks unused, so the Progress Portal can tell a member without a role whom to ask for access. */
|
||||
export interface ProgressTenantStatusResponse {
|
||||
success: boolean;
|
||||
/** True when the organization holds no session and signed up more than 14 days ago. */
|
||||
unused: boolean;
|
||||
/** When the organization signed up; null (key omitted on the wire) when unknown. */
|
||||
signedUpAt?: string;
|
||||
}
|
||||
|
||||
/** Error body of POST /api/global/raw/logs when the telemetry store rejected or failed the query (400 for a caller-side KQL error, 502 for store/grant failures): the envelope prefix plus the store's own error code, HTTP status and — capped — its full response, so nothing the CLI would print is lost. hint tells the caller how to fix the query. */
|
||||
export interface QueryBackendLogsErrorResponse {
|
||||
error: string;
|
||||
@@ -4328,6 +4355,8 @@ export interface SessionTimeBreakdown {
|
||||
/** Total observed in-window sleep seconds. Cross-cutting annotation like RebootSeconds — the wall clock keeps the pause; this discloses it. */
|
||||
sleepSeconds: number;
|
||||
sleepSpans: SleepSpan[];
|
||||
/** The OOBE quality updates behind the OsUpdate spans (at most one per observation window). */
|
||||
osUpdates: OsUpdateSpan[];
|
||||
/** Install intervals of ESP-blocking apps (positive-evidence join against the latest esp_config_detected lists), top 20 by duration. BlockingAppCount carries the uncapped count of matched blocking apps (including those without a measurable interval, e.g. unobserved start). */
|
||||
blockingApps: BlockingAppInterval[];
|
||||
/** Uncapped count of apps matched against the blocking set (see BlockingApps). */
|
||||
@@ -4898,7 +4927,7 @@ export interface TenantConfiguration {
|
||||
unrestrictedModeEnabled: boolean;
|
||||
/** When effective, agent guardrails are relaxed: all registry paths, WMI queries, and commands are allowed via GatherRules. File paths and diagnostics paths are allowed except C:\Users. Default: false. Can only be toggled by tenant admins when UnrestrictedModeEnabled is true. EFFECTIVE only while the tenant's edition is Pro (read-time re-gate, fail-closed on trial expiry/downgrade) — see TenantEntitlementService.IsUnrestrictedModeActive. */
|
||||
unrestrictedMode: boolean;
|
||||
/** When enabled, tenant member roles (Admin / Operator) may also be granted via Entra ID app-role assignments on the application's Enterprise App (the "roles" claim in the user's token), in addition to the TenantAdmins table. Resolution is table-first: an explicit TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator CanManageBootstrapTokens). Only Admin and Operator are claim-mappable; the platform-wide GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in. Backend-only setting: not delivered to the agent (no ConfigVersion impact). */
|
||||
/** When enabled, tenant member roles (Admin / Operator / Viewer) may also be granted via Entra ID app-role assignments on the application's Enterprise App (the "roles" claim in the user's token), in addition to the TenantAdmins table. Resolution is table-first: an explicit TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator CanManageBootstrapTokens). The platform-wide GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in. Backend-only setting: not delivered to the agent (no ConfigVersion impact). */
|
||||
entraAppRolesEnabled: boolean;
|
||||
/** JSON-serialized list of tenant-specific additional log paths/wildcards to include in the diagnostics ZIP package (additive to global paths). Each entry: { "path": "...", "description": "...", "isBuiltIn": false } */
|
||||
diagnosticsLogPathsJson: string;
|
||||
|
||||
@@ -41,6 +41,8 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'esp_config_detected',
|
||||
'completion_check',
|
||||
'completion_waiting',
|
||||
'completion_wait_expired',
|
||||
'oobe_update_phase_shadow',
|
||||
'enrollment_complete',
|
||||
'enrollment_failed',
|
||||
'desktop_arrived',
|
||||
@@ -94,7 +96,7 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'office_install_failed',
|
||||
'office_preinstalled_detected',
|
||||
],
|
||||
script_events: ['script_started', 'script_completed', 'script_failed', 'script_timeout_suspected', 'script_recurrence_summary', 'historic_ime_replay_detected'],
|
||||
script_events: ['script_started', 'script_completed', 'script_failed', 'script_output_reconciliation', 'script_timeout_suspected', 'script_recurrence_summary', 'historic_ime_replay_detected'],
|
||||
network_events: [
|
||||
'network_state_change',
|
||||
'network_connectivity_check',
|
||||
@@ -162,6 +164,10 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'windows_update_succeeded',
|
||||
'windows_update_failed',
|
||||
'windows_update_started',
|
||||
'windows_update_servicing',
|
||||
'windows_update_activity_summary',
|
||||
'oobe_update_page',
|
||||
'oobe_update_state',
|
||||
'windows_update_reboot_pending',
|
||||
'windows_update_history',
|
||||
'os_build_changed',
|
||||
|
||||
@@ -1033,7 +1033,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
title: 'Verdict Calibration (classifier thermometer)',
|
||||
description:
|
||||
'Answer "which code path produced our session verdicts, and does any of them look wrong?". Per verdict ' +
|
||||
'path (agent:complete, agent:failed, sweep:r5_incomplete, maxlife:r6, late:r4, retro:r6, rule:<id>, ' +
|
||||
'path (agent:complete, agent:failed, sweep:r5_incomplete, maxlife:r6, stopwait:r5_incomplete, late:r4, retro:r6, rule:<id>, ' +
|
||||
'manual:failed, register:superseded, legacy:* for pre-instrumentation rows derived read-side): session ' +
|
||||
'count and share in the window, derivedCount (attributed by derivation — weaker evidence), the 7-day ' +
|
||||
're-enrollment proxy (eligible7d = terminal sessions old enough to judge, reEnrolled7d = the same device ' +
|
||||
@@ -2057,9 +2057,11 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
title: 'Enrollment Time Attribution',
|
||||
description:
|
||||
'Answer "where did the enrollment time go?". Two modes: pass sessionId for one terminal session\'s ' +
|
||||
'time breakdown — wall-clock partition into device_prep / esp_apps / identity_hello / user_esp / ' +
|
||||
'desktop_handoff spans plus an EXPLICIT unattributed remainder (sums are exact against the session\'s ' +
|
||||
'authoritative duration; the WhiteGlove pause is excluded by design), the ESP-blocking app install ' +
|
||||
'time breakdown — wall-clock partition into device_prep / esp_apps / os_update / awaiting_sign_in / ' +
|
||||
'identity_hello / user_esp / desktop_handoff spans plus an EXPLICIT unattributed remainder (sums are exact ' +
|
||||
'against the session\'s authoritative duration; the WhiteGlove pause is excluded by design; os_update = the ' +
|
||||
'OOBE quality update, its KBs and restarts in osUpdates; awaiting_sign_in = after that update\'s restart ' +
|
||||
'until the user is back), the ESP-blocking app install ' +
|
||||
'intervals with critical-path occupancy (an app\'s seconds = its ACTIVE install time summed across all ' +
|
||||
'IME passes/attempts — deliberately broader than get_app_install_metrics\' final-attempt duration, ' +
|
||||
'because a failed attempt and an evaluation pass still occupied the critical path), reboot outage ' +
|
||||
|
||||
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { z } from 'zod';
|
||||
import { ApiError, apiFetch, buildQuery, jsonBody, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, pageSizeForCall, pickGlobalOrTenantPath, scanUntilMatch, scanWithTimeoutFallback } from '../client.js';
|
||||
import { withToolTelemetry } from '../telemetry.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, foreignSuccessRunIds, isCorrectedForeignFailure, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import { lookupErrorCode } from '../error-code-catalog.js';
|
||||
import { withEventTypeNote, assertKnownDevicePropertyKeys } from '../resource-catalog.js';
|
||||
@@ -534,6 +534,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
// Observation coverage: what the agent could and could not see, folded from the
|
||||
// health/lifecycle events that the triage timeline below ranks low or drops.
|
||||
const coverage = buildSessionCoverage(s, allEvents);
|
||||
// Platform runs IME's saved result proved to carry another script's exit code (D-316).
|
||||
const foreignRuns = foreignSuccessRunIds(allEvents);
|
||||
|
||||
let errorCount = 0;
|
||||
let warningCount = 0;
|
||||
@@ -545,7 +547,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
const sev = String(e.severity ?? '');
|
||||
// A compliant health-script detection mis-stamped script_failed/Error is benign —
|
||||
// exclude it so a green session's errorCount isn't inflated by routine compliance reports.
|
||||
const benign = isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data);
|
||||
const benign = isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data)
|
||||
|| isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns);
|
||||
if (!benign && (sev === 'Error' || sev === 'Critical')) errorCount++;
|
||||
if (sev === 'Warning') warningCount++;
|
||||
const et = String(e.eventType ?? '');
|
||||
@@ -571,6 +574,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
const relevanceScore = (e: Partial<EnrollmentEvent>): number => {
|
||||
// Benign compliant detection mis-stamped Error → rank as info-level, not top.
|
||||
if (isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data)) return 10;
|
||||
// A failure that was another script's exit code is not a failure of this one.
|
||||
if (isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns)) return 10;
|
||||
const sev = SEVERITY_RANK[String(e.severity ?? '')] ?? -1;
|
||||
if (sev >= 3) return 100; // Error/Critical
|
||||
if (PHASE_EVENT_TYPES.has(String(e.eventType ?? ''))) return 60;
|
||||
@@ -597,7 +602,10 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
phase: phaseName(e.phase, s.enrollmentType),
|
||||
message: e.message,
|
||||
source: e.source,
|
||||
...keyEventErrorCode(e.data, e.source),
|
||||
// The exit code on a corrected run was another script's: no catalog text for it.
|
||||
...(isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns)
|
||||
? { correctedByImeSavedResult: true }
|
||||
: keyEventErrorCode(e.data, e.source)),
|
||||
}));
|
||||
|
||||
let analysis = null;
|
||||
|
||||
@@ -75,6 +75,8 @@ export function leanFieldSelection(
|
||||
export const SUMMARY_EVENT_FIELDS =
|
||||
'eventType,severity,source,phase,timestamp,message,sequence,' +
|
||||
'data.scriptType,data.script_type,data.scriptPart,data.script_part,data.result,' +
|
||||
// Platform-run corrections from IME's saved result (foreignSuccessRunIds below).
|
||||
'data.runId,data.outcome,' +
|
||||
'data.rejectedSourceTimestamp,data.rejected_source_timestamp,' +
|
||||
'data.errorCode,data.exitCode,data.hresult,data.hresultFromWin32,data.hresultSymbol,' +
|
||||
'data.errorCodeInfo,data.exitCodeInfo,data.hresultInfo,data.hresultFromWin32Info,' +
|
||||
@@ -125,6 +127,38 @@ export function isBenignHealthDetectionReport(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ids of platform runs whose `script_failed` came from another script's end block while IME
|
||||
* itself reported Success: `script_output_reconciliation` with outcome "foreign" and result Success
|
||||
* (D-316 — the agent had attributed a foreign executor's exit code and output; IME's saved result
|
||||
* proved it). The stored `script_failed` stays as written; read sides treat it like the benign
|
||||
* detection reports above: no error count, info-level rank, no exit-code catalog text.
|
||||
*/
|
||||
export function foreignSuccessRunIds(
|
||||
events: ReadonlyArray<{ eventType?: string | null; data?: Record<string, unknown> }>,
|
||||
): Set<string> {
|
||||
const runs = new Set<string>();
|
||||
for (const e of events) {
|
||||
if (e.eventType !== 'script_output_reconciliation' || !e.data) continue;
|
||||
if (String(e.data.outcome ?? '') !== 'foreign') continue;
|
||||
if (String(e.data.result ?? '').toLowerCase() !== 'success') continue;
|
||||
const runId = e.data.runId;
|
||||
if (typeof runId === 'string' && runId.length > 0) runs.add(runId);
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** True for a `script_failed` whose run `foreignSuccessRunIds` names. */
|
||||
export function isCorrectedForeignFailure(
|
||||
eventType: string | undefined,
|
||||
data: Record<string, unknown> | undefined,
|
||||
foreignRuns: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (eventType !== 'script_failed' || !data || foreignRuns.size === 0) return false;
|
||||
const runId = data.runId;
|
||||
return typeof runId === 'string' && foreignRuns.has(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the role-aware description for a tenant-boundable tool's `tenantId` argument. MCP clients weigh
|
||||
* the per-arg schema heavily, so a delegated (MSP) caller — for whom omitting tenantId is REJECTED, not
|
||||
|
||||
@@ -64,7 +64,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// 2026-07-10: a pre-sign-in IME AccountSetup line let a Device→Account handoff exit
|
||||
/// arm the window, which then failed a live enrollment mid-install): a reboot while
|
||||
/// armed cancels the window, and enforcement progress since arming re-arms it instead
|
||||
/// of failing.
|
||||
/// of failing. During the OOBE update phase (Windows/.NET update activity after the ESP
|
||||
/// exit, no real user yet — D-310) the window re-arms within the phase's own bounds: the
|
||||
/// update cap, the sign-in window after the update, one window after the sign-in.
|
||||
/// </summary>
|
||||
public const string AdvisoryCompletion = "advisory_completion";
|
||||
|
||||
@@ -201,6 +203,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
public const string HelloWaitTimeoutSeconds = "helloWaitTimeoutSeconds";
|
||||
|
||||
/// <summary>
|
||||
/// On <c>EnrollmentFactsObserved</c>: the mode of the OOBE update phase (remote config
|
||||
/// <c>OobeUpdatePhaseMode</c>, stamped by the agent at every start — <c>Off</c>,
|
||||
/// <c>Shadow</c> or <c>Active</c>, see <see cref="State.OobeUpdatePhaseModes"/>). Missing →
|
||||
/// the engine treats the phase as <c>Shadow</c>.
|
||||
/// </summary>
|
||||
public const string OobeUpdatePhaseMode = "oobeUpdatePhaseMode";
|
||||
|
||||
// --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------
|
||||
// Mirrors the EnrollmentEvent fields the reducer must reconstruct for the
|
||||
// EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are
|
||||
|
||||
@@ -34,6 +34,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
RecordOobeUpdateRestart(state, builder, signal);
|
||||
var rebaseEffects = RebaseEspExitVariantAdvisoryWindowOnReboot(state, builder, signal);
|
||||
|
||||
var newState = builder.Build();
|
||||
@@ -961,6 +962,79 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
return new DecisionStep(gateRearmedState, gateRearmedTransition, gateRearmedEffects);
|
||||
}
|
||||
|
||||
// OOBE update phase (D-310, report 3a2207978e4c). The arming exit was the Device-ESP
|
||||
// handoff and Windows has been installing its OOBE quality update since — 20–40 min
|
||||
// plus a restart to the lock screen, where the device then waits for the user. No
|
||||
// real user has signed in yet, so "the page closed and nothing happens" is not
|
||||
// the shape this window resolves. The phase holds the window, each state bounded on
|
||||
// its own (see DecisionEngine.OsUpdate.cs): while the update runs up to its cap, after
|
||||
// it for the sign-in window, and for one window after the sign-in so the user's own
|
||||
// setup (User ESP, Hello wizard) is not failed by a window re-armed against the
|
||||
// update. Each re-arm is clamped to the state's bound, so its expiry is decided on
|
||||
// time. Expired: a still-running update fails as oobe_update_not_finished below; an
|
||||
// ended update without a sign-in stops waiting — no verdict, the backend classifies.
|
||||
// The advisory variant is exempt for the same reason as above, unless the failure
|
||||
// recovered or this is the Continue-Anyway observation (the user must still continue
|
||||
// past the update page).
|
||||
//
|
||||
// Measurement before enforcement: only the Active mode lets the phase act. In Shadow
|
||||
// mode the evaluation is reported next to today's verdict below; Off ignores it.
|
||||
var oobePhaseMode = OobeUpdatePhaseModes.Normalize(state.ScenarioObservations.OobeUpdatePhaseMode?.Value);
|
||||
var evaluatedOobePhase = (!hasAdvisoryAnchor || advisoryResolved || isObservationAdvisory)
|
||||
&& oobePhaseMode != OobeUpdatePhaseModes.Off
|
||||
? EvaluateOobeUpdatePhase(state, EffectiveDeadlineBase(state, signal))
|
||||
: new OobeUpdatePhaseStatus(OobeUpdatePhaseKind.None);
|
||||
var oobePhase = oobePhaseMode == OobeUpdatePhaseModes.Active
|
||||
? evaluatedOobePhase
|
||||
: new OobeUpdatePhaseStatus(OobeUpdatePhaseKind.None);
|
||||
if (oobePhase.Holds)
|
||||
{
|
||||
var untilBound = oobePhase.BoundUtc!.Value - EffectiveDeadlineBase(state, signal);
|
||||
var phaseRearmedDeadline = BuildAdvisoryCompletionDeadline(state, signal,
|
||||
untilBound < s_advisoryCompletionWindow ? untilBound : s_advisoryCompletionWindow);
|
||||
builder.AddDeadline(phaseRearmedDeadline);
|
||||
|
||||
var (triggerSuffix, message) = DescribeOobeUpdateHold(oobePhase.Kind);
|
||||
var phaseTrigger = $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}:{triggerSuffix}";
|
||||
var phaseRearmedState = builder.Build();
|
||||
var phaseRearmedTransition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: phaseTrigger);
|
||||
|
||||
var phaseParameters = new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = SharedConstants.EventTypes.CompletionWaiting,
|
||||
["source"] = "DecisionEngine",
|
||||
["severity"] = "Info",
|
||||
["immediateUpload"] = "false",
|
||||
["message"] = message,
|
||||
["missingPrerequisites"] = string.Join(",", BuildMissingCompletionPrerequisites(state)),
|
||||
["trigger"] = phaseTrigger,
|
||||
["stage"] = state.Stage.ToString(),
|
||||
["resolutionDeadlineDueAtUtc"] = phaseRearmedDeadline.DueAtUtc.ToString("o"),
|
||||
["oobeUpdateHoldsUntilUtc"] = oobePhase.BoundUtc.Value.ToString("o"),
|
||||
};
|
||||
AddOobeUpdateContext(phaseParameters, state, oobePhase.UpdateEndedUtc);
|
||||
|
||||
// Same deliberate dedupe bypass as the re-arm sites above — bounded to one event
|
||||
// per window; the phase's own bounds cap the count.
|
||||
var phaseRearmedEffects = new[]
|
||||
{
|
||||
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: phaseRearmedDeadline),
|
||||
new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry, parameters: phaseParameters),
|
||||
};
|
||||
|
||||
return new DecisionStep(phaseRearmedState, phaseRearmedTransition, phaseRearmedEffects);
|
||||
}
|
||||
|
||||
if (oobePhase.Kind == OobeUpdatePhaseKind.SignInWaitExpired)
|
||||
{
|
||||
return BuildOobeUpdateStopWaitingStep(state, builder, signal, nextStep, oobePhase);
|
||||
}
|
||||
|
||||
// Conjunction not met — the session is failed. The two arming variants carry
|
||||
// distinct context (see method doc): the advisory variant un-defangs the original
|
||||
// ESP failure; the esp-exit variant never had an ESP failure, so it gets its own
|
||||
@@ -968,7 +1042,18 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// stays the deadline, not EspTerminalFailure).
|
||||
string reason;
|
||||
Dictionary<string, string> parameters;
|
||||
if (hasAdvisoryAnchor && advisoryResolved)
|
||||
var failTrigger = $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}";
|
||||
if (oobePhase.Kind == OobeUpdatePhaseKind.UpdateNotFinished)
|
||||
{
|
||||
// D-310: the OOBE update still ran when its cap passed — a hanging update, not a
|
||||
// device that went quiet after the ESP page. Its own reason, so the failure names
|
||||
// the update instead of the generic window expiry.
|
||||
reason = "oobe_update_not_finished";
|
||||
parameters = BuildOobeUpdateNotFinishedParameters(state);
|
||||
failTrigger += ":OobeUpdateNotFinished";
|
||||
builder.WithLastFailureTrigger(nameof(DecisionSignalKind.DeadlineFired), signal.SessionSignalOrdinal);
|
||||
}
|
||||
else if (hasAdvisoryAnchor && advisoryResolved)
|
||||
{
|
||||
// Session 4910a5a5: the original ESP failure demonstrably recovered (failed
|
||||
// category resolved to success after the advisory), so claiming
|
||||
@@ -1038,22 +1123,25 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
signal: signal,
|
||||
toStage: SessionStage.Failed,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}");
|
||||
trigger: failTrigger);
|
||||
|
||||
var failedEffects = new[]
|
||||
var failedEffects = new List<DecisionEffect>(2);
|
||||
// Shadow mode: what the OOBE update phase would have decided, ahead of today's verdict.
|
||||
if (evaluatedOobePhase.Kind != OobeUpdatePhaseKind.None && oobePhaseMode == OobeUpdatePhaseModes.Shadow)
|
||||
{
|
||||
new DecisionEffect(
|
||||
DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: parameters,
|
||||
typedPayload: BuildEspFailureAuditTrail(
|
||||
postState: failedState,
|
||||
decidedStage: SessionStage.Failed,
|
||||
trigger: $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}",
|
||||
failureReason: reason,
|
||||
parameters: parameters)),
|
||||
};
|
||||
failedEffects.Add(BuildOobeUpdateShadowEffect(state, evaluatedOobePhase, reason));
|
||||
}
|
||||
failedEffects.Add(new DecisionEffect(
|
||||
DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: parameters,
|
||||
typedPayload: BuildEspFailureAuditTrail(
|
||||
postState: failedState,
|
||||
decidedStage: SessionStage.Failed,
|
||||
trigger: failTrigger,
|
||||
failureReason: reason,
|
||||
parameters: parameters)));
|
||||
|
||||
return new DecisionStep(failedState, failedTransition, failedEffects);
|
||||
return new DecisionStep(failedState, failedTransition, failedEffects.ToArray());
|
||||
}
|
||||
|
||||
private DecisionStep BuildFailedStep(
|
||||
@@ -1226,6 +1314,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
RecordOobeUpdateRestart(state, builder, signal);
|
||||
var rebaseEffects = RebaseEspExitVariantAdvisoryWindowOnReboot(state, builder, signal);
|
||||
|
||||
var newState = builder.Build();
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
// OOBE update phase (D-310). With the ESP setting "Install Windows quality updates" Windows
|
||||
// runs an update page at the end of OOBE — after the Device-ESP page exits and before the
|
||||
// user signs in (CloudExperienceHost nodes OobeNDUP → RebootNDUP → OobeExit). Microsoft
|
||||
// documents 20–40 minutes plus a restart, after which the user must sign in again at the lock
|
||||
// screen. When the IME logged its AccountSetup phase line before that handoff exit (it does so
|
||||
// as soon as the device phase completes), the exit armed the AdvisoryCompletion window and
|
||||
// the session was failed mid-update (report 3a2207978e4c).
|
||||
//
|
||||
// The phase holds that window, but every hold is bounded on its own — the agent's max
|
||||
// lifetime is no bound, it restarts with every agent start:
|
||||
// - the update runs: at most OobeUpdateCap after the first update activity, then the
|
||||
// session fails as oobe_update_not_finished;
|
||||
// - the update ended: OobeSignInWindow for the sign-in, then the engine stops waiting
|
||||
// (StoppedWaitingReason) and the agent ends the session like its
|
||||
// max-lifetime watchdog — the backend classifies it honestly, never Failed;
|
||||
// - the user arrived: one AdvisoryCompletion window of grace for the user's own setup.
|
||||
public sealed partial class DecisionEngine
|
||||
{
|
||||
internal static readonly TimeSpan s_oobeUpdateCap = TimeSpan.FromMinutes(OobeUpdatePhaseBounds.UpdateCapMinutes);
|
||||
|
||||
internal static readonly TimeSpan s_oobeSignInWindow = TimeSpan.FromMinutes(OobeUpdatePhaseBounds.SignInWindowMinutes);
|
||||
|
||||
/// <summary>
|
||||
/// Record one Windows/.NET update step. The earliest activity after the ESP exit while no
|
||||
/// real user has signed in anchors the OOBE update phase
|
||||
/// (<see cref="OsUpdateFacts.PostExitActivityUtc"/>). Bookkeeping only: the stage stays,
|
||||
/// no deadline is armed — the phase only changes how
|
||||
/// <see cref="HandleAdvisoryCompletionDeadlineFired"/> resolves.
|
||||
/// </summary>
|
||||
private DecisionStep HandleOsUpdateActivityV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var nextStep = state.StepIndex + 1;
|
||||
|
||||
string? step = null;
|
||||
string? update = null;
|
||||
signal.Payload?.TryGetValue(OsUpdatePayloadKeys.Step, out step);
|
||||
signal.Payload?.TryGetValue(OsUpdatePayloadKeys.Update, out update);
|
||||
|
||||
if (string.IsNullOrEmpty(step))
|
||||
{
|
||||
var bookkept = BumpStepBookkeeping(state, signal);
|
||||
return new DecisionStep(
|
||||
bookkept,
|
||||
BuildDeadEndTransition(state, signal, bookkept.StepIndex,
|
||||
trigger: nameof(DecisionSignalKind.OsUpdateActivity),
|
||||
deadEndReason: "os_update_activity_without_step"),
|
||||
Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
var phaseActivity = IsOobeUpdatePhaseActivity(state, signal);
|
||||
var phaseStarts = phaseActivity && state.OsUpdateFacts.PostExitActivityUtc == null;
|
||||
|
||||
var facts = state.OsUpdateFacts.WithActivity(
|
||||
signal.OccurredAtUtc, signal.SessionSignalOrdinal, step!, update, phaseActivity);
|
||||
|
||||
var newState = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||
.WithOsUpdateFacts(facts)
|
||||
.Build();
|
||||
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.OsUpdateActivity) + (phaseStarts ? ":OobeUpdatePhaseStarted" : string.Empty));
|
||||
|
||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a system restart that belongs to the OOBE update phase — the update page ends
|
||||
/// with one. Called by the reboot handlers; a restart before the anchor or after a real
|
||||
/// user arrived is not the update's restart.
|
||||
/// </summary>
|
||||
private static void RecordOobeUpdateRestart(DecisionState state, DecisionStateBuilder builder, DecisionSignal signal)
|
||||
{
|
||||
var anchor = state.OsUpdateFacts.PostExitActivityUtc;
|
||||
if (anchor == null || signal.OccurredAtUtc < anchor.Value || HasRealUserEvidence(state)) return;
|
||||
builder.WithOsUpdateFacts(state.OsUpdateFacts.WithRestart(signal.OccurredAtUtc, signal.SessionSignalOrdinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True while the session is in the OOBE update phase: OS update activity was observed
|
||||
/// after the ESP exit and no real user has signed in since. Says nothing about the bounds —
|
||||
/// <see cref="EvaluateOobeUpdatePhase"/> decides what the phase holds.
|
||||
/// </summary>
|
||||
internal static bool IsInOobeUpdatePhase(DecisionState state) =>
|
||||
state.OsUpdateFacts.PostExitActivityUtc != null && !HasRealUserEvidence(state);
|
||||
|
||||
internal enum OobeUpdatePhaseKind
|
||||
{
|
||||
/// <summary>No OOBE update phase in this session.</summary>
|
||||
None,
|
||||
/// <summary>The update runs; holds until the cap.</summary>
|
||||
Updating,
|
||||
/// <summary>The update ended; holds until the sign-in window closes.</summary>
|
||||
AwaitingSignIn,
|
||||
/// <summary>The user signed in less than one window ago; holds for the user's setup.</summary>
|
||||
UserArrived,
|
||||
/// <summary>The cap passed while the update still ran — the session fails as not finished.</summary>
|
||||
UpdateNotFinished,
|
||||
/// <summary>Nobody signed in within the sign-in window — the engine stops waiting.</summary>
|
||||
SignInWaitExpired,
|
||||
/// <summary>The user's grace passed; the window resolves the normal way.</summary>
|
||||
Over,
|
||||
}
|
||||
|
||||
internal readonly struct OobeUpdatePhaseStatus
|
||||
{
|
||||
public OobeUpdatePhaseStatus(OobeUpdatePhaseKind kind, DateTime? boundUtc = null, DateTime? updateEndedUtc = null)
|
||||
{
|
||||
Kind = kind;
|
||||
BoundUtc = boundUtc;
|
||||
UpdateEndedUtc = updateEndedUtc;
|
||||
}
|
||||
|
||||
public OobeUpdatePhaseKind Kind { get; }
|
||||
|
||||
/// <summary>When the current state stops holding (cap, end of sign-in window or grace).</summary>
|
||||
public DateTime? BoundUtc { get; }
|
||||
|
||||
/// <summary>When the update ended (its restart or terminal step); null while it runs.</summary>
|
||||
public DateTime? UpdateEndedUtc { get; }
|
||||
|
||||
public bool Holds =>
|
||||
Kind == OobeUpdatePhaseKind.Updating
|
||||
|| Kind == OobeUpdatePhaseKind.AwaitingSignIn
|
||||
|| Kind == OobeUpdatePhaseKind.UserArrived;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the OOBE update phase stands at <paramref name="nowUtc"/>. The update counts as
|
||||
/// ended when its latest event — by event time — is its restart or a terminal step
|
||||
/// (installed, failed, reboot_required); any newer non-terminal step means it runs (again).
|
||||
/// </summary>
|
||||
internal static OobeUpdatePhaseStatus EvaluateOobeUpdatePhase(DecisionState state, DateTime nowUtc)
|
||||
{
|
||||
var facts = state.OsUpdateFacts;
|
||||
var anchor = facts.PostExitActivityUtc;
|
||||
if (anchor == null) return new OobeUpdatePhaseStatus(OobeUpdatePhaseKind.None);
|
||||
|
||||
var userArrivedUtc = FirstRealUserEvidenceUtc(state);
|
||||
if (userArrivedUtc != null)
|
||||
{
|
||||
var graceEnd = userArrivedUtc.Value + s_advisoryCompletionWindow;
|
||||
return nowUtc < graceEnd
|
||||
? new OobeUpdatePhaseStatus(OobeUpdatePhaseKind.UserArrived, graceEnd)
|
||||
: new OobeUpdatePhaseStatus(OobeUpdatePhaseKind.Over);
|
||||
}
|
||||
|
||||
var endedUtc = UpdateEndedUtc(facts);
|
||||
if (endedUtc == null)
|
||||
{
|
||||
var capEnd = anchor.Value + s_oobeUpdateCap;
|
||||
return new OobeUpdatePhaseStatus(
|
||||
nowUtc < capEnd ? OobeUpdatePhaseKind.Updating : OobeUpdatePhaseKind.UpdateNotFinished,
|
||||
capEnd);
|
||||
}
|
||||
|
||||
var signInEnd = endedUtc.Value + s_oobeSignInWindow;
|
||||
return new OobeUpdatePhaseStatus(
|
||||
nowUtc < signInEnd ? OobeUpdatePhaseKind.AwaitingSignIn : OobeUpdatePhaseKind.SignInWaitExpired,
|
||||
signInEnd,
|
||||
endedUtc);
|
||||
}
|
||||
|
||||
private static DateTime? UpdateEndedUtc(OsUpdateFacts facts)
|
||||
{
|
||||
var last = facts.LastActivityUtc?.Value;
|
||||
var restart = facts.RestartUtc?.Value;
|
||||
if (restart != null && (last == null || restart.Value >= last.Value)) return restart;
|
||||
if (last != null && OsUpdateSteps.IsTerminal(facts.LastStep?.Value)) return last;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>completion_waiting</c> trigger suffix and message for a holding phase state.
|
||||
/// </summary>
|
||||
private static (string TriggerSuffix, string Message) DescribeOobeUpdateHold(OobeUpdatePhaseKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case OobeUpdatePhaseKind.Updating:
|
||||
return ("OobeUpdatePhase",
|
||||
"Completion resolution window re-armed: Windows is installing an update in OOBE");
|
||||
case OobeUpdatePhaseKind.AwaitingSignIn:
|
||||
return ("OobeUpdateAwaitingSignIn",
|
||||
"Completion resolution window re-armed: the Windows update in OOBE has finished, waiting for the user to sign in");
|
||||
default:
|
||||
return ("OobeUpdatePhaseUserArrived",
|
||||
"Completion resolution window re-armed: the user signed in after the OOBE update and gets one full window for the own setup");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nobody signed in within the sign-in window after the OOBE update: stop waiting. The
|
||||
/// stage stays non-terminal and no verdict is declared — the device is fine, the user is
|
||||
/// absent. <see cref="DecisionState.StoppedWaitingReason"/> makes the agent end the session
|
||||
/// like its max-lifetime watchdog; the backend then classifies it (AwaitingUser for a
|
||||
/// WhiteGlove Part 2, otherwise Incomplete), never Failed.
|
||||
/// </summary>
|
||||
private DecisionStep BuildOobeUpdateStopWaitingStep(
|
||||
DecisionState state,
|
||||
DecisionStateBuilder builder,
|
||||
DecisionSignal signal,
|
||||
int nextStep,
|
||||
OobeUpdatePhaseStatus phase)
|
||||
{
|
||||
builder.StoppedWaitingReason = new SignalFact<string>(
|
||||
StoppedWaitingReasons.OobeUpdateNoSignIn, signal.SessionSignalOrdinal);
|
||||
builder.ClearDeadlines();
|
||||
|
||||
var trigger = $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}:OobeUpdateSignInWaitExpired";
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = SharedConstants.EventTypes.CompletionWaitExpired,
|
||||
["source"] = "DecisionEngine",
|
||||
["severity"] = "Warning",
|
||||
["immediateUpload"] = "true",
|
||||
["message"] = "Nobody signed in within " +
|
||||
s_oobeSignInWindow.TotalMinutes.ToString("0", CultureInfo.InvariantCulture) +
|
||||
" minutes after the Windows update in OOBE — the agent stops waiting. Not a failure: " +
|
||||
"the session is classified as awaiting the user or incomplete.",
|
||||
["reason"] = StoppedWaitingReasons.OobeUpdateNoSignIn,
|
||||
["signInWindowMinutes"] = s_oobeSignInWindow.TotalMinutes.ToString("0", CultureInfo.InvariantCulture),
|
||||
["stage"] = state.Stage.ToString(),
|
||||
};
|
||||
AddOobeUpdateContext(parameters, state, phase.UpdateEndedUtc);
|
||||
|
||||
var stoppedState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: trigger);
|
||||
|
||||
return new DecisionStep(stoppedState, transition, new[]
|
||||
{
|
||||
new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry, parameters: parameters),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shadow mode: what the phase would decide at this advisory fire, emitted right before
|
||||
/// today's verdict (<paramref name="decidedReason"/>). At most once per session — in Shadow
|
||||
/// mode the fire that reaches the phase guard always ends in that verdict.
|
||||
/// </summary>
|
||||
private static DecisionEffect BuildOobeUpdateShadowEffect(
|
||||
DecisionState state,
|
||||
OobeUpdatePhaseStatus phase,
|
||||
string decidedReason)
|
||||
{
|
||||
var (phaseState, wouldDecide, outlook) = DescribeOobeUpdateShadow(phase.Kind);
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = SharedConstants.EventTypes.OobeUpdatePhaseShadow,
|
||||
["source"] = "DecisionEngine",
|
||||
["severity"] = "Debug",
|
||||
["immediateUpload"] = "true",
|
||||
["message"] = $"OOBE update phase (shadow): {outlook} Today's rule decides ({decidedReason}).",
|
||||
["phaseMode"] = OobeUpdatePhaseModes.Shadow,
|
||||
["phaseState"] = phaseState,
|
||||
["wouldDecide"] = wouldDecide,
|
||||
["decidedReason"] = decidedReason,
|
||||
["trigger"] = $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}",
|
||||
["stage"] = state.Stage.ToString(),
|
||||
};
|
||||
if (phase.BoundUtc != null)
|
||||
parameters["phaseBoundUtc"] = phase.BoundUtc.Value.ToString("o");
|
||||
var userArrivedUtc = FirstRealUserEvidenceUtc(state);
|
||||
if (userArrivedUtc != null)
|
||||
parameters["userArrivedUtc"] = userArrivedUtc.Value.ToString("o");
|
||||
AddOobeUpdateContext(parameters, state, phase.UpdateEndedUtc);
|
||||
return new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry, parameters: parameters);
|
||||
}
|
||||
|
||||
/// <summary><c>phaseState</c>, <c>wouldDecide</c> and the message part of a shadow evaluation.</summary>
|
||||
private static (string PhaseState, string WouldDecide, string Outlook) DescribeOobeUpdateShadow(OobeUpdatePhaseKind kind)
|
||||
{
|
||||
var capHours = s_oobeUpdateCap.TotalHours.ToString("0", CultureInfo.InvariantCulture);
|
||||
var signInMinutes = s_oobeSignInWindow.TotalMinutes.ToString("0", CultureInfo.InvariantCulture);
|
||||
switch (kind)
|
||||
{
|
||||
case OobeUpdatePhaseKind.Updating:
|
||||
return ("update_running", "hold",
|
||||
$"Windows is still installing an update in OOBE; the phase would keep waiting (cap {capHours} h).");
|
||||
case OobeUpdatePhaseKind.AwaitingSignIn:
|
||||
return ("awaiting_sign_in", "hold",
|
||||
$"the Windows update in OOBE has finished; the phase would wait {signInMinutes} minutes for the sign-in.");
|
||||
case OobeUpdatePhaseKind.UserArrived:
|
||||
return ("user_arrived", "hold",
|
||||
"the user signed in after the OOBE update; the phase would give the user's own setup one more window.");
|
||||
case OobeUpdatePhaseKind.UpdateNotFinished:
|
||||
return ("update_not_finished", "fail_oobe_update_not_finished",
|
||||
$"the update still ran after {capHours} hours; the phase would fail the session as oobe_update_not_finished.");
|
||||
case OobeUpdatePhaseKind.SignInWaitExpired:
|
||||
return ("sign_in_wait_expired", "stop_waiting",
|
||||
$"nobody signed in within {signInMinutes} minutes after the update; the phase would stop waiting without a verdict.");
|
||||
default:
|
||||
return ("over", "unchanged",
|
||||
"the user's window after the OOBE update has passed; the phase would decide the same.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Failure parameters for an OOBE update that still ran when the cap passed — a hanging
|
||||
/// update, reported with its own reason instead of <c>esp_exit_without_completion_evidence</c>.
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> BuildOobeUpdateNotFinishedParameters(DecisionState state)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = SharedConstants.EventTypes.EnrollmentFailed,
|
||||
["reason"] = "oobe_update_not_finished",
|
||||
["advisoryReason"] = "oobe_update_cap_expired_without_restart",
|
||||
["oobeUpdateCapHours"] = s_oobeUpdateCap.TotalHours.ToString("0", CultureInfo.InvariantCulture),
|
||||
};
|
||||
AddOobeUpdateContext(parameters, state, updateEndedUtc: null);
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private static void AddOobeUpdateContext(Dictionary<string, string> parameters, DecisionState state, DateTime? updateEndedUtc)
|
||||
{
|
||||
var facts = state.OsUpdateFacts;
|
||||
if (facts.PostExitActivityUtc != null)
|
||||
parameters["oobeUpdatePhaseStartedUtc"] = facts.PostExitActivityUtc.Value.ToString("o");
|
||||
if (updateEndedUtc != null)
|
||||
parameters["oobeUpdateEndedUtc"] = updateEndedUtc.Value.ToString("o");
|
||||
if (facts.LastStep != null)
|
||||
parameters["osUpdateLastStep"] = facts.LastStep.Value;
|
||||
if (facts.LastUpdate != null)
|
||||
parameters["osUpdate"] = facts.LastUpdate.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Real-user evidence: a DAD-validated desktop, a Hello wizard start or resolution, or a
|
||||
/// confirmed Account Setup. Deliberately NOT evidence: the IME AccountSetup phase line and
|
||||
/// the IME user-session completion — IME writes both under <c>defaultuser0</c> before the
|
||||
/// sign-in (session 1924092e, report 3a2207978e4c).
|
||||
/// </summary>
|
||||
internal static bool HasRealUserEvidence(DecisionState state) => FirstRealUserEvidenceUtc(state) != null;
|
||||
|
||||
private static DateTime? FirstRealUserEvidenceUtc(DecisionState state)
|
||||
{
|
||||
DateTime? first = null;
|
||||
foreach (var fact in new[]
|
||||
{
|
||||
state.DesktopArrivedUtc,
|
||||
state.HelloWizardStartedUtc,
|
||||
state.HelloResolvedUtc,
|
||||
state.AccountSetupProvisioningSucceededUtc,
|
||||
})
|
||||
{
|
||||
if (fact != null && (first == null || fact.Value < first.Value)) first = fact.Value;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
private static bool IsOobeUpdatePhaseActivity(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var exit = state.EspFinalExitUtc;
|
||||
if (exit == null) return false;
|
||||
if (signal.SessionSignalOrdinal <= exit.SourceSignalOrdinal) return false;
|
||||
// The watchers backfill their logs after an agent restart: such a record gets a fresh
|
||||
// ordinal but keeps its event-log time, so activity that predates the exit is not
|
||||
// part of the phase.
|
||||
if (signal.OccurredAtUtc < exit.Value) return false;
|
||||
return !HasRealUserEvidence(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user