mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7dd962133 | |||
| 14a67ef5af | |||
| 2371855b77 | |||
| 3a50de83ef | |||
| 24dfd247e9 | |||
| a640039518 | |||
| c3e9011905 | |||
| 8f6926a945 | |||
| 01323fdf6d | |||
| c566ed818b | |||
| 3a40361bb6 | |||
| 1b988d2943 | |||
| e88a0e455c | |||
| 0695f7943c | |||
| 6f8e7d48b2 | |||
| 0248d14738 | |||
| dc59fea6e5 | |||
| 08455cc5f9 | |||
| 87c5bf018c | |||
| 0d6c03d64b | |||
| b14866c9a6 | |||
| 25b133d3d0 | |||
| 684167292f | |||
| 6474860a57 | |||
| d23f2eae3e | |||
| fae6aa0b92 | |||
| 43dfb731f1 | |||
| dd5534b0c9 | |||
| 54334de378 | |||
| 9671191eef | |||
| c5a4feb762 | |||
| 2253fca533 | |||
| ff9e79dcc7 | |||
| e54b4585b8 | |||
| 7894d61e0b | |||
| 090f47d4fb | |||
| c2d68b6537 | |||
| 5b0dfaad99 | |||
| 50d29e61e8 | |||
| 4b03ba4e19 | |||
| f0aec031f3 | |||
| 66208b3767 | |||
| c60e07dfd4 | |||
| f564ee6a06 |
+1
-1
Submodule .claude/commands updated: 0d25a07655...6067f753bf
@@ -114,7 +114,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -125,6 +125,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -78,6 +78,23 @@ jobs:
|
||||
working-directory: ${{ env.WEB_DIR }}
|
||||
run: npm ci
|
||||
|
||||
# Star counter for the GitHub star link (utils/githubStars.ts). Baked into the static HTML
|
||||
# so visitors' browsers never call GitHub; stale until the next web deploy. The token stays
|
||||
# in this step, never in the build. Fail-soft: without a number the link has no counter.
|
||||
- name: Read GitHub star count (fail-soft)
|
||||
id: stars
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
count=$(gh api "repos/$REPO" --jq .stargazers_count 2>/dev/null || true)
|
||||
if [[ "$count" =~ ^[0-9]+$ ]]; then
|
||||
echo "count=$count" >> "$GITHUB_OUTPUT"
|
||||
echo "GitHub stars: $count"
|
||||
else
|
||||
echo "::warning::GitHub star count unavailable - the star link renders without a counter"
|
||||
fi
|
||||
|
||||
# next.config output:'export' -- `next build` writes the static site to out/
|
||||
# (prebuild: sync-error-codes, generate-lastmod, generate-version, whats-new).
|
||||
- name: Build
|
||||
@@ -98,6 +115,8 @@ jobs:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
# Docs checkout for public/whats-new.json; the prebuild fails in CI without it.
|
||||
WHATS_NEW_DOCS_DIR: ${{ github.workspace }}/whats-new-docs
|
||||
# Star counter from the step above; empty when the lookup failed.
|
||||
NEXT_PUBLIC_GITHUB_STARS: ${{ steps.stars.outputs.count }}
|
||||
run: |
|
||||
npm run build
|
||||
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
|
||||
|
||||
+1
-1
Submodule internal updated: 997079976b...6d1c80d6f9
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -203,7 +203,7 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
|
||||
"action": "updateDoTelemetry",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent from the 1.104.102.0 and 1.105.103.0 builds, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -584,7 +584,7 @@
|
||||
"category": "always",
|
||||
"pattern": "^Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
|
||||
"action": "updateDoTelemetry",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent from the 1.104.102.0 and 1.105.103.0 builds, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "^Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
+6
-3
@@ -41,15 +41,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
}
|
||||
|
||||
private GatherRuleContext Context(bool unrestricted = false, string? imeLogPathOverride = null)
|
||||
=> new GatherRuleContext(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
return new GatherRuleContext(
|
||||
logger,
|
||||
"sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
imeLogPathOverride,
|
||||
new LogFilePositionTracker())
|
||||
new GatherRuleSessionState("sess", null, logger))
|
||||
{
|
||||
UnrestrictedMode = unrestricted
|
||||
};
|
||||
}
|
||||
|
||||
private static GatherRule Rule(string collectorType, string target,
|
||||
Dictionary<string, string>? parameters = null) => new GatherRule
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
public void Context_DebugLog_is_noop_without_writer()
|
||||
{
|
||||
var context = new GatherRuleContext(
|
||||
_logger, "sess", "tenant", _ => { }, null, new LogFilePositionTracker());
|
||||
_logger, "sess", "tenant", _ => { }, null, new GatherRuleSessionState("sess", null, _logger));
|
||||
|
||||
context.DebugLog("RULE-1", GatherRuleDebugLog.StageScope, "should not throw, should not write");
|
||||
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// An agent restart inside one enrollment session — a new <see cref="GatherRuleExecutor"/> on the
|
||||
/// same state directory — continues where the previous run stopped: logparser rules do not
|
||||
/// replay their log, on_change rules do not re-emit an unchanged result. Regression anchor:
|
||||
/// session c8106021, where every restart replayed the whole HP Image Assistant log (70 events).
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorRestartTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly TempDirectory _stateDir = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly object _eventsGate = new object();
|
||||
private readonly List<GatherRuleExecutor> _executors = new List<GatherRuleExecutor>();
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — log targets sit beside the test assembly.
|
||||
private readonly string _logDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "gather-restart-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public GatherRuleExecutorRestartTests()
|
||||
{
|
||||
Directory.CreateDirectory(_logDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var executor in _executors)
|
||||
executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
_stateDir.Dispose();
|
||||
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private string StateFile => Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
|
||||
|
||||
private List<EnrollmentEvent> Events
|
||||
{
|
||||
get { lock (_eventsGate) return _events.ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>One agent run: a fresh executor whose startup rules run once. Not disposed — a hard kill.</summary>
|
||||
private GatherRuleExecutor AgentRun(GatherRule rule, string sessionId = "session-a", string? stateDirectory = "default",
|
||||
Action<EnrollmentEvent>? onEvent = null, string? debugLogPath = null)
|
||||
{
|
||||
var executor = new GatherRuleExecutor(
|
||||
sessionId, "tenant",
|
||||
evt =>
|
||||
{
|
||||
onEvent?.Invoke(evt);
|
||||
lock (_eventsGate) _events.Add(evt);
|
||||
},
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
debugLogPath: debugLogPath,
|
||||
stateDirectory: stateDirectory == "default" ? _stateDir.Path : stateDirectory)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
_executors.Add(executor);
|
||||
executor.UpdateRules(new List<GatherRule> { rule });
|
||||
Assert.True(executor.WaitForStartupRules(30));
|
||||
return executor;
|
||||
}
|
||||
|
||||
private static GatherRule LogRule(string path, string id = "hpia-log-collect") => new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "logparser",
|
||||
Target = path,
|
||||
Parameters = new Dictionary<string, string> { ["pattern"] = "^(?<status>Update .+)$", ["format"] = "text" },
|
||||
Trigger = "startup",
|
||||
OutputEventType = "HPiA-UpdateStatus",
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
private static GatherRule OnChangeFileRule(string path) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-OC-FILE",
|
||||
Title = "file presence",
|
||||
CollectorType = "file",
|
||||
Target = path,
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_file_presence",
|
||||
Enabled = true,
|
||||
EmitMode = "on_change",
|
||||
};
|
||||
|
||||
private string WriteLog(string name, string content)
|
||||
{
|
||||
var path = Path.Combine(_logDir, name);
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
private int StatusEvents => Events.Count(e => e.EventType == "HPiA-UpdateStatus");
|
||||
|
||||
[Fact]
|
||||
public void Restart_does_not_replay_the_log_and_new_lines_arrive_once()
|
||||
{
|
||||
var path = WriteLog("HP Image Assistant.log", "Update one\nUpdate two\nnoise\n");
|
||||
|
||||
AgentRun(LogRule(path));
|
||||
Assert.Equal(2, StatusEvents);
|
||||
|
||||
AgentRun(LogRule(path)); // reboot, nothing new
|
||||
Assert.Equal(2, StatusEvents);
|
||||
|
||||
File.AppendAllText(path, "Update three\n");
|
||||
AgentRun(LogRule(path)); // next restart picks up only the new line
|
||||
Assert.Equal(3, StatusEvents);
|
||||
Assert.Equal("Update three", Events.Last(e => e.EventType == "HPiA-UpdateStatus").Data["status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_is_on_disk_right_after_the_run_without_a_clean_shutdown()
|
||||
{
|
||||
var path = WriteLog("hardkill.log", "Update one\n");
|
||||
|
||||
AgentRun(LogRule(path)); // never disposed
|
||||
|
||||
var saved = JsonConvert.DeserializeObject<GatherRuleStateData>(File.ReadAllText(StateFile))!;
|
||||
var position = Assert.Single(saved.LogPositions!);
|
||||
Assert.Equal(new FileInfo(path).Length, position.Position);
|
||||
Assert.Equal("session-a", saved.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_is_saved_only_after_the_events_went_out()
|
||||
{
|
||||
var path = WriteLog("ordering.log", "Update one\n");
|
||||
var savedDuringEmit = new List<bool>();
|
||||
|
||||
AgentRun(LogRule(path), onEvent: _ => savedDuringEmit.Add(File.Exists(StateFile)));
|
||||
|
||||
Assert.Equal(new[] { false }, savedDuringEmit);
|
||||
Assert.True(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_session_reads_the_log_from_the_beginning()
|
||||
{
|
||||
var path = WriteLog("rotated.log", "Update one\nUpdate two\n");
|
||||
AgentRun(LogRule(path), sessionId: "session-a");
|
||||
|
||||
AgentRun(LogRule(path), sessionId: "session-b"); // session rotated on the same device
|
||||
|
||||
Assert.Equal(4, StatusEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_replaced_during_the_reboot_is_read_from_the_beginning()
|
||||
{
|
||||
var path = WriteLog("replaced.log", "Update one\n");
|
||||
AgentRun(LogRule(path, "replaced"));
|
||||
|
||||
// The tool writes a new log after the reboot; it is already longer than the old position.
|
||||
File.WriteAllText(path, "Update fresh A\nUpdate fresh B\nUpdate fresh C\n");
|
||||
AgentRun(LogRule(path, "replaced"));
|
||||
|
||||
Assert.Equal(new[] { "Update one", "Update fresh A", "Update fresh B", "Update fresh C" },
|
||||
Events.Where(e => e.EventType == "HPiA-UpdateStatus").Select(e => (string)e.Data["status"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_on_change_result_stays_silent_after_a_restart()
|
||||
{
|
||||
var watched = Path.Combine(_logDir, "marker.txt");
|
||||
|
||||
AgentRun(OnChangeFileRule(watched)); // absent → first result emits
|
||||
AgentRun(OnChangeFileRule(watched)); // reboot, still absent → silent
|
||||
Assert.Single(Events, e => e.EventType == "gather_file_presence");
|
||||
|
||||
File.WriteAllText(watched, "present");
|
||||
AgentRun(OnChangeFileRule(watched)); // changed → emits
|
||||
|
||||
Assert.Equal(2, Events.Count(e => e.EventType == "gather_file_presence"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_a_state_directory_every_run_starts_over_and_writes_nothing()
|
||||
{
|
||||
var path = WriteLog("diagnostic.log", "Update one\n");
|
||||
|
||||
AgentRun(LogRule(path), stateDirectory: null);
|
||||
AgentRun(LogRule(path), stateDirectory: null);
|
||||
|
||||
Assert.Equal(2, StatusEvents);
|
||||
Assert.False(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Debug_trace_says_what_was_restored_and_where_reading_continues()
|
||||
{
|
||||
var path = WriteLog("traced.log", "Update one\n");
|
||||
var trace = Path.Combine(_tmp.Path, "gather-rules-debug.log");
|
||||
AgentRun(LogRule(path));
|
||||
File.AppendAllText(path, "Update two\n");
|
||||
|
||||
AgentRun(LogRule(path), debugLogPath: trace);
|
||||
|
||||
var text = File.ReadAllText(trace);
|
||||
Assert.Contains("gather state: restored 1 logparser position(s) for 1 rule(s)", text);
|
||||
Assert.Contains("hpia-log-collect | config | logparser position restored: traced.log", text);
|
||||
Assert.Contains("traced.log: continuing at position", text);
|
||||
}
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="GatherRuleSessionState"/> and <see cref="GatherRuleStatePersistence"/>: what is
|
||||
/// written, what a restart restores, what it refuses, and when the file is (not) written.
|
||||
/// </summary>
|
||||
public sealed class GatherRuleSessionStateTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly TempDirectory _stateDir = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public GatherRuleSessionStateTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
_stateDir.Dispose();
|
||||
}
|
||||
|
||||
private string StateFile => Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
|
||||
|
||||
private GatherRuleSessionState NewState(string sessionId = "session-a") =>
|
||||
new GatherRuleSessionState(sessionId, new GatherRuleStatePersistence(_stateDir.Path, _logger), _logger);
|
||||
|
||||
private static LogFileHead Head(string text) => LogFileHead.FromBytes(Encoding.UTF8.GetBytes(text));
|
||||
|
||||
// ── restore ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Positions_and_emitted_hashes_survive_a_restart_of_the_same_session()
|
||||
{
|
||||
var first = NewState();
|
||||
first.CommitLogPosition("RULE-1", @"C:\Logs\a.log", 120, 7, Head("2026-09-28 a.log start"), heldTailStart: -1);
|
||||
first.CommitEmittedHash("RULE-2", "hash-value");
|
||||
|
||||
var second = NewState();
|
||||
|
||||
var position = second.PeekLogPosition("RULE-1", @"C:\Logs\a.log");
|
||||
Assert.NotNull(position);
|
||||
Assert.Equal(120, position!.Position);
|
||||
Assert.Equal(7, position.LineNumber);
|
||||
Assert.True(position.FromPreviousRun);
|
||||
Assert.Equal("hash-value", second.GetEmittedHashes()["RULE-2"]);
|
||||
Assert.StartsWith("restored 1 logparser position(s) for 1 rule(s) and 1 on_change value(s)", second.RestoreSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_of_another_session_is_ignored()
|
||||
{
|
||||
NewState("session-a").CommitLogPosition("RULE-1", @"C:\Logs\a.log", 120, 7, Head("start"), -1);
|
||||
|
||||
var rotated = NewState("session-b");
|
||||
|
||||
Assert.Null(rotated.PeekLogPosition("RULE-1", @"C:\Logs\a.log"));
|
||||
Assert.Contains("saved for session session-a, current session is session-b", rotated.RestoreSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_file_is_a_first_run_and_corrupt_file_a_fresh_start()
|
||||
{
|
||||
Assert.StartsWith("no saved state", NewState().RestoreSummary);
|
||||
|
||||
File.WriteAllText(StateFile, "not json {{{");
|
||||
var afterCorruption = NewState();
|
||||
|
||||
Assert.StartsWith("saved state discarded", afterCorruption.RestoreSummary);
|
||||
Assert.Empty(afterCorruption.GetEmittedHashes());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tampered_entries_are_dropped_and_the_rest_restored()
|
||||
{
|
||||
var data = new GatherRuleStateData
|
||||
{
|
||||
SessionId = "session-a",
|
||||
SavedAtUtc = DateTime.UtcNow,
|
||||
LogPositions = new List<GatherLogPositionData>
|
||||
{
|
||||
new GatherLogPositionData { RuleId = "OK", Path = @"C:\a.log", Position = 10, LineNumber = 1, HeadLength = 10, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "NEG", Path = @"C:\b.log", Position = -5, HeadLength = 10, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "", Path = @"C:\c.log", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "BIGHEAD", Path = @"C:\d.log", Position = 1, HeadLength = 5000, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "NOHEAD", Path = @"C:\e.log", Position = 50, HeadLength = 0 },
|
||||
},
|
||||
OnChangeHashes = new Dictionary<string, string> { ["GOOD"] = "x", ["EMPTY"] = "" },
|
||||
};
|
||||
File.WriteAllText(StateFile, JsonConvert.SerializeObject(data));
|
||||
|
||||
var state = NewState();
|
||||
|
||||
Assert.NotNull(state.PeekLogPosition("OK", @"C:\a.log"));
|
||||
Assert.Null(state.PeekLogPosition("NEG", @"C:\b.log"));
|
||||
Assert.Null(state.PeekLogPosition("BIGHEAD", @"C:\d.log"));
|
||||
Assert.Null(state.PeekLogPosition("NOHEAD", @"C:\e.log"));
|
||||
Assert.Equal(new[] { "GOOD" }, state.GetEmittedHashes().Keys.ToArray());
|
||||
Assert.Contains("5 invalid entries dropped", state.RestoreSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_path_no_real_file_can_have_is_dropped_and_never_stops_the_executor()
|
||||
{
|
||||
// Path APIs throw on these characters under .NET Framework; a throw here would keep
|
||||
// every collector host down at every restart of the session.
|
||||
var data = new GatherRuleStateData
|
||||
{
|
||||
SessionId = "session-a",
|
||||
LogPositions = new List<GatherLogPositionData>
|
||||
{
|
||||
new GatherLogPositionData { RuleId = "BAD", Path = "C:\\Logs\\x.|og", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "WILD", Path = "C:\\Logs\\*.log", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "OK", Path = "C:\\Logs\\ok.log", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
},
|
||||
};
|
||||
File.WriteAllText(StateFile, JsonConvert.SerializeObject(data));
|
||||
|
||||
var state = NewState();
|
||||
|
||||
Assert.NotNull(state.PeekLogPosition("OK", "C:\\Logs\\ok.log"));
|
||||
Assert.Null(state.PeekLogPosition("BAD", "C:\\Logs\\x.|og"));
|
||||
Assert.Contains("2 invalid entries dropped", state.RestoreSummary);
|
||||
}
|
||||
|
||||
// ── where reading starts ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Reading_continues_while_the_file_head_still_matches()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("R", @"C:\a.log", 30, 3, Head("2026-09-28 first line\n"), -1);
|
||||
|
||||
var start = GatherRuleSessionState.ResolveStart(state.PeekLogPosition("R", @"C:\a.log"), 200,
|
||||
Head("2026-09-28 first line\nsecond line\nthird"), out var how);
|
||||
|
||||
Assert.Equal(30, start);
|
||||
Assert.Equal(LogReadStart.Continue, how);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_shrunk_file_and_a_replaced_file_are_read_from_the_beginning()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("R", @"C:\a.log", 30, 3, Head("2026-09-28 first line\n"), -1);
|
||||
var known = state.PeekLogPosition("R", @"C:\a.log");
|
||||
|
||||
Assert.Equal(0, GatherRuleSessionState.ResolveStart(known, 20, Head("2026-09-28 fir"), out var shrunk));
|
||||
Assert.Equal(LogReadStart.Shrunk, shrunk);
|
||||
|
||||
// Replaced and already longer than the old position: only the head tells.
|
||||
Assert.Equal(0, GatherRuleSessionState.ResolveStart(known, 500, Head("2026-09-30 a new log entirely, much longer"), out var replaced));
|
||||
Assert.Equal(LogReadStart.Replaced, replaced);
|
||||
|
||||
// Shorter than the recorded head although not shorter than the position.
|
||||
state.CommitLogPosition("S", @"C:\b.log", 5, 1, Head("0123456789abcdef"), -1);
|
||||
Assert.Equal(0, GatherRuleSessionState.ResolveStart(state.PeekLogPosition("S", @"C:\b.log"), 8, Head("01234567"), out var cut));
|
||||
Assert.Equal(LogReadStart.Replaced, cut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Positions_are_kept_per_rule_for_the_same_file()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("RULE-A", @"C:\shared.log", 100, 10, Head("x"), -1);
|
||||
|
||||
Assert.Null(state.PeekLogPosition("RULE-B", @"C:\shared.log"));
|
||||
Assert.NotNull(state.PeekLogPosition("RULE-A", @"C:\SHARED.LOG")); // paths compare case-insensitively
|
||||
}
|
||||
|
||||
// ── when the file is written ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_commit_does_not_write_the_file()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
|
||||
File.Delete(StateFile);
|
||||
|
||||
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
|
||||
state.CommitEmittedHash("H", "v");
|
||||
File.Delete(StateFile);
|
||||
state.CommitEmittedHash("H", "v");
|
||||
|
||||
Assert.False(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failed_save_is_retried_at_the_next_commit()
|
||||
{
|
||||
var blocked = Path.Combine(_stateDir.Path, "blocked");
|
||||
File.WriteAllText(blocked, "a file where the state directory should be");
|
||||
var state = new GatherRuleSessionState("session-a", new GatherRuleStatePersistence(blocked, _logger), _logger);
|
||||
|
||||
state.CommitEmittedHash("H", "v"); // Directory.CreateDirectory fails
|
||||
File.Delete(blocked);
|
||||
state.CommitEmittedHash("H", "v"); // unchanged, but the earlier save is still owed
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(blocked, GatherRuleStatePersistence.FileName)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_closed_state_no_longer_writes()
|
||||
{
|
||||
var state = NewState();
|
||||
state.Close();
|
||||
|
||||
state.CommitEmittedHash("H", "v");
|
||||
|
||||
Assert.False(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_persistence_nothing_is_written()
|
||||
{
|
||||
var state = new GatherRuleSessionState("session-a", null, _logger);
|
||||
|
||||
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
|
||||
|
||||
Assert.StartsWith("persistence off", state.RestoreSummary);
|
||||
Assert.Empty(Directory.GetFiles(_stateDir.Path));
|
||||
}
|
||||
|
||||
// ── bounds and concurrency ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void A_rule_forgets_its_least_recently_touched_file_beyond_the_cap()
|
||||
{
|
||||
var state = NewState();
|
||||
for (var i = 0; i < GatherRuleSessionState.MaxLogFilesPerRule; i++)
|
||||
state.CommitLogPosition("R", $@"C:\logs\{i}.log", 1, 1, Head("h"), -1);
|
||||
state.PeekLogPosition("R", @"C:\logs\0.log"); // touched again — survives
|
||||
|
||||
state.CommitLogPosition("R", @"C:\logs\new.log", 1, 1, Head("h"), -1);
|
||||
|
||||
Assert.NotNull(state.PeekLogPosition("R", @"C:\logs\0.log"));
|
||||
Assert.Null(state.PeekLogPosition("R", @"C:\logs\1.log"));
|
||||
Assert.NotNull(state.PeekLogPosition("R", @"C:\logs\new.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parallel_commits_leave_a_consistent_file()
|
||||
{
|
||||
var state = NewState();
|
||||
|
||||
Parallel.For(0, 8, worker =>
|
||||
{
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
state.CommitLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log", i + 1, i, Head("h"), -1);
|
||||
state.PeekLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log");
|
||||
state.CommitEmittedHash($"RULE-{worker}", "hash-" + i);
|
||||
}
|
||||
});
|
||||
|
||||
var restored = NewState();
|
||||
for (var worker = 0; worker < 8; worker++)
|
||||
{
|
||||
Assert.Equal(50, restored.PeekLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log")!.Position);
|
||||
Assert.Equal("hash-49", restored.GetEmittedHashes()[$"RULE-{worker}"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="LogLineReader"/>, <see cref="LogTextEncoding"/> and <see cref="LogFileHead"/>:
|
||||
/// exact byte positions, StreamReader-compatible line ends, byte order marks only at offset 0,
|
||||
/// and the code-unit view of UTF-16/UTF-32 files.
|
||||
/// </summary>
|
||||
public sealed class LogLineReaderTests
|
||||
{
|
||||
private static byte[] Bytes(Encoding encoding, string text, bool withBom = true)
|
||||
{
|
||||
var body = encoding.GetBytes(text);
|
||||
return withBom ? encoding.GetPreamble().Concat(body).ToArray() : body;
|
||||
}
|
||||
|
||||
private static (List<string> Lines, List<long> EndPositions, List<bool> Terminated) ReadAll(
|
||||
byte[] file, long start = 0, int maxLineBytes = 1024)
|
||||
{
|
||||
var encoding = LogFileHead.FromBytes(file.Take(LogFileHead.MaxBytes).ToArray()).Encoding;
|
||||
using (var stream = new MemoryStream(file))
|
||||
{
|
||||
stream.Seek(start, SeekOrigin.Begin);
|
||||
var reader = new LogLineReader(stream, encoding, maxLineBytes);
|
||||
var lines = new List<string>();
|
||||
var ends = new List<long>();
|
||||
var terminated = new List<bool>();
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) != null)
|
||||
{
|
||||
lines.Add(line);
|
||||
ends.Add(reader.Position);
|
||||
terminated.Add(reader.LastLineTerminated);
|
||||
}
|
||||
return (lines, ends, terminated);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_ends_match_StreamReader_including_a_lone_carriage_return()
|
||||
{
|
||||
var file = Encoding.UTF8.GetBytes("one\ntwo\r\nthree\rfour");
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal(new[] { "one", "two", "three", "four" }, result.Lines);
|
||||
Assert.Equal(new long[] { 4, 9, 15, 19 }, result.EndPositions);
|
||||
Assert.Equal(new[] { true, true, true, false }, result.Terminated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_after_each_line_is_the_offset_to_resume_from()
|
||||
{
|
||||
var file = Encoding.UTF8.GetBytes("alpha\nbeta\ngamma\n");
|
||||
|
||||
var first = ReadAll(file);
|
||||
var resumed = ReadAll(file, start: first.EndPositions[0]);
|
||||
|
||||
Assert.Equal(new[] { "beta", "gamma" }, resumed.Lines);
|
||||
Assert.Equal(file.Length, resumed.EndPositions.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Utf8_byte_order_mark_is_skipped_only_at_offset_0()
|
||||
{
|
||||
var file = Bytes(Encoding.UTF8, "first\nsecond\n");
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal(new[] { "first", "second" }, result.Lines);
|
||||
Assert.Equal(LogTextEncoding.Utf8WithBom, LogFileHead.FromBytes(file).Encoding);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("utf-16le")]
|
||||
[InlineData("utf-16be")]
|
||||
[InlineData("utf-32le")]
|
||||
[InlineData("utf-32be")]
|
||||
public void Unicode_files_are_read_in_their_code_units_from_any_resume_point(string name)
|
||||
{
|
||||
var encoding = name == "utf-16le" ? Encoding.Unicode
|
||||
: name == "utf-16be" ? Encoding.BigEndianUnicode
|
||||
: name == "utf-32le" ? Encoding.UTF32
|
||||
: new UTF32Encoding(bigEndian: true, byteOrderMark: true);
|
||||
var file = Bytes(encoding, "ERROR first\r\nERROR zweite Zeile mit Umlaut äöü\r\n");
|
||||
|
||||
var whole = ReadAll(file);
|
||||
var resumed = ReadAll(file, start: whole.EndPositions[0]);
|
||||
|
||||
Assert.Equal(name, LogFileHead.FromBytes(file).Encoding.Name);
|
||||
Assert.Equal(new[] { "ERROR first", "ERROR zweite Zeile mit Umlaut äöü" }, whole.Lines);
|
||||
Assert.Equal(new[] { "ERROR zweite Zeile mit Umlaut äöü" }, resumed.Lines);
|
||||
Assert.Equal(file.Length, whole.EndPositions.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_without_byte_order_mark_is_utf8_and_ansi_bytes_decode_as_before()
|
||||
{
|
||||
var file = new byte[] { (byte)'M', (byte)'a', (byte)'n', 0xFC, (byte)'\n' }; // "Man" + Windows-1252 'ü'
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal(LogTextEncoding.Utf8, LogFileHead.FromBytes(file).Encoding);
|
||||
Assert.Equal(new StreamReader(new MemoryStream(file), Encoding.UTF8).ReadLine(), result.Lines.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Carriage_return_at_end_of_file_ends_the_line_and_its_late_line_feed_is_skipped()
|
||||
{
|
||||
var firstWrite = Encoding.UTF8.GetBytes("done\r");
|
||||
var later = Encoding.UTF8.GetBytes("done\r\nnext\n");
|
||||
|
||||
var first = ReadAll(firstWrite);
|
||||
var resumed = ReadAll(later, start: first.EndPositions.Last());
|
||||
|
||||
Assert.Equal(new[] { "done" }, first.Lines);
|
||||
Assert.True(first.Terminated.Single());
|
||||
Assert.Equal(new[] { "next" }, resumed.Lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overlong_line_is_consumed_without_being_kept_and_reading_continues_behind_it()
|
||||
{
|
||||
var file = Encoding.UTF8.GetBytes(new string('x', 50) + "\nshort\n");
|
||||
using (var stream = new MemoryStream(file))
|
||||
{
|
||||
var reader = new LogLineReader(stream, LogTextEncoding.Utf8, maxLineBytes: 10);
|
||||
|
||||
Assert.Equal(string.Empty, reader.ReadLine());
|
||||
Assert.True(reader.LastLineTruncated);
|
||||
Assert.Equal(51, reader.Position);
|
||||
Assert.Equal("short", reader.ReadLine());
|
||||
Assert.False(reader.LastLineTruncated);
|
||||
Assert.Null(reader.ReadLine());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_spanning_many_read_buffers_is_returned_whole()
|
||||
{
|
||||
var longLine = new string('y', 200_000);
|
||||
var file = Encoding.UTF8.GetBytes(longLine + "\nend\n");
|
||||
|
||||
var result = ReadAll(file, maxLineBytes: 1_000_000);
|
||||
|
||||
Assert.Equal(new[] { longLine, "end" }, result.Lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Half_written_utf16_character_at_end_of_file_stays_in_the_unterminated_line()
|
||||
{
|
||||
var full = Bytes(Encoding.Unicode, "ok\r\nhalf");
|
||||
var file = full.Concat(new byte[] { 0x41 }).ToArray(); // first byte of the next UTF-16 unit
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal("ok", result.Lines[0]);
|
||||
Assert.False(result.Terminated.Last());
|
||||
Assert.Equal(file.Length, result.EndPositions.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlignDown_snaps_to_code_units_behind_the_byte_order_mark()
|
||||
{
|
||||
Assert.Equal(0, LogTextEncoding.Utf16LittleEndian.AlignDown(1));
|
||||
Assert.Equal(2, LogTextEncoding.Utf16LittleEndian.AlignDown(3));
|
||||
Assert.Equal(6, LogTextEncoding.Utf16LittleEndian.AlignDown(7));
|
||||
Assert.Equal(8, LogTextEncoding.Utf32LittleEndian.AlignDown(11));
|
||||
Assert.Equal(7, LogTextEncoding.Utf8.AlignDown(7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Head_hash_of_a_prefix_matches_the_hash_of_the_shorter_file()
|
||||
{
|
||||
var shortFile = LogFileHead.FromBytes(Encoding.UTF8.GetBytes("2026-09-28 start"));
|
||||
var grown = LogFileHead.FromBytes(Encoding.UTF8.GetBytes("2026-09-28 start\nmore lines"));
|
||||
|
||||
Assert.Equal(shortFile.Hash(shortFile.Length), grown.Hash(shortFile.Length));
|
||||
Assert.NotEqual(shortFile.Hash(shortFile.Length), grown.Hash(grown.Length));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
logger, "sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
null,
|
||||
new LogFilePositionTracker(),
|
||||
new GatherRuleSessionState("sess", null, logger),
|
||||
new GatherRuleDebugLog(_debugLogPath, logger))
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Reading semantics of <see cref="LogParserCollector"/> across runs on one context: every
|
||||
/// line of a growing log is matched exactly once — no line lost at a maxLines cut, no line
|
||||
/// split by a writer that is mid-line, UTF-16 logs readable after the first run, positions
|
||||
/// kept per rule, line numbers counted from the start of the file.
|
||||
/// </summary>
|
||||
public sealed class LogParserCollectorReadingTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly GatherRuleContext _context;
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — parse targets sit beside the
|
||||
// test assembly instead, admitted via UnrestrictedMode.
|
||||
private readonly string _logDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "gather-reading-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public LogParserCollectorReadingTests()
|
||||
{
|
||||
Directory.CreateDirectory(_logDir);
|
||||
_context = NewContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private GatherRuleContext NewContext()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
return new GatherRuleContext(logger, "sess", "tenant",
|
||||
evt => _events.Add(evt), null, new GatherRuleSessionState("sess", null, logger))
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
private string LogPath(string name) => Path.Combine(_logDir, name);
|
||||
|
||||
private static GatherRule TextRule(string ruleId, string target, string pattern,
|
||||
string trigger = "interval", int? maxLines = null)
|
||||
{
|
||||
var parameters = new Dictionary<string, string> { ["pattern"] = pattern, ["format"] = "text" };
|
||||
if (maxLines.HasValue) parameters["maxLines"] = maxLines.Value.ToString();
|
||||
return new GatherRule
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Title = ruleId,
|
||||
CollectorType = "logparser",
|
||||
Target = target,
|
||||
Parameters = parameters,
|
||||
Trigger = trigger,
|
||||
IntervalSeconds = trigger == "interval" ? 30 : (int?)null,
|
||||
OutputEventType = "logparser_reading_test",
|
||||
};
|
||||
}
|
||||
|
||||
private List<string> Captured(string group) =>
|
||||
_events.Select(e => e.Data.TryGetValue(group, out var v) ? (string)v : "<missing>").ToList();
|
||||
|
||||
[Fact]
|
||||
public void Two_rules_on_one_file_each_read_the_whole_file()
|
||||
{
|
||||
var path = LogPath("shared.log");
|
||||
File.WriteAllText(path, "ERROR one\nWARN two\nERROR three\n");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(TextRule("RULE-ERRORS", path, "^ERROR (?<what>\\w+)"), _context);
|
||||
collector.Execute(TextRule("RULE-WARNINGS", path, "^WARN (?<what>\\w+)"), _context);
|
||||
|
||||
Assert.Equal(2, _events.Count(e => (string)e.Data["ruleId"] == "RULE-ERRORS"));
|
||||
Assert.Single(_events, e => (string)e.Data["ruleId"] == "RULE-WARNINGS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxLines_cut_loses_no_line_across_runs()
|
||||
{
|
||||
var path = LogPath("long.log");
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 1; i <= 30; i++)
|
||||
sb.Append("hit ").Append(i.ToString("D3")).Append(' ').Append('x', 90).Append('\n');
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
var rule = TextRule("RULE-LONG", path, "^hit (?<n>\\d{3})", maxLines: 10);
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
for (var run = 0; run < 6; run++)
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
var expected = Enumerable.Range(1, 30).Select(i => i.ToString("D3")).ToList();
|
||||
Assert.Equal(expected, Captured("n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_written_in_two_parts_is_matched_once_and_whole()
|
||||
{
|
||||
var path = LogPath("partial.log");
|
||||
File.WriteAllText(path, "ERR");
|
||||
var rule = TextRule("RULE-PARTIAL", path, "^ERROR (?<code>0x[0-9A-F]+)$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context); // the writer is mid-line
|
||||
File.AppendAllText(path, "OR 0x1F\n");
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new List<string> { "0x1F" }, Captured("code"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interval_rule_reads_a_line_still_unfinished_on_its_second_run_once()
|
||||
{
|
||||
var path = LogPath("never-terminated.log");
|
||||
File.WriteAllText(path, "ERROR 0x2A");
|
||||
var rule = TextRule("RULE-UNFINISHED", path, "^ERROR (?<code>0x[0-9A-F]+)$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context); // held: the writer may still be in the line
|
||||
collector.Execute(rule, _context); // still unfinished — taken as it is
|
||||
collector.Execute(rule, _context); // nothing new
|
||||
|
||||
Assert.Equal(new List<string> { "0x2A" }, Captured("code"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_shot_rule_reads_an_unfinished_line_now_and_whole_on_its_next_run()
|
||||
{
|
||||
var path = LogPath("startup.log");
|
||||
File.WriteAllText(path, "ok\nERR");
|
||||
var rule = TextRule("RULE-STARTUP", path, "^ERR(?<rest>.*)$", trigger: "startup");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context); // agent start: the writer is mid-line
|
||||
File.AppendAllText(path, "OR 0x1F\n");
|
||||
collector.Execute(rule, _context); // next agent start
|
||||
|
||||
// Read twice, never split: the second read sees the whole line.
|
||||
Assert.Equal(new List<string> { "", "OR 0x1F" }, Captured("rest"));
|
||||
Assert.Equal(new long[] { 2, 2 }, _events.Select(e => Convert.ToInt64(e.Data["logLineNumber"])).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_feed_written_after_a_trailing_carriage_return_is_consumed_without_a_line()
|
||||
{
|
||||
var path = LogPath("cr-at-end.log");
|
||||
File.WriteAllText(path, "hit\r");
|
||||
var rule = TextRule("RULE-CR", path, "^hit$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context);
|
||||
File.AppendAllText(path, "\n");
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Single(_events);
|
||||
var position = _context.SessionState.PeekLogPosition("RULE-CR", path);
|
||||
Assert.Equal(new FileInfo(path).Length, position!.Position);
|
||||
Assert.Equal(1, position.LineNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Utf16_log_appended_after_the_first_run_is_still_matched()
|
||||
{
|
||||
var path = LogPath("transcript.log");
|
||||
File.WriteAllText(path, "ERROR first\r\n", Encoding.Unicode); // UTF-16 LE with BOM
|
||||
var rule = TextRule("RULE-UTF16", path, "^ERROR (?<what>\\w+)$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context);
|
||||
using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write))
|
||||
{
|
||||
var bytes = new UnicodeEncoding(bigEndian: false, byteOrderMark: false).GetBytes("ERROR second\r\n");
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new List<string> { "first", "second" }, Captured("what"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abc")]
|
||||
[InlineData("0")]
|
||||
[InlineData("-5")]
|
||||
public void MaxLines_that_is_no_positive_number_falls_back_to_the_default(string maxLines)
|
||||
{
|
||||
var path = LogPath("misconfigured.log");
|
||||
File.WriteAllText(path, "hit 1\nhit 2\n");
|
||||
var rule = TextRule("RULE-MISCONFIGURED", path, "^hit (?<n>\\d)$");
|
||||
rule.Parameters["maxLines"] = maxLines;
|
||||
|
||||
new LogParserCollector().Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new List<string> { "1", "2" }, Captured("n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_numbers_count_from_the_start_of_the_file()
|
||||
{
|
||||
var path = LogPath("numbers.log");
|
||||
File.WriteAllText(path, "a\nhit\nb\n");
|
||||
var rule = TextRule("RULE-NUMBERS", path, "^hit$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context);
|
||||
File.AppendAllText(path, "c\nhit\n");
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new long[] { 2, 5 }, _events.Select(e => Convert.ToInt64(e.Data["logLineNumber"])).ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -35,7 +35,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
tenantId: "tenant",
|
||||
onEventCollected: _ => { },
|
||||
imeLogPathOverride: null,
|
||||
filePositionTracker: new LogFilePositionTracker())
|
||||
sessionState: new GatherRuleSessionState("sess", null, logger))
|
||||
{
|
||||
// Bypass the registry allowlist guard for the synthetic test path.
|
||||
UnrestrictedMode = true
|
||||
|
||||
+1
-1
@@ -337,7 +337,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
"Launch powershell executor in machine session",
|
||||
new[] { "context=machine" }
|
||||
};
|
||||
// Wording from the IME decompile (ContentDownloaderDeliveryOptimization, identical 1.97–1.105).
|
||||
// IME wording, identical from 1.97 to 1.105.
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-DO-TIMEOUT-2",
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
/// completion markers (whiteglove.complete, clean-exit) — V1 sessions had only AgentLogs/
|
||||
/// and ImeLogs/, which left forensics blind to decision-engine state and pending uploads.
|
||||
/// </summary>
|
||||
[Collection(UserProfileResolverCollection.Name)] // replaces UserProfileResolver's static state
|
||||
public sealed class DiagnosticsPackageServiceTests
|
||||
{
|
||||
private static AgentConfiguration Cfg(string sessionId = "S1") => new AgentConfiguration
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialises the test classes that replace <see cref="UserProfileResolver"/>'s static state.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class UserProfileResolverCollection
|
||||
{
|
||||
public const string Name = "UserProfileResolver";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The agent starts before anyone has signed in. A lookup in that window must not pin
|
||||
/// "no user" for the rest of the process — the %LOGGED_ON_USER_PROFILE% gather rules and the
|
||||
/// RealmJoin user logs of the diagnostics package would never resolve.
|
||||
/// </summary>
|
||||
[Collection(UserProfileResolverCollection.Name)]
|
||||
public sealed class UserProfileResolverTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime Start = new DateTime(2026, 9, 28, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public void Dispose() => UserProfileResolver.Reset();
|
||||
|
||||
[Fact]
|
||||
public void User_who_signs_in_after_the_first_lookup_is_found_later()
|
||||
{
|
||||
string? signedIn = null;
|
||||
var now = Start;
|
||||
UserProfileResolver.SetDetectorForTesting(() => signedIn, () => now);
|
||||
|
||||
Assert.Null(UserProfileResolver.GetLoggedOnUserProfilePath());
|
||||
|
||||
signedIn = @"C:\Users\enduser";
|
||||
now += UserProfileResolver.RetryInterval;
|
||||
|
||||
Assert.Equal(@"C:\Users\enduser", UserProfileResolver.GetLoggedOnUserProfilePath());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_user_is_detected_again_only_after_the_retry_interval()
|
||||
{
|
||||
var detections = 0;
|
||||
var now = Start;
|
||||
UserProfileResolver.SetDetectorForTesting(() => { detections++; return null; }, () => now);
|
||||
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
now += TimeSpan.FromSeconds(10);
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
Assert.Equal(1, detections);
|
||||
|
||||
now += UserProfileResolver.RetryInterval;
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
Assert.Equal(2, detections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_clock_stepping_backwards_does_not_delay_the_next_detection()
|
||||
{
|
||||
var detections = 0;
|
||||
var now = Start;
|
||||
UserProfileResolver.SetDetectorForTesting(() => { detections++; return null; }, () => now);
|
||||
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
now -= TimeSpan.FromHours(1); // OOBE time sync
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
|
||||
Assert.Equal(2, detections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_found_user_is_kept_without_detecting_again()
|
||||
{
|
||||
var detections = 0;
|
||||
UserProfileResolver.SetDetectorForTesting(() => { detections++; return @"C:\Users\enduser"; }, () => Start);
|
||||
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
|
||||
Assert.Equal(1, detections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pinned_no_user_stays_null_without_real_detection()
|
||||
{
|
||||
UserProfileResolver.SetForTesting(null!);
|
||||
|
||||
Assert.Null(UserProfileResolver.GetLoggedOnUserProfilePath());
|
||||
Assert.Null(UserProfileResolver.ExpandCustomTokens(@"%LOGGED_ON_USER_PROFILE%\AppData\Local\x.log"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+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()
|
||||
{
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
|
||||
// IME AppInstallStatus values (StatusServiceReports\..\Status), stable IME 1.50 .. 1.106.
|
||||
// 1001/1003 sit inside the 1000 band but are NOT terminal — IME maps every InProgress
|
||||
// enforcement state to 1001 (verified in the decompiled 1.106 StatusServiceHelpers).
|
||||
// enforcement state to 1001 (verified against IME 1.106).
|
||||
[Theory]
|
||||
[InlineData(1000, "installed")]
|
||||
[InlineData(1002, "installed")]
|
||||
|
||||
+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()
|
||||
{
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// The production factory hands its state directory to the gather executor. Without it every
|
||||
/// unit test of the gather state passes while the agent keeps replaying logs after each reboot —
|
||||
/// the one line in <see cref="DefaultComponentFactory"/> is the whole feature.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // the startup rule executes on the shared ThreadPool
|
||||
public sealed class DefaultComponentFactoryGatherStateTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly TempDirectory _stateDir = new TempDirectory();
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — the log sits beside the test assembly.
|
||||
private readonly string _logDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "factory-gather-state-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public DefaultComponentFactoryGatherStateTests() => Directory.CreateDirectory(_logDir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
_stateDir.Dispose();
|
||||
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gather_host_built_by_the_factory_persists_logparser_positions_in_the_state_directory()
|
||||
{
|
||||
var log = Path.Combine(_logDir, "vendor.log");
|
||||
File.WriteAllText(log, "Update one\n");
|
||||
|
||||
var factory = new DefaultComponentFactory(
|
||||
agentConfig: new AgentConfiguration
|
||||
{
|
||||
ApiBaseUrl = "https://example",
|
||||
TenantId = "t",
|
||||
SessionId = "s",
|
||||
UnrestrictedMode = true,
|
||||
},
|
||||
remoteConfig: new AgentConfigResponse
|
||||
{
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
GatherRules = new List<GatherRule>
|
||||
{
|
||||
new GatherRule
|
||||
{
|
||||
RuleId = "vendor-log",
|
||||
Title = "vendor log",
|
||||
CollectorType = "logparser",
|
||||
Target = log,
|
||||
Parameters = new Dictionary<string, string> { ["pattern"] = "^Update", ["format"] = "text" },
|
||||
Trigger = "startup",
|
||||
OutputEventType = "vendor_update",
|
||||
Enabled = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
networkMetrics: null,
|
||||
agentVersion: "test",
|
||||
stateDirectory: _stateDir.Path,
|
||||
startupEventGate: null);
|
||||
|
||||
var hosts = factory.CreateCollectorHosts(
|
||||
sessionId: "s",
|
||||
tenantId: "t",
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
whiteGloveSealingPatternIds: Array.Empty<string>(),
|
||||
ingress: new FakeSignalIngressSink(),
|
||||
clock: new VirtualClock(new DateTime(2026, 9, 28, 12, 0, 0, DateTimeKind.Utc)),
|
||||
telemetrySpool: null,
|
||||
timelineEvents: null).Hosts;
|
||||
try
|
||||
{
|
||||
hosts.OfType<GatherRuleExecutorHost>().Single().Start();
|
||||
|
||||
var stateFile = Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
|
||||
Assert.True(SpinWait.SpinUntil(() => File.Exists(stateFile), TimeSpan.FromSeconds(10)),
|
||||
"the gather host did not write its state into the factory's state directory");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
/// <summary>
|
||||
/// GatherRuleExecutorHost feeds its phase_change / phase_exit / on_event triggers from the
|
||||
/// POST-REDUCE <see cref="TimelineEventStream"/> (emitted timeline events), not from raw
|
||||
/// signals. Regression anchor: session 32312a32 (rsneuffen.de) — a phase_change rule on
|
||||
/// signals. Regression anchor: session 32312a32 — a phase_change rule on
|
||||
/// FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP exit),
|
||||
/// 7 minutes before the engine's RealmJoin-gated phase_transition(FinalizingSetup), and read
|
||||
/// a registry key before the RealmJoin package wrote it. With the timeline feed the rule
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
+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()
|
||||
{
|
||||
|
||||
+2
-2
@@ -58,8 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// (IME service family and AgentExecutor.log: eras are byte ranges).</description></item>
|
||||
/// <item><description><see cref="TryResolveByLocalTime"/> — transfer for files written by the
|
||||
/// SAME process as the IME service log but without their own era marker (AppWorkload.log,
|
||||
/// AppActionProcessor.log: plugins hosted in IntuneManagementExtension.exe, verified in the
|
||||
/// decompiled build). Within one process every file shares the zone belief, so a local
|
||||
/// AppActionProcessor.log: plugins hosted in IntuneManagementExtension.exe, verified against
|
||||
/// the IME build). Within one process every file shares the zone belief, so a local
|
||||
/// timestamp places the line in the service era whose local range contains it. Disabled
|
||||
/// entirely when the service eras' local ranges are not monotonic (a westward zone change
|
||||
/// between two service starts makes the ranges overlap) — ambiguity must never anchor.</description></item>
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@ using System.Collections.Generic;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks file read positions for incremental log file reading.
|
||||
/// In-memory only - resets on agent restart, which is acceptable since
|
||||
/// we start fresh per enrollment session.
|
||||
/// Tracks the IME log tracker's file read positions for incremental log file reading.
|
||||
/// The bookmarks survive agent restarts through the tracker's own state file
|
||||
/// (<see cref="GetAllPositions"/> / <see cref="RestorePosition"/>); the freshness timestamps do not.
|
||||
/// </summary>
|
||||
public class LogFilePositionTracker
|
||||
{
|
||||
|
||||
+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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -360,7 +360,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
: (DateTime?)null;
|
||||
}
|
||||
|
||||
/// <summary>StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104).</summary>
|
||||
/// <summary>StateMessageEnforcementState bands (verified against IME 1.97/1.104).</summary>
|
||||
internal static string ClassifyEnforcementState(int state)
|
||||
{
|
||||
if (state >= 1000 && state < 2000) return "success";
|
||||
|
||||
+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 = "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; }
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+267
-250
@@ -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,
|
||||
EventType = shape.EventType,
|
||||
Severity = shape.Severity,
|
||||
Source = "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;
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -472,7 +472,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
// Expand BEFORE the guard: the guard must judge the path that is read,
|
||||
// and an unresolved token is not a path it can judge.
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path, userProfilePath);
|
||||
if (expandedPath == null)
|
||||
{
|
||||
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
|
||||
|
||||
+58
-18
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
@@ -14,7 +15,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// The agent runs as SYSTEM, so standard environment variables like %USERPROFILE% or
|
||||
/// %LOCALAPPDATA% resolve to the SYSTEM profile — not the logged-on user. This class
|
||||
/// detects the real user via explorer.exe ownership (WTS session query, WMI fallback —
|
||||
/// see <see cref="Interop.ProcessOwnerLookup"/>) and caches the result for the agent's lifetime.
|
||||
/// see <see cref="Interop.ProcessOwnerLookup"/>) and caches the first user it finds for the
|
||||
/// agent's lifetime. "No user yet" is not cached: the agent usually starts before anyone has
|
||||
/// signed in, so detection is retried — at most every <see cref="RetryInterval"/>.
|
||||
///
|
||||
/// Usage in paths: %LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log
|
||||
/// </summary>
|
||||
@@ -23,9 +26,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// Spelled once, in Shared: the built-in diagnostics catalog uses the same token.
|
||||
public const string Token = Shared.Models.DiagnosticsBuiltInSections.UserProfileToken;
|
||||
|
||||
/// <summary>Minimum time between two detections while no user has been found.</summary>
|
||||
internal static readonly TimeSpan RetryInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static readonly object Lock = new object();
|
||||
private static bool _resolved;
|
||||
private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null if no user detected
|
||||
private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null until a user is detected
|
||||
private static DateTime _lastFailedDetectionUtc = DateTime.MinValue;
|
||||
|
||||
// Seams for tests; production uses the real detection and clock.
|
||||
private static Func<string> _detector = DetectLoggedOnUserProfile;
|
||||
private static Func<DateTime> _utcNow = () => DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the logged-on user's profile path (e.g. C:\Users\JohnDoe) or null
|
||||
@@ -34,17 +44,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// </summary>
|
||||
public static string GetLoggedOnUserProfilePath()
|
||||
{
|
||||
if (_resolved)
|
||||
return _userProfilePath;
|
||||
var cached = Volatile.Read(ref _userProfilePath);
|
||||
if (cached != null)
|
||||
return cached;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (_resolved)
|
||||
if (_userProfilePath != null)
|
||||
return _userProfilePath;
|
||||
|
||||
_userProfilePath = DetectLoggedOnUserProfile();
|
||||
_resolved = true;
|
||||
return _userProfilePath;
|
||||
// A clock that stepped backwards (common during OOBE) never delays the retry.
|
||||
var now = _utcNow();
|
||||
var sinceLastFailure = now - _lastFailedDetectionUtc;
|
||||
if (_lastFailedDetectionUtc != DateTime.MinValue
|
||||
&& sinceLastFailure >= TimeSpan.Zero && sinceLastFailure < RetryInterval)
|
||||
return null;
|
||||
|
||||
var detected = _detector();
|
||||
if (detected != null)
|
||||
Volatile.Write(ref _userProfilePath, detected);
|
||||
else
|
||||
_lastFailedDetectionUtc = now;
|
||||
return detected;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,18 +74,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// Environment.ExpandEnvironmentVariables for standard tokens.
|
||||
/// Returns null if the path contains the token but no user is logged on.
|
||||
/// </summary>
|
||||
public static string ExpandCustomTokens(string rawPath)
|
||||
public static string ExpandCustomTokens(string rawPath) =>
|
||||
ExpandCustomTokens(rawPath, ContainsUserProfileToken(rawPath) ? GetLoggedOnUserProfilePath() : null);
|
||||
|
||||
/// <summary>
|
||||
/// Same, with the profile path the caller already resolved for its guard. Callers that
|
||||
/// guard the expanded path must use this overload: while detection is still retrying, a
|
||||
/// second lookup can find the user the first one missed, and the guard would then judge a
|
||||
/// C:\Users path without the profile that admits it.
|
||||
/// </summary>
|
||||
public static string ExpandCustomTokens(string rawPath, string userProfilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rawPath))
|
||||
return rawPath;
|
||||
|
||||
if (rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
var profilePath = GetLoggedOnUserProfilePath();
|
||||
if (profilePath == null)
|
||||
if (userProfilePath == null)
|
||||
return null; // No user detected — caller should skip this path
|
||||
|
||||
rawPath = ReplaceCaseInsensitive(rawPath, Token, profilePath);
|
||||
rawPath = ReplaceCaseInsensitive(rawPath, Token, userProfilePath);
|
||||
}
|
||||
|
||||
return Environment.ExpandEnvironmentVariables(rawPath);
|
||||
@@ -80,26 +109,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the cached state. Only used for testing.
|
||||
/// Resets the cached state and the seams. Only used for testing.
|
||||
/// </summary>
|
||||
internal static void Reset()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_resolved = false;
|
||||
_userProfilePath = null;
|
||||
_lastFailedDetectionUtc = DateTime.MinValue;
|
||||
_detector = DetectLoggedOnUserProfile;
|
||||
_utcNow = () => DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows tests to inject a specific profile path without WMI.
|
||||
/// Allows tests to inject a specific profile path without WMI. Null pins "no user signed in"
|
||||
/// — detection keeps answering null instead of finding the developer's own session.
|
||||
/// </summary>
|
||||
internal static void SetForTesting(string profilePath)
|
||||
{
|
||||
SetDetectorForTesting(() => profilePath, () => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Replaces detection and clock, clearing what was cached. Only used for testing.</summary>
|
||||
internal static void SetDetectorForTesting(Func<string> detector, Func<DateTime> utcNow)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_userProfilePath = profilePath;
|
||||
_resolved = true;
|
||||
_userProfilePath = null;
|
||||
_lastFailedDetectionUtc = DateTime.MinValue;
|
||||
_detector = detector ?? throw new ArgumentNullException(nameof(detector));
|
||||
_utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Text encoding of a log file as the logparser reads it. Chosen from the byte order mark at
|
||||
/// the start of the file in <see cref="StreamReader"/>'s detection order; without one the file
|
||||
/// is UTF-8, which is also how ANSI logs were always read (invalid bytes decode to U+FFFD).
|
||||
/// </summary>
|
||||
internal sealed class LogTextEncoding
|
||||
{
|
||||
public static readonly LogTextEncoding Utf8 = new LogTextEncoding("utf-8", new UTF8Encoding(false), 1, 0);
|
||||
public static readonly LogTextEncoding Utf8WithBom = new LogTextEncoding("utf-8 with bom", new UTF8Encoding(false), 1, 3);
|
||||
public static readonly LogTextEncoding Utf16LittleEndian = new LogTextEncoding("utf-16le", new UnicodeEncoding(false, false), 2, 2);
|
||||
public static readonly LogTextEncoding Utf16BigEndian = new LogTextEncoding("utf-16be", new UnicodeEncoding(true, false), 2, 2);
|
||||
public static readonly LogTextEncoding Utf32LittleEndian = new LogTextEncoding("utf-32le", new UTF32Encoding(false, false), 4, 4);
|
||||
public static readonly LogTextEncoding Utf32BigEndian = new LogTextEncoding("utf-32be", new UTF32Encoding(true, false), 4, 4);
|
||||
|
||||
private LogTextEncoding(string name, Encoding encoding, int unitSize, int byteOrderMarkLength)
|
||||
{
|
||||
Name = name;
|
||||
Encoding = encoding;
|
||||
UnitSize = unitSize;
|
||||
ByteOrderMarkLength = byteOrderMarkLength;
|
||||
LineFeed = encoding.GetBytes("\n");
|
||||
CarriageReturn = encoding.GetBytes("\r");
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public Encoding Encoding { get; }
|
||||
|
||||
/// <summary>Bytes per code unit; every line boundary lies on a unit boundary.</summary>
|
||||
public int UnitSize { get; }
|
||||
|
||||
/// <summary>Length of the byte order mark at offset 0 — skipped, never part of a line.</summary>
|
||||
public int ByteOrderMarkLength { get; }
|
||||
|
||||
public byte[] LineFeed { get; }
|
||||
public byte[] CarriageReturn { get; }
|
||||
|
||||
public static LogTextEncoding Detect(byte[] head)
|
||||
{
|
||||
if (StartsWith(head, 0xEF, 0xBB, 0xBF)) return Utf8WithBom;
|
||||
if (StartsWith(head, 0xFF, 0xFE, 0x00, 0x00)) return Utf32LittleEndian;
|
||||
if (StartsWith(head, 0xFF, 0xFE)) return Utf16LittleEndian;
|
||||
if (StartsWith(head, 0xFE, 0xFF)) return Utf16BigEndian;
|
||||
if (StartsWith(head, 0x00, 0x00, 0xFE, 0xFF)) return Utf32BigEndian;
|
||||
return Utf8;
|
||||
}
|
||||
|
||||
/// <summary>The last code-unit boundary at or before <paramref name="position"/>.</summary>
|
||||
public long AlignDown(long position)
|
||||
{
|
||||
if (position <= ByteOrderMarkLength) return position < ByteOrderMarkLength ? 0 : position;
|
||||
return position - (position - ByteOrderMarkLength) % UnitSize;
|
||||
}
|
||||
|
||||
private static bool StartsWith(byte[] bytes, params byte[] prefix)
|
||||
{
|
||||
if (bytes.Length < prefix.Length) return false;
|
||||
for (var i = 0; i < prefix.Length; i++)
|
||||
if (bytes[i] != prefix[i]) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first bytes of a log file: they carry its byte order mark and identify the file, so a
|
||||
/// file replaced under the same name is recognised even when it is already longer than the
|
||||
/// position read so far.
|
||||
/// </summary>
|
||||
internal sealed class LogFileHead
|
||||
{
|
||||
internal const int MaxBytes = 4096;
|
||||
|
||||
private readonly byte[] _bytes;
|
||||
|
||||
private LogFileHead(byte[] bytes)
|
||||
{
|
||||
_bytes = bytes;
|
||||
}
|
||||
|
||||
/// <summary>Up to <see cref="MaxBytes"/> from offset 0. Leaves the stream at an undefined position.</summary>
|
||||
public static LogFileHead Read(Stream stream)
|
||||
{
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
var buffer = new byte[(int)Math.Min(MaxBytes, stream.Length)];
|
||||
var read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
var n = stream.Read(buffer, read, buffer.Length - read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
if (read < buffer.Length) Array.Resize(ref buffer, read);
|
||||
return new LogFileHead(buffer);
|
||||
}
|
||||
|
||||
internal static LogFileHead FromBytes(byte[] bytes) => new LogFileHead(bytes);
|
||||
|
||||
public int Length => _bytes.Length;
|
||||
|
||||
public LogTextEncoding Encoding => LogTextEncoding.Detect(_bytes);
|
||||
|
||||
/// <summary>SHA-256 of the first <paramref name="length"/> bytes, Base64.</summary>
|
||||
public string Hash(int length)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
return Convert.ToBase64String(sha.ComputeHash(_bytes, 0, length));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Byte-exact line reader for the logparser collector. <see cref="Position"/> is the file
|
||||
/// offset right behind the last line returned, so a caller that stops after any line can
|
||||
/// resume exactly there — <see cref="StreamReader"/> only exposes its read-ahead boundary.
|
||||
/// <para>
|
||||
/// Line ends are those of <see cref="StreamReader.ReadLine"/>: "\r\n", "\n" and a lone "\r",
|
||||
/// in code units of the file's <see cref="LogTextEncoding"/>. A "\n" directly after the start
|
||||
/// position is skipped when the unit before it is "\r": the previous run ended on a "\r" whose
|
||||
/// "\n" had not been written yet. A line at end of file without a line end is returned with
|
||||
/// <see cref="LastLineTerminated"/> false; the caller decides whether the writer is still in
|
||||
/// it. A line longer than the cap is consumed without being kept: <see cref="ReadLine"/>
|
||||
/// returns an empty string and sets <see cref="LastLineTruncated"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class LogLineReader
|
||||
{
|
||||
private const int ReadBufferSize = 64 * 1024;
|
||||
|
||||
private readonly Stream _stream;
|
||||
private readonly LogTextEncoding _encoding;
|
||||
private readonly int _unit;
|
||||
private readonly int _maxLineBytes;
|
||||
private readonly byte[] _buffer = new byte[ReadBufferSize];
|
||||
private int _pos;
|
||||
private int _len;
|
||||
private long _bufferOffset;
|
||||
private bool _eof;
|
||||
private bool _skipLineFeedAfterCarriageReturn;
|
||||
|
||||
private byte[] _line = new byte[4096];
|
||||
private int _lineLength;
|
||||
|
||||
/// <param name="stream">Seekable stream positioned at a code-unit boundary.</param>
|
||||
public LogLineReader(Stream stream, LogTextEncoding encoding, int maxLineBytes)
|
||||
{
|
||||
if (stream == null) throw new ArgumentNullException(nameof(stream));
|
||||
if (encoding == null) throw new ArgumentNullException(nameof(encoding));
|
||||
if (maxLineBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxLineBytes));
|
||||
_stream = stream;
|
||||
_encoding = encoding;
|
||||
_unit = encoding.UnitSize;
|
||||
_maxLineBytes = maxLineBytes;
|
||||
_bufferOffset = stream.Position;
|
||||
_skipLineFeedAfterCarriageReturn = PrecededByCarriageReturn();
|
||||
}
|
||||
|
||||
/// <summary>File offset of the next unread byte, exact.</summary>
|
||||
public long Position => _bufferOffset + _pos;
|
||||
|
||||
/// <summary>File offset of the first byte of the line most recently returned.</summary>
|
||||
public long LastLineStart { get; private set; }
|
||||
|
||||
/// <summary>False for a line that reached end of file without a line end.</summary>
|
||||
public bool LastLineTerminated { get; private set; }
|
||||
|
||||
/// <summary>True when the last line was longer than the cap; it was consumed, not returned.</summary>
|
||||
public bool LastLineTruncated { get; private set; }
|
||||
|
||||
/// <summary>The next line without its line end, or null at end of file.</summary>
|
||||
public string ReadLine()
|
||||
{
|
||||
if (Position == 0 && _encoding.ByteOrderMarkLength > 0 && Ensure(_encoding.ByteOrderMarkLength))
|
||||
_pos += _encoding.ByteOrderMarkLength;
|
||||
|
||||
if (_skipLineFeedAfterCarriageReturn)
|
||||
{
|
||||
_skipLineFeedAfterCarriageReturn = false;
|
||||
if (Ensure(_unit) && UnitAt(_pos, _encoding.LineFeed))
|
||||
_pos += _unit;
|
||||
}
|
||||
|
||||
LastLineStart = Position;
|
||||
LastLineTerminated = false;
|
||||
LastLineTruncated = false;
|
||||
_lineLength = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (!Ensure(_unit))
|
||||
{
|
||||
// End of file. A partial code unit (a writer mid-character) belongs to the line.
|
||||
var rest = _len - _pos;
|
||||
if (rest > 0)
|
||||
{
|
||||
Append(_pos, rest);
|
||||
_pos = _len;
|
||||
}
|
||||
return Position > LastLineStart ? Decode() : null;
|
||||
}
|
||||
|
||||
// Scan the whole code units in the buffer for the next line end and take the text
|
||||
// before it in one copy.
|
||||
var end = _pos + (_len - _pos) / _unit * _unit;
|
||||
var scan = _pos;
|
||||
while (scan < end && !IsLineEnd(scan))
|
||||
scan += _unit;
|
||||
if (scan > _pos)
|
||||
{
|
||||
Append(_pos, scan - _pos);
|
||||
_pos = scan;
|
||||
}
|
||||
if (scan == end)
|
||||
continue; // no line end in the buffer yet — refill
|
||||
|
||||
var carriageReturn = !UnitAt(_pos, _encoding.LineFeed);
|
||||
_pos += _unit;
|
||||
if (carriageReturn && Ensure(_unit) && UnitAt(_pos, _encoding.LineFeed))
|
||||
_pos += _unit;
|
||||
LastLineTerminated = true;
|
||||
return Decode();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsLineEnd(int offset)
|
||||
{
|
||||
if (_unit == 1)
|
||||
{
|
||||
var b = _buffer[offset];
|
||||
return b == (byte)'\n' || b == (byte)'\r';
|
||||
}
|
||||
return UnitAt(offset, _encoding.LineFeed) || UnitAt(offset, _encoding.CarriageReturn);
|
||||
}
|
||||
|
||||
private bool PrecededByCarriageReturn()
|
||||
{
|
||||
var start = _bufferOffset;
|
||||
if (start - _unit < _encoding.ByteOrderMarkLength) return false;
|
||||
var previous = new byte[_unit];
|
||||
_stream.Seek(start - _unit, SeekOrigin.Begin);
|
||||
var read = 0;
|
||||
while (read < _unit)
|
||||
{
|
||||
var n = _stream.Read(previous, read, _unit - read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
_stream.Seek(start, SeekOrigin.Begin);
|
||||
if (read < _unit) return false;
|
||||
for (var i = 0; i < _unit; i++)
|
||||
if (previous[i] != _encoding.CarriageReturn[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>At least <paramref name="count"/> unread bytes in the buffer, unless the file ends first.</summary>
|
||||
private bool Ensure(int count)
|
||||
{
|
||||
while (_len - _pos < count)
|
||||
{
|
||||
if (_eof) return false;
|
||||
var remaining = _len - _pos;
|
||||
if (_pos > 0)
|
||||
{
|
||||
Buffer.BlockCopy(_buffer, _pos, _buffer, 0, remaining);
|
||||
_bufferOffset += _pos;
|
||||
_pos = 0;
|
||||
_len = remaining;
|
||||
}
|
||||
var read = _stream.Read(_buffer, _len, _buffer.Length - _len);
|
||||
if (read <= 0)
|
||||
{
|
||||
_eof = true;
|
||||
return _len - _pos >= count;
|
||||
}
|
||||
_len += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool UnitAt(int offset, byte[] unit)
|
||||
{
|
||||
for (var i = 0; i < _unit; i++)
|
||||
if (_buffer[offset + i] != unit[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Append(int offset, int count)
|
||||
{
|
||||
var room = _maxLineBytes - _lineLength;
|
||||
if (count > room)
|
||||
{
|
||||
LastLineTruncated = true;
|
||||
count = room;
|
||||
if (count <= 0) return;
|
||||
}
|
||||
if (_lineLength + count > _line.Length)
|
||||
{
|
||||
var grown = new byte[Math.Max(_line.Length * 2, _lineLength + count)];
|
||||
Buffer.BlockCopy(_line, 0, grown, 0, _lineLength);
|
||||
_line = grown;
|
||||
}
|
||||
Buffer.BlockCopy(_buffer, offset, _line, _lineLength, count);
|
||||
_lineLength += count;
|
||||
}
|
||||
|
||||
private string Decode() =>
|
||||
_lineLength == 0 || LastLineTruncated ? string.Empty : _encoding.Encoding.GetString(_line, 0, _lineLength);
|
||||
}
|
||||
}
|
||||
+254
-150
@@ -18,6 +18,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public string CollectorType => "logparser";
|
||||
|
||||
/// <summary>A wildcard target reads at most this many files per run, the newest first.</summary>
|
||||
internal const int MaxFilesPerRun = 20;
|
||||
|
||||
/// <summary>Lines one run reads from one file unless the rule sets <c>maxLines</c>.</summary>
|
||||
internal const int DefaultMaxLines = 1000;
|
||||
|
||||
/// <summary>The IME tracker's line bound: a longer physical line is skipped, never matched.</summary>
|
||||
internal const int MaxLineBytes = 32 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Executes the log parser rule. Returns null because logparser emits events directly
|
||||
/// via context.OnEventCollected rather than returning a single result dictionary.
|
||||
@@ -34,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
@@ -84,10 +93,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr))
|
||||
bool.TryParse(trackPositionStr, out trackPosition);
|
||||
|
||||
// A value that is no positive number keeps the default: a run capped at 0 lines would
|
||||
// never advance its position, and the rule would read nothing for the whole session.
|
||||
string maxLinesStr;
|
||||
int maxLines = 1000;
|
||||
int maxLines = DefaultMaxLines;
|
||||
if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr))
|
||||
int.TryParse(maxLinesStr, out maxLines);
|
||||
{
|
||||
int parsedMaxLines;
|
||||
if (int.TryParse(maxLinesStr, out parsedMaxLines) && parsedMaxLines > 0)
|
||||
maxLines = parsedMaxLines;
|
||||
else
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"maxLines '{maxLinesStr}' is not a positive number — using {DefaultMaxLines}");
|
||||
}
|
||||
|
||||
// Determine format: "cmtrace" (default) or "text" for plain text logs
|
||||
string formatStr;
|
||||
@@ -144,7 +162,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
/// as the event data). Stops after <see cref="MaxTracedMatchesPerFile"/> matches.
|
||||
/// </summary>
|
||||
private static void TraceMatch(GatherRuleContext context, GatherRule rule, string fileName,
|
||||
int lineNumber, Match match, Regex pattern, int matchNumber)
|
||||
long lineNumber, Match match, Regex pattern, int matchNumber)
|
||||
{
|
||||
if (matchNumber > MaxTracedMatchesPerFile)
|
||||
{
|
||||
@@ -195,14 +213,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
try
|
||||
{
|
||||
// Return matched files sorted by last write time (newest first), capped at 20
|
||||
// Return matched files sorted by last write time (newest first), capped at MaxFilesPerRun
|
||||
var allMatches = Directory.GetFiles(directory, fileNamePart);
|
||||
if (allMatches.Length > 20)
|
||||
if (allMatches.Length > MaxFilesPerRun)
|
||||
context.DebugLog(ruleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"wildcard matched {allMatches.Length} files — capped to the 20 newest");
|
||||
$"wildcard matched {allMatches.Length} files — capped to the {MaxFilesPerRun} newest");
|
||||
return allMatches
|
||||
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
|
||||
.Take(20)
|
||||
.Take(MaxFilesPerRun)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -216,183 +234,248 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
private static void ProcessLogFile(string filePath, GatherRule rule, Regex pattern,
|
||||
bool trackPosition, int maxLines, bool isTextMode, GatherRuleContext context)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
long startPosition = trackPosition
|
||||
? context.FilePositionTracker.GetSafePosition(filePath, fileInfo.Length)
|
||||
: 0;
|
||||
var state = context.SessionState;
|
||||
var known = trackPosition ? state.PeekLogPosition(rule.RuleId, filePath) : null;
|
||||
|
||||
// Nothing new to read
|
||||
if (startPosition >= fileInfo.Length)
|
||||
// Nothing new since the last run — decided from the length alone, without opening the file.
|
||||
var length = new FileInfo(filePath).Length;
|
||||
if (known != null && known.Position == length)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{Path.GetFileName(filePath)}: no new content (position {startPosition} >= length {fileInfo.Length}) — trackPosition=true, nothing re-read");
|
||||
$"{fileName}: no new content (position {known.Position} >= length {length}) — trackPosition=true, nothing re-read" +
|
||||
(known.FromPreviousRun ? "; position saved by a previous agent run" : ""));
|
||||
return;
|
||||
}
|
||||
|
||||
// A last line without a line end is usually one its writer is still in. An interval
|
||||
// rule leaves it for its next run, once. A one-shot trigger may have no next run, so it
|
||||
// reads the line now but saves the position before it: the next run reads it again,
|
||||
// whole — at worst a match repeats, it is never split.
|
||||
var isInterval = string.Equals(rule.Trigger, "interval", StringComparison.OrdinalIgnoreCase);
|
||||
var mayHoldTail = trackPosition && isInterval;
|
||||
var tailReadAgainNextRun = false;
|
||||
|
||||
int matchCount = 0;
|
||||
int linesRead = 0;
|
||||
int committedLines = 0;
|
||||
int parseFailCount = 0;
|
||||
int timeoutCount = 0;
|
||||
long endPosition = startPosition;
|
||||
int oversizedCount = 0;
|
||||
long startPosition;
|
||||
long endPosition;
|
||||
long lineNumberBase;
|
||||
long fileLength;
|
||||
long heldTailStart = -1;
|
||||
LogFileHead head;
|
||||
LogTextEncoding encoding;
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
fileLength = stream.Length;
|
||||
head = LogFileHead.Read(stream);
|
||||
encoding = head.Encoding;
|
||||
|
||||
LogReadStart start;
|
||||
startPosition = encoding.AlignDown(GatherRuleSessionState.ResolveStart(known, fileLength, head, out start));
|
||||
lineNumberBase = start == LogReadStart.Continue ? known.LineNumber : 0;
|
||||
TraceReadStart(context, rule, fileName, known, start, fileLength);
|
||||
|
||||
stream.Seek(startPosition, SeekOrigin.Begin);
|
||||
var reader = new LogLineReader(stream, encoding, MaxLineBytes);
|
||||
endPosition = startPosition;
|
||||
|
||||
using (var reader = new StreamReader(stream, Encoding.UTF8))
|
||||
while (linesRead < maxLines)
|
||||
{
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) != null && linesRead < maxLines)
|
||||
var line = reader.ReadLine();
|
||||
if (line == null)
|
||||
{
|
||||
linesRead++;
|
||||
|
||||
if (isTextMode)
|
||||
{
|
||||
// Text mode: match regex directly against the raw line
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(line);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
data["logLine"] = TruncateMessage(line, 500);
|
||||
data["logLineNumber"] = linesRead;
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// CMTrace mode: parse line as CMTrace format, match regex against message
|
||||
CmTraceLogEntry entry;
|
||||
if (!CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
parseFailCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(entry.Message);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
// Gather-rule logs are arbitrary files this collector does not
|
||||
// tail, so no writer offset can be measured for them; the
|
||||
// reader-zone fallback carries its known defect (see
|
||||
// ResolveUtcAssumingReaderZone). Behaviour unchanged from before
|
||||
// the parser split.
|
||||
var entryUtc = entry.TimestampUtc
|
||||
?? (entry.HasTimestamp
|
||||
? CmTraceLogParser.ResolveUtcAssumingReaderZone(entry.LocalTimestamp)
|
||||
: DateTime.UtcNow);
|
||||
data["logTimestamp"] = entryUtc.ToString("o");
|
||||
data["logComponent"] = entry.Component;
|
||||
data["logType"] = entry.Type;
|
||||
data["logMessage"] = TruncateMessage(entry.Message, 500);
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = entryUtc,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
// End of file. A "\n" the reader skipped behind the "\r" the previous run
|
||||
// ended on is consumed too — unless a kept tail must be read again.
|
||||
if (!tailReadAgainNextRun)
|
||||
endPosition = reader.Position;
|
||||
break;
|
||||
}
|
||||
|
||||
// Capture the final stream position after reading
|
||||
endPosition = stream.Position;
|
||||
var unfinished = !reader.LastLineTerminated;
|
||||
if (unfinished && mayHoldTail
|
||||
&& (known == null || known.HeldTailStart != reader.LastLineStart))
|
||||
{
|
||||
heldTailStart = reader.LastLineStart;
|
||||
break;
|
||||
}
|
||||
|
||||
linesRead++;
|
||||
var lineNumber = lineNumberBase + linesRead;
|
||||
if (unfinished && trackPosition && !isInterval)
|
||||
{
|
||||
tailReadAgainNextRun = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
endPosition = reader.Position;
|
||||
committedLines = linesRead;
|
||||
}
|
||||
|
||||
if (reader.LastLineTruncated)
|
||||
{
|
||||
oversizedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTextMode)
|
||||
{
|
||||
// Text mode: match regex directly against the raw line
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(line);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
data["logLine"] = TruncateMessage(line, 500);
|
||||
data["logLineNumber"] = lineNumber;
|
||||
data["logFile"] = fileName;
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, fileName, lineNumber, match, pattern, matchCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// CMTrace mode: parse line as CMTrace format, match regex against message
|
||||
CmTraceLogEntry entry;
|
||||
if (!CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
parseFailCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(entry.Message);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
// Gather-rule logs are arbitrary files this collector does not
|
||||
// tail, so no writer offset can be measured for them; the
|
||||
// reader-zone fallback carries its known defect (see
|
||||
// ResolveUtcAssumingReaderZone). Behaviour unchanged from before
|
||||
// the parser split.
|
||||
var entryUtc = entry.TimestampUtc
|
||||
?? (entry.HasTimestamp
|
||||
? CmTraceLogParser.ResolveUtcAssumingReaderZone(entry.LocalTimestamp)
|
||||
: DateTime.UtcNow);
|
||||
data["logTimestamp"] = entryUtc.ToString("o");
|
||||
data["logComponent"] = entry.Component;
|
||||
data["logType"] = entry.Type;
|
||||
data["logMessage"] = TruncateMessage(entry.Message, 500);
|
||||
data["logFile"] = fileName;
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = entryUtc,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, fileName, lineNumber, match, pattern, matchCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Saved only now, after every event of this file went out: a process that dies
|
||||
// before this line repeats the file's new lines after the restart, never skips them.
|
||||
if (trackPosition)
|
||||
context.FilePositionTracker.SetPosition(filePath, endPosition);
|
||||
state.CommitLogPosition(rule.RuleId, filePath, endPosition, lineNumberBase + committedLines, head, heldTailStart);
|
||||
|
||||
if (matchCount > 0)
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {fileName}");
|
||||
|
||||
// Per-file outcome — always written so a "matched 0" run is visible too.
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{Path.GetFileName(filePath)}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
|
||||
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}");
|
||||
$"{fileName}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
|
||||
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}, encoding={encoding.Name}" +
|
||||
(oversizedCount > 0 ? $", skipped {oversizedCount} line(s) over {MaxLineBytes / (1024 * 1024)} MB" : ""));
|
||||
|
||||
if (heldTailStart >= 0)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: last line has no line end yet — left for the next run, read then even if still unfinished");
|
||||
else if (tailReadAgainNextRun)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: last line has no line end yet — read now and again by the next run, position kept before it");
|
||||
|
||||
if (!isTextMode && linesRead > 0 && parseFailCount == linesRead)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
"every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
|
||||
|
||||
if (linesRead == maxLines)
|
||||
if (linesRead == maxLines && endPosition < fileLength)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"stopped at maxLines={maxLines} — remaining content is deferred to the next run");
|
||||
}
|
||||
@@ -403,6 +486,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraceReadStart(GatherRuleContext context, GatherRule rule, string fileName,
|
||||
LogPositionSnapshot known, LogReadStart start, long fileLength)
|
||||
{
|
||||
if (start == LogReadStart.Shrunk)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: file is shorter than the saved position (length {fileLength} < {known.Position}) — truncated or recreated, reading from the beginning");
|
||||
}
|
||||
else if (start == LogReadStart.Replaced)
|
||||
{
|
||||
context.Logger.Info($"LogParser rule {rule.RuleId}: {fileName} was replaced since the last read — reading it from the beginning");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: file start changed since the last read (replaced or rewritten) — saved position {known.Position} discarded, reading from the beginning");
|
||||
}
|
||||
else if (start == LogReadStart.Continue && known.FromPreviousRun)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: continuing at position {known.Position} (after line {known.LineNumber}) saved by a previous agent run");
|
||||
}
|
||||
}
|
||||
|
||||
private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride)
|
||||
{
|
||||
// If the rule specifies a severity, use it
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
|
||||
+6
-4
@@ -4,7 +4,6 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
@@ -19,7 +18,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public string TenantId { get; }
|
||||
public Action<EnrollmentEvent> OnEventCollected { get; }
|
||||
public string ImeLogPathOverride { get; }
|
||||
public LogFilePositionTracker FilePositionTracker { get; }
|
||||
|
||||
/// <summary>Logparser positions per (rule, file) and on_change values; survives agent restarts when persisted.</summary>
|
||||
public GatherRuleSessionState SessionState { get; }
|
||||
|
||||
public bool UnrestrictedMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -34,7 +36,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
string imeLogPathOverride,
|
||||
LogFilePositionTracker filePositionTracker,
|
||||
GatherRuleSessionState sessionState,
|
||||
GatherRuleDebugLog debugLog = null)
|
||||
{
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -42,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
ImeLogPathOverride = imeLogPathOverride;
|
||||
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
|
||||
SessionState = sessionState ?? throw new ArgumentNullException(nameof(sessionState));
|
||||
DebugWriter = debugLog;
|
||||
}
|
||||
|
||||
|
||||
+56
-9
@@ -10,7 +10,6 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
@@ -38,6 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
|
||||
private readonly GatherRuleSessionState _state;
|
||||
private readonly GatherRuleContext _context;
|
||||
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
|
||||
|
||||
@@ -50,12 +50,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Phase-scope + emit-mode state (ActivePhases / ActiveFromPhase / EmitMode on GatherRule).
|
||||
// Guarded by _scopeLock; collector work always runs outside the lock. The state deliberately
|
||||
// survives UpdateRules config refreshes — an agent restart causes at most one re-emit per
|
||||
// on_change rule (acceptable under the per-enrollment lifecycle).
|
||||
// survives UpdateRules config refreshes. Across agent restarts only the last emitted hash
|
||||
// survives: it is seeded from _state and committed there after each emit, so an unchanged
|
||||
// on_change result stays silent after a reboot. Phase latches, dedup sets and the
|
||||
// suppression streak are process-scoped.
|
||||
private readonly object _scopeLock = new object();
|
||||
private EnrollmentPhase _currentPhase = EnrollmentPhase.Unknown;
|
||||
private readonly HashSet<string> _fromPhaseLatched = new HashSet<string>();
|
||||
private readonly Dictionary<string, string> _lastEmittedHash = new Dictionary<string, string>();
|
||||
private readonly HashSet<string> _hashFromPreviousRun = new HashSet<string>();
|
||||
private readonly Dictionary<string, (int Count, DateTime SinceUtc)> _suppressed =
|
||||
new Dictionary<string, (int Count, DateTime SinceUtc)>();
|
||||
private readonly HashSet<string> _scopeBypassLogged = new HashSet<string>();
|
||||
@@ -77,9 +80,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
set { _context.UnrestrictedMode = value; }
|
||||
}
|
||||
|
||||
/// <param name="stateDirectory">
|
||||
/// Where logparser positions and on_change values are kept across agent restarts. Null keeps
|
||||
/// them in memory — required for <c>--run-gather-rules</c>, which runs beside the live agent
|
||||
/// under a throwaway session id and must not overwrite the session's file.
|
||||
/// </param>
|
||||
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger, string imeLogPathOverride = null, string debugLogPath = null,
|
||||
Action<string> debugEcho = null)
|
||||
Action<string> debugEcho = null, string stateDirectory = null)
|
||||
{
|
||||
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
|
||||
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -89,8 +97,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
_logger = logger;
|
||||
_debug = string.IsNullOrEmpty(debugLogPath) ? null : new GatherRuleDebugLog(debugLogPath, logger, debugEcho);
|
||||
|
||||
var filePositionTracker = new LogFilePositionTracker();
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker, _debug);
|
||||
var persistence = string.IsNullOrEmpty(stateDirectory) ? null : new GatherRuleStatePersistence(stateDirectory, logger);
|
||||
_state = new GatherRuleSessionState(sessionId, persistence, logger);
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, _state, _debug);
|
||||
|
||||
foreach (var hash in _state.GetEmittedHashes())
|
||||
{
|
||||
_lastEmittedHash[hash.Key] = hash.Value;
|
||||
_hashFromPreviousRun.Add(hash.Key);
|
||||
}
|
||||
_logger.Info($"GatherRuleExecutor: gather state {_state.RestoreSummary}");
|
||||
DebugLog(null, GatherRuleDebugLog.StageConfig, $"gather state: {_state.RestoreSummary}");
|
||||
foreach (var detail in _state.RestoredDetails)
|
||||
DebugLog(detail.Key, GatherRuleDebugLog.StageConfig, detail.Value);
|
||||
|
||||
// Register all collector strategies
|
||||
var collectorList = new IGatherRuleCollector[]
|
||||
@@ -480,14 +499,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// empty result never reaches this point, so an emitOnlyIfExists miss neither
|
||||
// emits nor updates the hash — composing to "one event on appearance, then
|
||||
// only on change".
|
||||
string emittedHash = null;
|
||||
if (IsOnChangeMode(rule))
|
||||
{
|
||||
string hashPrefix;
|
||||
int suppressedStreak;
|
||||
if (!ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak))
|
||||
bool comparedWithPreviousRun;
|
||||
if (!ShouldEmitOnChange(rule, result, out emittedHash, out hashPrefix, out suppressedStreak, out comparedWithPreviousRun))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageSuppress,
|
||||
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}");
|
||||
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}" +
|
||||
(comparedWithPreviousRun ? "; compared with the value a previous agent run emitted" : ""));
|
||||
return;
|
||||
}
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
@@ -524,6 +546,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = result
|
||||
});
|
||||
|
||||
// Committed only after the event went out: a process that dies in between emits
|
||||
// the result once more after the restart instead of never.
|
||||
if (emittedHash != null)
|
||||
_state.CommitEmittedHash(rule.RuleId, emittedHash);
|
||||
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
$"emitted {eventType} (severity={severity}, {result.Count} fields)");
|
||||
}
|
||||
@@ -679,9 +707,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
|
||||
out string hashPrefix, out int suppressedStreak)
|
||||
{
|
||||
var hash = ComputeCanonicalHash(result);
|
||||
string hash;
|
||||
bool comparedWithPreviousRun;
|
||||
return ShouldEmitOnChange(rule, result, out hash, out hashPrefix, out suppressedStreak, out comparedWithPreviousRun);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full form: <paramref name="hash"/> is the result's canonical hash, committed to the session
|
||||
/// state once the event went out; <paramref name="comparedWithPreviousRun"/> says a suppression
|
||||
/// compared against the value a previous agent run emitted.
|
||||
/// </summary>
|
||||
private bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
|
||||
out string hash, out string hashPrefix, out int suppressedStreak, out bool comparedWithPreviousRun)
|
||||
{
|
||||
hash = ComputeCanonicalHash(result);
|
||||
hashPrefix = hash.Length > 8 ? hash.Substring(0, 8) : hash;
|
||||
suppressedStreak = 0;
|
||||
comparedWithPreviousRun = false;
|
||||
bool emit;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
@@ -693,11 +735,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
? (streak.Count + 1, streak.SinceUtc)
|
||||
: (1, DateTime.UtcNow);
|
||||
suppressedStreak = _suppressed[rule.RuleId].Count;
|
||||
comparedWithPreviousRun = _hashFromPreviousRun.Contains(rule.RuleId);
|
||||
emit = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastEmittedHash[rule.RuleId] = hash;
|
||||
_hashFromPreviousRun.Remove(rule.RuleId);
|
||||
(int Count, DateTime SinceUtc) streak;
|
||||
if (_suppressed.TryGetValue(rule.RuleId, out streak) && streak.Count > 0)
|
||||
{
|
||||
@@ -820,6 +864,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
StopAllTimers();
|
||||
_startupRulesLatch?.Dispose();
|
||||
_startupRulesLatch = null;
|
||||
// A rule still finishing on a pool thread keeps its in-memory state but no longer writes:
|
||||
// the agent may be removing its own folder right now.
|
||||
_state.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Gather-rule state of one enrollment session that survives agent restarts: where each
|
||||
/// logparser rule stopped reading each file, and the on_change value each rule last emitted.
|
||||
/// <para>
|
||||
/// Positions are kept per (rule, file) — two rules parsing the same log each read all of it.
|
||||
/// Every position carries a fingerprint of the file's first bytes, so a file replaced under
|
||||
/// the same name is read from the beginning even when it is already longer than the position.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every commit is written to disk synchronously, AFTER the events it covers were handed on:
|
||||
/// a process that dies in between repeats that one pass after the restart instead of losing it.
|
||||
/// The lock guards memory and file write together and is never taken under the executor's
|
||||
/// scope lock. Without a persistence (diagnostic mode, tests) the state lives in memory only.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GatherRuleSessionState
|
||||
{
|
||||
/// <summary>
|
||||
/// Files remembered per rule. A run reads at most <see cref="LogParserCollector.MaxFilesPerRun"/>
|
||||
/// files; the least recently touched entry beyond the cap is dropped, and its file — should it
|
||||
/// come back — is simply read again.
|
||||
/// </summary>
|
||||
internal const int MaxLogFilesPerRule = 5 * LogParserCollector.MaxFilesPerRun;
|
||||
|
||||
private static readonly char[] WildcardChars = { '*', '?' };
|
||||
|
||||
private readonly object _gate = new object();
|
||||
private readonly string _sessionId;
|
||||
private readonly GatherRuleStatePersistence _persistence;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Dictionary<string, Dictionary<string, Entry>> _logPositions =
|
||||
new Dictionary<string, Dictionary<string, Entry>>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _emittedHashes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
private readonly List<KeyValuePair<string, string>> _restoredDetails = new List<KeyValuePair<string, string>>();
|
||||
private long _touchCounter;
|
||||
private bool _unsaved;
|
||||
private bool _saveFailureLogged;
|
||||
private bool _closed;
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
public long Position;
|
||||
public long LineNumber;
|
||||
public int HeadLength;
|
||||
public string HeadHash;
|
||||
public bool FromPreviousRun;
|
||||
public long HeldTailStart = -1;
|
||||
public long LastTouched;
|
||||
}
|
||||
|
||||
public GatherRuleSessionState(string sessionId, GatherRuleStatePersistence persistence, AgentLogger logger)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_persistence = persistence;
|
||||
RestoreSummary = Restore();
|
||||
}
|
||||
|
||||
/// <summary>One line on what the constructor found on disk — for the agent log and the debug trace.</summary>
|
||||
public string RestoreSummary { get; }
|
||||
|
||||
/// <summary>(ruleId, message) per restored position and on_change value — for the debug trace.</summary>
|
||||
internal IReadOnlyList<KeyValuePair<string, string>> RestoredDetails => _restoredDetails;
|
||||
|
||||
// ===== logparser positions =====
|
||||
|
||||
/// <summary>The recorded position of <paramref name="filePath"/> for the rule, or null when it has none.</summary>
|
||||
internal LogPositionSnapshot PeekLogPosition(string ruleId, string filePath)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Dictionary<string, Entry> files;
|
||||
Entry entry;
|
||||
if (!_logPositions.TryGetValue(ruleId, out files) || !files.TryGetValue(filePath, out entry))
|
||||
return null;
|
||||
entry.LastTouched = ++_touchCounter;
|
||||
return new LogPositionSnapshot(entry.Position, entry.LineNumber, entry.HeadLength, entry.HeadHash,
|
||||
entry.FromPreviousRun, entry.HeldTailStart);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records where the rule stopped in the file and saves the state when it changed.
|
||||
/// <paramref name="heldTailStart"/> (in memory only) marks an unfinished last line left for
|
||||
/// the next run; -1 when none.
|
||||
/// </summary>
|
||||
internal void CommitLogPosition(string ruleId, string filePath, long position, long lineNumber,
|
||||
LogFileHead head, long heldTailStart)
|
||||
{
|
||||
var headLength = head.Length;
|
||||
var headHash = headLength > 0 ? head.Hash(headLength) : null;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
Dictionary<string, Entry> files;
|
||||
if (!_logPositions.TryGetValue(ruleId, out files))
|
||||
{
|
||||
files = new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
|
||||
_logPositions[ruleId] = files;
|
||||
}
|
||||
|
||||
Entry entry;
|
||||
var changed = false;
|
||||
if (!files.TryGetValue(filePath, out entry))
|
||||
{
|
||||
entry = new Entry();
|
||||
files[filePath] = entry;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed || entry.Position != position || entry.LineNumber != lineNumber
|
||||
|| entry.HeadLength != headLength || !string.Equals(entry.HeadHash, headHash, StringComparison.Ordinal))
|
||||
{
|
||||
entry.Position = position;
|
||||
entry.LineNumber = lineNumber;
|
||||
entry.HeadLength = headLength;
|
||||
entry.HeadHash = headHash;
|
||||
entry.FromPreviousRun = false;
|
||||
changed = true;
|
||||
}
|
||||
entry.HeldTailStart = heldTailStart;
|
||||
entry.LastTouched = ++_touchCounter;
|
||||
|
||||
if (files.Count > MaxLogFilesPerRule)
|
||||
EvictLeastRecentlyTouched(ruleId, files);
|
||||
|
||||
if (changed || _unsaved)
|
||||
SaveLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where to continue reading: the recorded position while the file is still the one it was
|
||||
/// recorded for, otherwise 0 with the reason.
|
||||
/// </summary>
|
||||
internal static long ResolveStart(LogPositionSnapshot known, long fileLength, LogFileHead head, out LogReadStart start)
|
||||
{
|
||||
if (known == null)
|
||||
{
|
||||
start = LogReadStart.FirstRead;
|
||||
return 0;
|
||||
}
|
||||
if (fileLength < known.Position)
|
||||
{
|
||||
start = LogReadStart.Shrunk;
|
||||
return 0;
|
||||
}
|
||||
if (known.HeadLength > 0
|
||||
&& (head.Length < known.HeadLength
|
||||
|| !string.Equals(head.Hash(known.HeadLength), known.HeadHash, StringComparison.Ordinal)))
|
||||
{
|
||||
start = LogReadStart.Replaced;
|
||||
return 0;
|
||||
}
|
||||
start = LogReadStart.Continue;
|
||||
return known.Position;
|
||||
}
|
||||
|
||||
// ===== on_change values =====
|
||||
|
||||
/// <summary>Rule id → hash of the result the rule last emitted.</summary>
|
||||
internal Dictionary<string, string> GetEmittedHashes()
|
||||
{
|
||||
lock (_gate)
|
||||
return new Dictionary<string, string>(_emittedHashes, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Records the hash of a result that was just emitted and saves the state when it changed.</summary>
|
||||
internal void CommitEmittedHash(string ruleId, string hash)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
string current;
|
||||
var changed = !_emittedHashes.TryGetValue(ruleId, out current)
|
||||
|| !string.Equals(current, hash, StringComparison.Ordinal);
|
||||
_emittedHashes[ruleId] = hash;
|
||||
if (changed || _unsaved)
|
||||
SaveLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retries a save that failed earlier, then stops writing (executor disposed).</summary>
|
||||
internal void Close()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_unsaved)
|
||||
SaveLocked();
|
||||
_closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== persistence =====
|
||||
|
||||
private void SaveLocked()
|
||||
{
|
||||
if (_persistence == null || _closed)
|
||||
{
|
||||
_unsaved = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new GatherRuleStateData
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
SavedAtUtc = DateTime.UtcNow,
|
||||
LogPositions = _logPositions
|
||||
.SelectMany(rule => rule.Value.Select(file => new { RuleId = rule.Key, Path = file.Key, Entry = file.Value }))
|
||||
.OrderBy(x => x.Entry.LastTouched)
|
||||
.Select(x => new GatherLogPositionData
|
||||
{
|
||||
RuleId = x.RuleId,
|
||||
Path = x.Path,
|
||||
Position = x.Entry.Position,
|
||||
LineNumber = x.Entry.LineNumber,
|
||||
HeadLength = x.Entry.HeadLength,
|
||||
HeadHash = x.Entry.HeadHash,
|
||||
})
|
||||
.ToList(),
|
||||
OnChangeHashes = new Dictionary<string, string>(_emittedHashes, StringComparer.Ordinal),
|
||||
};
|
||||
|
||||
if (_persistence.Save(data))
|
||||
{
|
||||
_unsaved = false;
|
||||
_saveFailureLogged = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_unsaved = true;
|
||||
if (!_saveFailureLogged)
|
||||
{
|
||||
_saveFailureLogged = true;
|
||||
_logger.Warning($"GatherRuleSessionState: saving {_persistence.StateFilePath} failed — kept in memory, retried at the next change");
|
||||
}
|
||||
}
|
||||
|
||||
private string Restore()
|
||||
{
|
||||
if (_persistence == null)
|
||||
return "persistence off (no state directory) — positions and on_change values live in memory only";
|
||||
|
||||
string discardReason;
|
||||
var data = _persistence.Load(_sessionId, out discardReason);
|
||||
if (data == null)
|
||||
{
|
||||
return discardReason == null
|
||||
? "no saved state — first agent run of this session"
|
||||
: $"saved state discarded ({discardReason}) — starting fresh";
|
||||
}
|
||||
|
||||
// The file is input like any other: whatever it holds, the executor must come up. A
|
||||
// failure here would otherwise stop every collector host, at every restart of the session.
|
||||
try
|
||||
{
|
||||
return RestoreFrom(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logPositions.Clear();
|
||||
_emittedHashes.Clear();
|
||||
_restoredDetails.Clear();
|
||||
_logger.Warning($"GatherRuleSessionState: restoring {_persistence.StateFilePath} failed, starting fresh: {ex.Message}");
|
||||
return $"saved state discarded (restore failed: {ex.Message}) — starting fresh";
|
||||
}
|
||||
}
|
||||
|
||||
private string RestoreFrom(GatherRuleStateData data)
|
||||
{
|
||||
var dropped = 0;
|
||||
foreach (var position in data.LogPositions ?? new List<GatherLogPositionData>())
|
||||
{
|
||||
if (!IsValid(position))
|
||||
{
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Dictionary<string, Entry> files;
|
||||
if (!_logPositions.TryGetValue(position.RuleId, out files))
|
||||
{
|
||||
files = new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
|
||||
_logPositions[position.RuleId] = files;
|
||||
}
|
||||
files[position.Path] = new Entry
|
||||
{
|
||||
Position = position.Position,
|
||||
LineNumber = position.LineNumber,
|
||||
HeadLength = position.HeadLength,
|
||||
HeadHash = position.HeadLength > 0 ? position.HeadHash : null,
|
||||
FromPreviousRun = true,
|
||||
LastTouched = ++_touchCounter,
|
||||
};
|
||||
if (files.Count > MaxLogFilesPerRule)
|
||||
EvictLeastRecentlyTouched(position.RuleId, files);
|
||||
}
|
||||
|
||||
foreach (var hash in data.OnChangeHashes ?? new Dictionary<string, string>())
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash.Key) || string.IsNullOrEmpty(hash.Value))
|
||||
{
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
_emittedHashes[hash.Key] = hash.Value;
|
||||
}
|
||||
|
||||
foreach (var rule in _logPositions)
|
||||
{
|
||||
foreach (var file in rule.Value)
|
||||
{
|
||||
_restoredDetails.Add(new KeyValuePair<string, string>(rule.Key,
|
||||
$"logparser position restored: {Path.GetFileName(file.Key)} at byte {file.Value.Position}, after line {file.Value.LineNumber}"));
|
||||
}
|
||||
}
|
||||
foreach (var hash in _emittedHashes)
|
||||
{
|
||||
_restoredDetails.Add(new KeyValuePair<string, string>(hash.Key,
|
||||
$"on_change value restored (hash {Prefix(hash.Value)}) — an unchanged result stays silent"));
|
||||
}
|
||||
|
||||
var positionCount = _logPositions.Sum(rule => rule.Value.Count);
|
||||
return $"restored {positionCount} logparser position(s) for {_logPositions.Count} rule(s) and {_emittedHashes.Count} on_change value(s), " +
|
||||
$"saved {data.SavedAtUtc:yyyy-MM-ddTHH:mm:ssZ} by a previous agent run of this session" +
|
||||
(dropped > 0 ? $"; {dropped} invalid entr{(dropped == 1 ? "y" : "ies")} dropped" : "") +
|
||||
" — logparser rules continue after their saved position, on_change rules stay silent until their result changes";
|
||||
}
|
||||
|
||||
private static bool IsValid(GatherLogPositionData position)
|
||||
{
|
||||
if (position == null || string.IsNullOrEmpty(position.RuleId) || string.IsNullOrEmpty(position.Path))
|
||||
return false;
|
||||
// A real resolved path never carries these; Path APIs throw on them under .NET Framework.
|
||||
if (position.Path.IndexOfAny(Path.GetInvalidPathChars()) >= 0 || position.Path.IndexOfAny(WildcardChars) >= 0)
|
||||
return false;
|
||||
if (position.Position < 0 || position.LineNumber < 0)
|
||||
return false;
|
||||
if (position.HeadLength < 0 || position.HeadLength > LogFileHead.MaxBytes)
|
||||
return false;
|
||||
if (position.HeadLength > 0 && string.IsNullOrEmpty(position.HeadHash))
|
||||
return false;
|
||||
// Reading any byte records a non-empty head; a position without one cannot be verified.
|
||||
return position.Position == 0 || position.HeadLength > 0;
|
||||
}
|
||||
|
||||
private void EvictLeastRecentlyTouched(string ruleId, Dictionary<string, Entry> files)
|
||||
{
|
||||
var oldest = files.OrderBy(file => file.Value.LastTouched).First();
|
||||
files.Remove(oldest.Key);
|
||||
_logger.Info($"GatherRuleSessionState: rule {ruleId} tracks more than {MaxLogFilesPerRule} files — forgot the position of {Path.GetFileName(oldest.Key)}");
|
||||
}
|
||||
|
||||
private static string Prefix(string hash) => hash.Length > 8 ? hash.Substring(0, 8) : hash;
|
||||
}
|
||||
|
||||
/// <summary>Immutable copy of a recorded logparser position.</summary>
|
||||
internal sealed class LogPositionSnapshot
|
||||
{
|
||||
public LogPositionSnapshot(long position, long lineNumber, int headLength, string headHash,
|
||||
bool fromPreviousRun, long heldTailStart)
|
||||
{
|
||||
Position = position;
|
||||
LineNumber = lineNumber;
|
||||
HeadLength = headLength;
|
||||
HeadHash = headHash;
|
||||
FromPreviousRun = fromPreviousRun;
|
||||
HeldTailStart = heldTailStart;
|
||||
}
|
||||
|
||||
public long Position { get; }
|
||||
public long LineNumber { get; }
|
||||
public int HeadLength { get; }
|
||||
public string HeadHash { get; }
|
||||
|
||||
/// <summary>Restored from disk and not advanced by this process yet.</summary>
|
||||
public bool FromPreviousRun { get; }
|
||||
|
||||
/// <summary>Start of an unfinished last line left for this run by the previous one; -1 when none.</summary>
|
||||
public long HeldTailStart { get; }
|
||||
}
|
||||
|
||||
/// <summary>How <see cref="GatherRuleSessionState.ResolveStart"/> decided where reading starts.</summary>
|
||||
internal enum LogReadStart
|
||||
{
|
||||
FirstRead,
|
||||
Continue,
|
||||
Shrunk,
|
||||
Replaced,
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists <see cref="GatherRuleSessionState"/> in the agent's state directory so one
|
||||
/// enrollment's logparser positions and on_change values survive agent restarts, reboots and
|
||||
/// the WhiteGlove Part 2 resume.
|
||||
/// <para>
|
||||
/// The state is only resumed for the SAME session id: the state directory is one fixed folder
|
||||
/// that a session rotation does not clear, and the new session starts empty in the backend, so
|
||||
/// it must read its logs from the beginning.
|
||||
/// </para>
|
||||
/// Fail-soft like <c>BandwidthStatePersistence</c>: I/O errors never throw; a missing, corrupt
|
||||
/// or foreign file loads as null (fresh start — the pre-persistence behavior).
|
||||
/// </summary>
|
||||
public class GatherRuleStatePersistence
|
||||
{
|
||||
public const string FileName = "gather-rule-state.json";
|
||||
|
||||
private const string SourceName = "GatherRuleStatePersistence";
|
||||
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _stateFilePath;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public GatherRuleStatePersistence(string stateDirectory, AgentLogger logger)
|
||||
{
|
||||
_stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
|
||||
_stateFilePath = Path.Combine(_stateDirectory, FileName);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string StateFilePath => _stateFilePath;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the persisted state, or null when absent, unreadable, or written for a different
|
||||
/// session. <paramref name="discardReason"/> is null when there simply was no file.
|
||||
/// </summary>
|
||||
public GatherRuleStateData Load(string expectedSessionId, out string discardReason)
|
||||
{
|
||||
discardReason = null;
|
||||
if (!File.Exists(_stateFilePath)) return null;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_stateFilePath);
|
||||
var state = JsonConvert.DeserializeObject<GatherRuleStateData>(json);
|
||||
if (state == null || string.IsNullOrEmpty(state.SessionId))
|
||||
{
|
||||
discardReason = "state file empty or invalid";
|
||||
_logger.Warning($"[{SourceName}] persisted state file was empty or invalid — starting fresh");
|
||||
return null;
|
||||
}
|
||||
if (!string.Equals(state.SessionId, expectedSessionId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
discardReason = $"saved for session {state.SessionId}, current session is {expectedSessionId}";
|
||||
_logger.Warning($"[{SourceName}] persisted state belongs to session {state.SessionId}, not {expectedSessionId} — starting fresh");
|
||||
return null;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
discardReason = $"state file unreadable: {ex.Message}";
|
||||
_logger.Warning($"[{SourceName}] failed to load persisted state, starting fresh: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Persists the state (atomic write via temp file). Never throws; false when the write failed.</summary>
|
||||
public bool Save(GatherRuleStateData state)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var tempPath = _stateFilePath + ".tmp";
|
||||
File.WriteAllText(tempPath, JsonConvert.SerializeObject(state));
|
||||
if (File.Exists(_stateFilePath))
|
||||
{
|
||||
File.Replace(tempPath, _stateFilePath, destinationBackupFileName: null, ignoreMetadataErrors: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(tempPath, _stateFilePath);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[{SourceName}] failed to save state: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DTOs for state serialization ----
|
||||
|
||||
public class GatherRuleStateData
|
||||
{
|
||||
public string SessionId { get; set; }
|
||||
public DateTime SavedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Least recently used first, so a restore keeps the eviction order.</summary>
|
||||
public List<GatherLogPositionData> LogPositions { get; set; }
|
||||
|
||||
/// <summary>Rule id → canonical hash of the result the rule last emitted (on_change).</summary>
|
||||
public Dictionary<string, string> OnChangeHashes { get; set; }
|
||||
}
|
||||
|
||||
public class GatherLogPositionData
|
||||
{
|
||||
public string RuleId { get; set; }
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>File offset right behind the last line the rule processed.</summary>
|
||||
public long Position { get; set; }
|
||||
|
||||
/// <summary>Lines before <see cref="Position"/> — the base for <c>logLineNumber</c>.</summary>
|
||||
public long LineNumber { get; set; }
|
||||
|
||||
/// <summary>Bytes from the start of the file the fingerprint covers (0..4096).</summary>
|
||||
public int HeadLength { get; set; }
|
||||
|
||||
/// <summary>SHA-256 of those bytes, Base64; null when <see cref="HeadLength"/> is 0.</summary>
|
||||
public string HeadHash { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -547,9 +547,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 +564,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-
|
||||
@@ -736,7 +741,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
|
||||
unrestrictedMode: _agentConfig.UnrestrictedMode,
|
||||
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath,
|
||||
timelineEvents: timelineEvents));
|
||||
timelineEvents: timelineEvents,
|
||||
stateDirectory: _stateDirectory));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_agentConfig.GatherRuleDebugLogPath))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -23,7 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// V1 fired these from MonitoringService on every emitted EnrollmentEvent; the first V2
|
||||
// wiring subscribed to SignalIngress.SignalPosted instead, which fires at enqueue time
|
||||
// with the RAW collector payload — before the reducer has applied gates like the
|
||||
// RealmJoin completion gate. Consequence (session 32312a32, rsneuffen.de): a phase_change
|
||||
// RealmJoin completion gate. Consequence (session 32312a32): a phase_change
|
||||
// rule on FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP
|
||||
// exit / Hello wizard), 7 minutes before the engine declared phase_transition(FinalizingSetup)
|
||||
// on the timeline — and before the RealmJoin package wrote the registry key the rule was
|
||||
@@ -47,7 +47,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
string? imeLogPathOverride,
|
||||
bool unrestrictedMode = false,
|
||||
string? gatherDebugLogPath = null,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
string? stateDirectory = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -66,7 +67,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
|
||||
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath);
|
||||
debugLogPath: gatherDebugLogPath, stateDirectory: stateDirectory);
|
||||
_timelineEvents = timelineEvents;
|
||||
}
|
||||
|
||||
|
||||
+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 = "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" ? "ServicingWatcher" : "WindowsUpdateWatcher",
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Raw,
|
||||
identifier: $"os-update-activity:{source}",
|
||||
summary: string.IsNullOrEmpty(update) ? step : $"{step}: {update}"),
|
||||
payload: payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
SharedConstants.EventTypes.ImePatternHits,
|
||||
// Bookkeeping of runs that were deliberately not reported as activity.
|
||||
SharedConstants.EventTypes.ScriptRecurrenceSummary,
|
||||
// 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>
|
||||
|
||||
+18
-9
@@ -1227,11 +1227,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 +1254,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,6 +230,9 @@ namespace AutopilotMonitor.Agent.V2
|
||||
logger.Info($"--run-gather-rules: debug trace -> {gatherDebugLogPath}");
|
||||
if (consoleMode) Console.WriteLine($"Debug trace: {gatherDebugLogPath}");
|
||||
|
||||
// No stateDirectory on purpose: this mode runs beside the live agent under a
|
||||
// throwaway session id and must neither resume nor overwrite the session's
|
||||
// gather state (logparser positions, on_change values).
|
||||
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath,
|
||||
debugEcho: consoleMode ? (Action<string>)(line => Console.WriteLine($" {line}")) : null))
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -207,8 +207,8 @@ public class BuiltInRulesTests
|
||||
/// Sessions 81daa77f / 75d6ae8e (2026-08-25). IME writes this line and then returns before
|
||||
/// its "Completed user session" statement, so on a device with no user-targeted Win32 apps
|
||||
/// it is the ONLY evidence that user-phase enforcement finished. The exact wording is
|
||||
/// pinned here against the real IME line (verified against decompiled IME 1.104.102.0,
|
||||
/// ApplicationPoller and AppWorkloadAbstraction) because a silent drift would bring back
|
||||
/// pinned here against the real IME line (verified against IME 1.104.102.0) because a
|
||||
/// silent drift would bring back
|
||||
/// the 30-min esp_exit_without_completion_evidence false positive with no test failing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeMatchScorer.ProductKey"/> turns a finding's CPE ("base CPE:installed version")
|
||||
/// into the product identity the exposure summary groups by.
|
||||
/// </summary>
|
||||
public class CpeProductKeyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:7-zip:7-zip:24.08", "7-zip:7-zip")]
|
||||
[InlineData("cpe:2.3:a:7-zip:7-zip:", "7-zip:7-zip")] // finding without a version
|
||||
[InlineData("cpe:2.3:a:Microsoft:Teams:1.6.00.4472", "microsoft:teams")]
|
||||
[InlineData("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*:1.2.3", "contoso:widget")] // custom mapping typed in full
|
||||
[InlineData(@"cpe:2.3:a:microsoft:visual_c\+\+:14.0", @"microsoft:visual_c\+\+")] // CPE escapes stay part of the key
|
||||
[InlineData("cpe:2.3:a:*:*:1.0", "")]
|
||||
[InlineData("cpe:2.3:a", "")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, "")]
|
||||
public void ProductKey_IsLowerCaseVendorAndProduct(string? cpe, string expected)
|
||||
=> Assert.Equal(expected, CpeMatchScorer.ProductKey(cpe));
|
||||
}
|
||||
@@ -122,4 +122,22 @@ public class CveIndexEntityMergeTests
|
||||
|
||||
Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, findings));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Product_IsTheCpeVendorAndProduct_ofTheWinningOccurrence()
|
||||
{
|
||||
var zip = Finding("7-Zip 24.08 (x64 edition)", "high", Vuln("CVE-1", 7.8, "HIGH"), Vuln("CVE-2", 3.3, "LOW"));
|
||||
zip["cpeUri"] = "cpe:2.3:a:7-zip:7-zip:24.08";
|
||||
var teams = Finding("Microsoft Teams", "high", Vuln("CVE-1", 8.8, "HIGH"));
|
||||
teams["cpeUri"] = "cpe:2.3:a:microsoft:teams:1.6.00.4472";
|
||||
var noCpe = Finding("Contoso Tool", "high", Vuln("CVE-3", 7.0, "HIGH"));
|
||||
|
||||
var entities = TableStorageService.BuildCveIndexEntities(Tenant, Session, new List<Dictionary<string, object>> { zip, teams, noCpe });
|
||||
|
||||
var cve1 = entities.Single(x => x.GetString("CveId") == "CVE-1");
|
||||
Assert.Equal("Microsoft Teams", cve1.GetString("SoftwareName")); // name and product move together
|
||||
Assert.Equal("microsoft:teams", cve1.GetString("Product"));
|
||||
Assert.Equal("7-zip:7-zip", entities.Single(x => x.GetString("CveId") == "CVE-2").GetString("Product"));
|
||||
Assert.Equal("", entities.Single(x => x.GetString("CveId") == "CVE-3").GetString("Product"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A stored RuleResult keeps the rule's {{token}} template. The web and the MCP server resolve
|
||||
/// it when they render; a notification has no renderer, so the backend resolves it before the
|
||||
/// text leaves. Three things are pinned here: the backend resolution equals the shared cases
|
||||
/// every renderer runs, no placeholder reaches a notification for any built-in rule, and the
|
||||
/// cut happens after the resolution and on a text boundary.
|
||||
/// </summary>
|
||||
public class RuleTemplateInterpolatorTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "0f1e2d3c-4b5a-4978-8a6b-5c4d3e2f1a0b";
|
||||
private const string FailedAppName = "Contoso Productivity Suite";
|
||||
|
||||
// ── Shared cases (tests/fixtures/rule-template-interpolation) ─────────
|
||||
|
||||
private static readonly Lazy<JObject> Fixture = new(() =>
|
||||
{
|
||||
var path = Path.Combine(FindRepoRoot(), "tests", "fixtures", "rule-template-interpolation", "cases.json");
|
||||
// No date parsing: a timestamp-looking string must stay the string the renderers see.
|
||||
return JsonConvert.DeserializeObject<JObject>(
|
||||
File.ReadAllText(path), new JsonSerializerSettings { DateParseHandling = DateParseHandling.None })!;
|
||||
});
|
||||
|
||||
public static IEnumerable<object[]> CaseNames()
|
||||
=> Fixture.Value["cases"]!.Select(c => new object[] { c.Value<string>("name")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CaseNames))]
|
||||
public void Interpolate_matches_the_shared_case(string name)
|
||||
{
|
||||
var testCase = Fixture.Value["cases"]!.Single(c => c.Value<string>("name") == name);
|
||||
var template = testCase.Value<string?>("template");
|
||||
var expected = testCase.Value<string>("expected")!;
|
||||
var evidence = testCase["matchedConditions"] as JObject;
|
||||
|
||||
// Evidence as the rule engine builds it (nested dictionaries) ...
|
||||
Assert.Equal(expected, RuleTemplateInterpolator.Interpolate(template, AsDictionaries(evidence)));
|
||||
// ... and as a JSON read without normalization leaves it (JSON nodes as values).
|
||||
Assert.Equal(expected, RuleTemplateInterpolator.Interpolate(template, AsJsonNodes(evidence)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_cases_are_present_and_cover_every_auto_field()
|
||||
{
|
||||
Assert.True(Fixture.Value["cases"]!.Count() >= 20, "cases.json lost cases — the parity guard is only as wide as the file.");
|
||||
Assert.Equal(
|
||||
Fixture.Value["autoFields"]!.ToObject<string[]>()!,
|
||||
RuleEngine.EvidenceAutoFields);
|
||||
}
|
||||
|
||||
// ── Notification variant ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Notification_drops_a_list_item_whose_value_was_not_recorded()
|
||||
{
|
||||
var text = "Facts:\n\n- **Reason:** `{{reason}}`\n- **ESP HRESULT:** `{{errorCode}}`\n* **App:** {{appName}}\n1. step {{exitCode}}\n\nDone.";
|
||||
var evidence = new Dictionary<string, object>
|
||||
{
|
||||
["enrollment_failed_reason"] = new Dictionary<string, object> { ["field"] = "reason", ["value"] = "hello_timeout" },
|
||||
};
|
||||
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(text, evidence);
|
||||
|
||||
Assert.Equal("Facts:\n\n- **Reason:** `hello_timeout`\n\nDone.", rendered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_replaces_an_unrecorded_value_in_running_text()
|
||||
{
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(
|
||||
"App **{{appName}}** failed with HRESULT **{{errorCode}}**.",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["failed_app"] = new Dictionary<string, object> { ["field"] = "appName", ["value"] = FailedAppName },
|
||||
});
|
||||
|
||||
Assert.Equal($"App **{FailedAppName}** failed with HRESULT **–**.", rendered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_removes_the_sentence_about_literal_placeholders()
|
||||
{
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(
|
||||
"See ANALYZE-APP-013 for the root cause. Any field still shown as a literal `{{…}}` placeholder was simply not recorded. Check the timeline.",
|
||||
new Dictionary<string, object>());
|
||||
|
||||
Assert.Equal("See ANALYZE-APP-013 for the root cause. Check the timeline.", rendered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_without_evidence_or_text_is_safe()
|
||||
{
|
||||
Assert.Equal(string.Empty, RuleTemplateInterpolator.InterpolateForNotification(null, null));
|
||||
Assert.Equal(string.Empty, RuleTemplateInterpolator.InterpolateForNotification("", new Dictionary<string, object>()));
|
||||
Assert.Equal("Reason: –", RuleTemplateInterpolator.InterpolateForNotification("Reason: {{reason}}", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_collapses_the_gap_a_dropped_paragraph_leaves()
|
||||
{
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(
|
||||
"First.\n\nOnly a literal `{{…}}` remark.\n\nLast.", new Dictionary<string, object>());
|
||||
|
||||
Assert.Equal("First.\n\nLast.", rendered);
|
||||
}
|
||||
|
||||
// ── No placeholder leaves the backend ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void No_builtin_rule_sends_a_placeholder_whatever_the_evidence()
|
||||
{
|
||||
// Worst case for every rule: nothing recorded at all. Both alert builders are the only
|
||||
// paths that put a rule explanation into a notification.
|
||||
var offenders = new List<string>();
|
||||
var templated = 0;
|
||||
|
||||
foreach (var rule in BuiltInAnalyzeRules.GetAll())
|
||||
{
|
||||
if (rule.Explanation.Contains("{{")) templated++;
|
||||
|
||||
var result = new RuleResult
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
RuleTitle = rule.Title,
|
||||
Severity = "critical",
|
||||
Category = rule.Category,
|
||||
Explanation = rule.Explanation,
|
||||
};
|
||||
|
||||
var fired = NotificationAlertBuilder.BuildRuleFiredAlert(result, "DESKTOP-TEST01", "SN-0001");
|
||||
var enrollment = NotificationAlertBuilder.BuildEnrollmentAlert(
|
||||
"DESKTOP-TEST01", "SN-0001", null, null, success: false, failureReason: "x", duration: null);
|
||||
NotificationAlertBuilder.AddRuleResultSections(enrollment, new List<RuleResult> { result });
|
||||
|
||||
foreach (var section in fired.Sections.Concat(enrollment.Sections))
|
||||
{
|
||||
if (section.Text.Contains("{{") || section.Text.Contains("}}"))
|
||||
offenders.Add($"{rule.RuleId}: {section.Text}");
|
||||
Assert.True(section.Text.Length <= NotificationAlertBuilder.RuleFiredExplanationMaxLength);
|
||||
}
|
||||
}
|
||||
|
||||
// Positive control: the catalog really contains templated rules, so an empty offender
|
||||
// list is a statement about them and not about a catalog without placeholders.
|
||||
Assert.True(templated >= 10, $"Only {templated} built-in rules carry a placeholder — did the catalog load?");
|
||||
Assert.True(offenders.Count == 0, "Placeholder reached a notification:\n" + string.Join("\n", offenders));
|
||||
}
|
||||
|
||||
// ── End to end: engine evidence → alert ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Rule_fired_alert_shows_the_values_the_engine_recorded()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
|
||||
|
||||
var alert = NotificationAlertBuilder.BuildRuleFiredAlert(result, "DESKTOP-TEST01", "SN-0001");
|
||||
|
||||
var text = Assert.Single(alert.Sections).Text;
|
||||
Assert.Equal(
|
||||
"The Autopilot enrollment has explicitly failed.\n\n" +
|
||||
"**Key facts extracted from this session's events:**\n\n" +
|
||||
"- **Failure reason:** `esp_terminal_failure`\n" +
|
||||
$"- **First failed app:** `{FailedAppName}`\n" +
|
||||
"- **ESP failed subcategory:** `Apps`\n" +
|
||||
"- **ESP HRESULT:** `0x87d1041c` …",
|
||||
text);
|
||||
// The stored result is untouched: the web and the MCP server still resolve the template.
|
||||
Assert.Contains("{{reason}}", result.Explanation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rule_fired_alert_reads_evidence_that_went_through_the_table()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
|
||||
var fresh = NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections.Single().Text;
|
||||
|
||||
// Same write and read as TableStorageService: JSON column, then nested dictionaries.
|
||||
var json = JsonConvert.SerializeObject(result.MatchedConditions);
|
||||
result.MatchedConditions = AsDictionaries(JObject.Parse(json))!;
|
||||
|
||||
Assert.Equal(fresh, NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections.Single().Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rule_fired_alert_omits_the_facts_a_non_esp_failure_never_had()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: false);
|
||||
|
||||
var text = Assert.Single(NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections).Text;
|
||||
|
||||
Assert.Contains("- **Failure reason:** `hello_timeout`", text);
|
||||
Assert.DoesNotContain("First failed app", text);
|
||||
Assert.DoesNotContain("ESP failed subcategory", text);
|
||||
Assert.DoesNotContain("ESP HRESULT:", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enrollment_alert_rule_section_is_resolved_and_cut_on_a_line()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentAlert(
|
||||
"DESKTOP-TEST01", "SN-0001", null, null, success: false, failureReason: "x", duration: null);
|
||||
|
||||
NotificationAlertBuilder.AddRuleResultSections(alert, new List<RuleResult> { result });
|
||||
|
||||
var text = Assert.Single(alert.Sections).Text;
|
||||
Assert.True(text.Length <= NotificationAlertBuilder.RuleSectionExplanationMaxLength);
|
||||
Assert.EndsWith("- **Failure reason:** `esp_terminal_failure` …", text);
|
||||
}
|
||||
|
||||
// ── Cut ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Truncate_keeps_short_text()
|
||||
=> Assert.Equal("short", NotificationAlertBuilder.TruncateAtBoundary("short", 300));
|
||||
|
||||
[Fact]
|
||||
public void Truncate_prefers_a_line_break_then_a_sentence_then_a_word()
|
||||
{
|
||||
var lines = "First paragraph of the text.\n" + new string('a', 40);
|
||||
Assert.Equal("First paragraph of the text. …", NotificationAlertBuilder.TruncateAtBoundary(lines, 50));
|
||||
|
||||
var sentences = "One sentence here. Another sentence that runs on and on without an end";
|
||||
Assert.Equal("One sentence here. …", NotificationAlertBuilder.TruncateAtBoundary(sentences, 30));
|
||||
|
||||
var words = "word word word word word word word word word word";
|
||||
Assert.Equal("word word word word word …", NotificationAlertBuilder.TruncateAtBoundary(words, 30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncate_never_exceeds_the_limit_and_survives_text_without_any_boundary()
|
||||
{
|
||||
var unbroken = new string('x', 500);
|
||||
var cut = NotificationAlertBuilder.TruncateAtBoundary(unbroken, 100);
|
||||
|
||||
Assert.Equal(100, cut.Length);
|
||||
Assert.EndsWith(" …", cut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncate_ignores_a_boundary_that_would_waste_most_of_the_budget()
|
||||
{
|
||||
var text = "Hi.\n" + string.Join(" ", Enumerable.Repeat("word", 60));
|
||||
var cut = NotificationAlertBuilder.TruncateAtBoundary(text, 100);
|
||||
|
||||
Assert.True(cut.Length > 50, cut);
|
||||
Assert.True(cut.Length <= 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncate_does_not_leave_a_bold_or_code_span_open()
|
||||
{
|
||||
var bold = "See the **App Detection Failure During ESP** rule for the cause and the **remediation steps in detail** below";
|
||||
Assert.Equal("See the **App Detection Failure During ESP** rule for the cause and the remediation steps …",
|
||||
NotificationAlertBuilder.TruncateAtBoundary(bold, 94));
|
||||
|
||||
var code = "The installer returned `1603 fatal error during installation` and stopped the whole chain";
|
||||
Assert.Equal("The installer returned 1603 fatal error during …",
|
||||
NotificationAlertBuilder.TruncateAtBoundary(code, 50));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<RuleResult> RunEnrollmentFailedRuleAsync(bool withEspFailure)
|
||||
{
|
||||
var rule = BuiltInAnalyzeRules.GetAll().Single(r => r.RuleId == "ANALYZE-ENRL-001");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Event("enrollment_failed", 163, new Dictionary<string, object>
|
||||
{
|
||||
["reason"] = withEspFailure ? "esp_terminal_failure" : "hello_timeout",
|
||||
}),
|
||||
};
|
||||
if (withEspFailure)
|
||||
{
|
||||
events.Add(Event("esp_failure_settle_started", 161, new Dictionary<string, object>
|
||||
{
|
||||
["failedSubcategory"] = "Apps",
|
||||
["errorCode"] = "0x87d1041c",
|
||||
}));
|
||||
events.Add(Event("app_install_failed", 158, new Dictionary<string, object>
|
||||
{
|
||||
["appId"] = "00000000-0000-4000-8000-000000000001",
|
||||
["appName"] = FailedAppName,
|
||||
["errorCode"] = "0x87d1041c",
|
||||
}));
|
||||
}
|
||||
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleTemplateInterpolatorTests>.Instance);
|
||||
|
||||
var outcome = await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
return Assert.Single(outcome.Results);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent Event(string eventType, long sequence, Dictionary<string, object> data) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = eventType,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = sequence,
|
||||
Data = data,
|
||||
};
|
||||
|
||||
private static Dictionary<string, object>? AsJsonNodes(JObject? evidence)
|
||||
=> evidence?.Properties().ToDictionary(p => p.Name, p => (object)p.Value);
|
||||
|
||||
private static Dictionary<string, object>? AsDictionaries(JObject? evidence)
|
||||
=> evidence?.Properties().ToDictionary(p => p.Name, p => Plain(p.Value)!);
|
||||
|
||||
private static object? Plain(JToken token) => token switch
|
||||
{
|
||||
JObject o => o.Properties().ToDictionary(p => p.Name, p => Plain(p.Value)!),
|
||||
JArray a => a.Select(Plain).ToList(),
|
||||
JValue v => v.Value,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
+241
-1
@@ -14,13 +14,14 @@ public class VulnerabilitySummaryAggregatorTests
|
||||
private static CveExposureEntry Row(
|
||||
string cve, string session, string severity, double score,
|
||||
bool kev = false, string tenant = "t1", string software = "Acme App",
|
||||
DateTime? detectedAt = null)
|
||||
DateTime? detectedAt = null, string product = "")
|
||||
=> new()
|
||||
{
|
||||
TenantId = tenant,
|
||||
CveId = cve,
|
||||
SessionId = session,
|
||||
SoftwareName = software,
|
||||
Product = product,
|
||||
CvssScore = score,
|
||||
CvssSeverity = severity,
|
||||
IsKev = kev,
|
||||
@@ -168,6 +169,245 @@ public class VulnerabilitySummaryAggregatorTests
|
||||
Assert.True(r.Truncated);
|
||||
Assert.Equal(365, r.WindowDays); // clamped from 9999
|
||||
Assert.Empty(r.TopCves);
|
||||
Assert.Empty(r.TopSoftware);
|
||||
Assert.Equal(0, r.TotalAffectedSessions);
|
||||
Assert.Equal(0, r.DistinctSoftware);
|
||||
Assert.Equal(0, r.KevAffectedSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopCves_AppendKevCvesBelowTheCut_inDeviceOrder()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0), Row("CVE-WIDE", "s3", "High", 7.0),
|
||||
Row("CVE-MID", "s1", "High", 8.0), Row("CVE-MID", "s2", "High", 8.0),
|
||||
Row("CVE-KEV", "s4", "High", 7.8, kev: true),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(new[] { "CVE-WIDE", "CVE-KEV" }, r.TopCves.Select(c => c.CveId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KevAdditions_AreCapped()
|
||||
{
|
||||
var rows = new List<CveExposureEntry> { Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0) };
|
||||
rows.AddRange(Enumerable.Range(0, VulnerabilitySummaryAggregator.MaxKevAdditions + 10)
|
||||
.Select(i => Row($"CVE-KEV-{i:D3}", $"k{i}", "High", 7.5, kev: true)));
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(1 + VulnerabilitySummaryAggregator.MaxKevAdditions, r.TopCves.Count);
|
||||
Assert.Equal("CVE-WIDE", r.TopCves[0].CveId);
|
||||
Assert.All(r.TopCves.Skip(1), c => Assert.True(c.IsKev));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KevAffectedSessions_CountsDistinctSessionsWithAKevCve()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-K1", "s1", "High", 8.8, kev: true),
|
||||
Row("CVE-K2", "s1", "High", 8.1, kev: true), // same session, counted once
|
||||
Row("CVE-K2", "s2", "High", 8.1, kev: true),
|
||||
Row("CVE-N", "s3", "High", 7.0),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
Assert.Equal(2, r.KevAffectedSessions);
|
||||
Assert.Equal(3, r.TotalAffectedSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_FoldsAProductAcrossTitlesAndCves()
|
||||
{
|
||||
const string zip = "7-zip:7-zip";
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-A", "s1", "HIGH", 7.8, software: "7-Zip 24.08 (x64 edition)", product: zip),
|
||||
Row("CVE-B", "s1", "LOW", 3.3, software: "7-Zip 24.08 (x64 edition)", product: zip),
|
||||
Row("CVE-A", "s2", "HIGH", 7.8, software: "7-Zip 25.01 (x64)", product: zip),
|
||||
Row("CVE-A", "s3", "HIGH", 7.8, software: "7-Zip 24.08 (x64 edition)", product: zip),
|
||||
Row("CVE-T", "s4", "MEDIUM", 5.0, software: "Microsoft Teams", product: "microsoft:teams"),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
Assert.Equal(2, r.DistinctSoftware);
|
||||
var p = r.TopSoftware[0];
|
||||
Assert.Equal(zip, p.Product);
|
||||
Assert.Equal("7-Zip", p.Name);
|
||||
Assert.Equal(3, p.AffectedSessions);
|
||||
Assert.Equal(2, p.CveCount);
|
||||
Assert.Equal(0, p.KevCount);
|
||||
Assert.Equal("HIGH", p.CvssSeverity);
|
||||
Assert.Equal(7.8, p.CvssScore);
|
||||
Assert.Equal(new[] { "7-Zip 24.08 (x64 edition)", "7-Zip 25.01 (x64)" }, p.Titles); // most sessions first
|
||||
var a = p.Cves.Single(c => c.CveId == "CVE-A");
|
||||
Assert.Equal(3, a.AffectedSessions);
|
||||
Assert.Equal(1, p.Cves.Single(c => c.CveId == "CVE-B").AffectedSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_RowWithoutProductBorrowsItFromTheSameTitle_elseUsesTheTitle()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-A", "s1", "High", 7.8, software: "7-Zip 24.08 (x64 edition)", product: "7-zip:7-zip"),
|
||||
Row("CVE-A", "s2", "High", 7.8, software: "7-zip 24.08 (X64 Edition)"), // legacy row, same title
|
||||
Row("CVE-C", "s3", "High", 7.1, software: "Contoso Tool 1.0"), // legacy row, title never seen with a product
|
||||
Row("CVE-D", "s4", "High", 7.1, software: ""), // neither: left out of the product view
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
Assert.Equal(2, r.DistinctSoftware);
|
||||
var zip = r.TopSoftware.Single(s => s.Product == "7-zip:7-zip");
|
||||
Assert.Equal(2, zip.AffectedSessions);
|
||||
var contoso = r.TopSoftware.Single(s => s.Product == "Contoso Tool 1.0");
|
||||
Assert.Equal("Contoso Tool", contoso.Name);
|
||||
Assert.Equal(3, r.DistinctCves); // the row without a title still counts as a CVE
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_CarriesTheMostUrgentCvesFirst_cappedWithTheFullCount()
|
||||
{
|
||||
const string chrome = "google:chrome";
|
||||
var act = Row("CVE-ACT", "s1", "High", 8.1, kev: true, software: "Google Chrome", product: chrome); act.Priority = "act";
|
||||
var attendEpss = Row("CVE-ATT-E", "s1", "Medium", 6.5, software: "Google Chrome", product: chrome); attendEpss.Priority = "attend"; attendEpss.EpssScore = 0.4;
|
||||
var attendCvss = Row("CVE-ATT-C", "s1", "Critical", 9.6, software: "Google Chrome", product: chrome); attendCvss.Priority = "attend"; attendCvss.EpssScore = 0.01;
|
||||
var rows = new List<CveExposureEntry> { act, attendEpss, attendCvss };
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var track = Row($"CVE-TRK-{i}", "s1", "High", 7.0 + i / 10.0, software: "Google Chrome", product: chrome);
|
||||
track.Priority = "track";
|
||||
rows.Add(track);
|
||||
}
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
var p = Assert.Single(r.TopSoftware);
|
||||
Assert.Equal(8, p.CveCount);
|
||||
Assert.Equal(1, p.KevCount);
|
||||
Assert.Equal("act", p.Priority);
|
||||
Assert.Equal("Critical", p.CvssSeverity); // worst band, with the score of that CVE
|
||||
Assert.Equal(9.6, p.CvssScore);
|
||||
Assert.Equal(VulnerabilitySummaryAggregator.MaxCvesPerSoftware, p.Cves.Count);
|
||||
Assert.Equal(new[] { "CVE-ACT", "CVE-ATT-E", "CVE-ATT-C" }, p.Cves.Select(c => c.CveId)); // act, then EPSS before CVSS
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_IsCappedIndependentlyOfTopN_plusCappedKevProducts()
|
||||
{
|
||||
var rows = new List<CveExposureEntry>();
|
||||
for (var p = 0; p < VulnerabilitySummaryAggregator.MaxSoftware + 5; p++)
|
||||
for (var s = 0; s <= p; s++) // product p has p + 1 sessions
|
||||
rows.Add(Row($"CVE-P{p}", $"p{p}-s{s}", "High", 7.0, software: $"Product {p}", product: $"acme:p{p}"));
|
||||
for (var k = 0; k < VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions + 2; k++)
|
||||
rows.Add(Row($"CVE-K{k}", $"k{k}", "High", 7.8, kev: true, software: $"Kev Product {k}", product: $"acme:k{k}"));
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 100, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(
|
||||
VulnerabilitySummaryAggregator.MaxSoftware + VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions,
|
||||
r.TopSoftware.Count);
|
||||
Assert.Equal("acme:p24", r.TopSoftware[0].Product); // most sessions first
|
||||
Assert.All(r.TopSoftware.Skip(VulnerabilitySummaryAggregator.MaxSoftware), s => Assert.True(s.KevCount > 0));
|
||||
Assert.Equal(VulnerabilitySummaryAggregator.MaxSoftware + 5 + VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions + 2, r.DistinctSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_AppendsProductsWithAKevCveBelowTheCut()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-W", "s1", "High", 7.0, software: "Wide App", product: "acme:wide"),
|
||||
Row("CVE-W", "s2", "High", 7.0, software: "Wide App", product: "acme:wide"),
|
||||
Row("CVE-M", "s3", "High", 9.0, software: "Mid App", product: "acme:mid"),
|
||||
Row("CVE-K", "s4", "High", 7.8, kev: true, software: "WinRAR 6.22 (64-bit)", product: "rarlab:winrar"),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(new[] { "acme:wide", "rarlab:winrar" }, r.TopSoftware.Select(s => s.Product));
|
||||
Assert.Equal("WinRAR", r.TopSoftware[1].Name);
|
||||
Assert.Equal(3, r.DistinctSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BandCves_AddEachBandsTopEntriesOutsideTheList_onlyWhenAsked()
|
||||
{
|
||||
var rows = new List<CveExposureEntry>
|
||||
{
|
||||
Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0), Row("CVE-WIDE", "s3", "High", 7.0),
|
||||
Row("CVE-HIGH-2", "s1", "High", 7.5), Row("CVE-HIGH-2", "s2", "High", 7.5),
|
||||
Row("CVE-ATT-1", "s1", "Critical", 9.8), Row("CVE-ATT-1", "s4", "Critical", 9.8),
|
||||
Row("CVE-ATT-2", "s5", "Critical", 9.1),
|
||||
Row("CVE-ATT-3", "s6", "Medium", 5.0),
|
||||
Row("CVE-LOW", "s7", "Low", 2.0),
|
||||
};
|
||||
foreach (var r in rows)
|
||||
r.Priority = r.CveId.StartsWith("CVE-ATT", StringComparison.Ordinal) ? "attend" : "track";
|
||||
|
||||
var plain = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
Assert.Null(plain.BandCves);
|
||||
Assert.Null(plain.BandSoftware);
|
||||
|
||||
var r2 = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 2);
|
||||
|
||||
Assert.Equal(new[] { "CVE-WIDE" }, r2.TopCves.Select(c => c.CveId));
|
||||
// critical: ATT-1, ATT-2 · high: WIDE (listed), HIGH-2 · medium: ATT-3 · low: LOW · attend: ATT-1, ATT-2 · track: WIDE, HIGH-2
|
||||
Assert.Equal(new[] { "CVE-ATT-1", "CVE-HIGH-2", "CVE-ATT-2", "CVE-ATT-3", "CVE-LOW" }, r2.BandCves!.Select(c => c.CveId));
|
||||
Assert.All(r2.BandCves!, c => Assert.NotEmpty(c.SampleSoftware));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BandSoftware_BandsProductsByTheirWorstValues_outsideTopSoftware()
|
||||
{
|
||||
var wide1 = Row("CVE-W", "s1", "High", 7.0, software: "Wide App", product: "acme:wide"); wide1.Priority = "track";
|
||||
var wide2 = Row("CVE-W", "s2", "High", 7.0, software: "Wide App", product: "acme:wide"); wide2.Priority = "track";
|
||||
var crit = Row("CVE-C", "s3", "Critical", 9.6, software: "Edge Tool", product: "acme:edge"); crit.Priority = "attend";
|
||||
var low = Row("CVE-L", "s4", "Low", 2.1, software: "Small Tool", product: "acme:small"); low.Priority = "track";
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(
|
||||
new[] { wide1, wide2, crit, low }, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 1);
|
||||
|
||||
Assert.Equal(new[] { "acme:wide" }, r.TopSoftware.Select(s => s.Product));
|
||||
// critical + attend → edge; low → small; high + track are already held by wide.
|
||||
Assert.Equal(new[] { "acme:edge", "acme:small" }, r.BandSoftware!.Select(s => s.Product));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerBand_IsClamped()
|
||||
{
|
||||
var rows = Enumerable.Range(0, VulnerabilitySummaryAggregator.MaxPerBand + 10)
|
||||
.Select(i => Row($"CVE-{i:D3}", $"s{i}", "Medium", 5.0))
|
||||
.ToList();
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 999);
|
||||
|
||||
Assert.Equal(VulnerabilitySummaryAggregator.MaxPerBand - 1, r.BandCves!.Count); // the band's first entry is already listed
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("7-Zip", "7-Zip 24.08 (x64 edition)", "7-Zip 25.01 (x64)", "7-Zip 26.00 (x64 edition)")]
|
||||
[InlineData("Cisco Secure Client", "Cisco Secure Client - AnyConnect VPN ", "Cisco Secure Client - Cloud Management")]
|
||||
[InlineData("Microsoft Teams", "Microsoft Teams", "MSTeams", "Teams Machine-Wide Installer")]
|
||||
[InlineData("Microsoft Teams", "Microsoft Teams", "Microsoft 365 Apps")] // one shared word is not a name
|
||||
[InlineData("WinRAR", "WinRAR 6.22 (64-bit)")]
|
||||
[InlineData("Mozilla Firefox", "Mozilla Firefox (x64 en-US)")]
|
||||
[InlineData("Google Chrome", "Google Chrome 127.0.6533.74")]
|
||||
[InlineData("Microsoft Office 2016", "Microsoft Office 2016")] // a bare year or major version stays
|
||||
[InlineData("PowerShell 7-x64", "PowerShell 7-x64")]
|
||||
[InlineData("Contoso Agent", "Contoso Agent v2.4.1 x64")]
|
||||
[InlineData("1.2.3", "1.2.3")] // never stripped to nothing
|
||||
public void DisplayName_UsesSharedWordsOrTheMostCommonTitle_withoutVersionAndArchitecture(string expected, params string[] titles)
|
||||
=> Assert.Equal(expected, VulnerabilitySummaryAggregator.DisplayName(titles));
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_OfNoTitlesIsEmpty()
|
||||
=> Assert.Equal("", VulnerabilitySummaryAggregator.DisplayName(Array.Empty<string>()));
|
||||
}
|
||||
|
||||
@@ -655,12 +655,12 @@ public class VulnerabilityWireParityTests
|
||||
}
|
||||
|
||||
// ---- GetVulnerabilitySummary / GetGlobalVulnerabilitySummary -------------------------
|
||||
// The endpoint always serialized the named CveExposureSummary record (never anonymous);
|
||||
// it only gained the IApiResponse marker. The anonymous side here replicates the record's
|
||||
// established wire shape to pin key names, order and the TotalAffectedTenants omission.
|
||||
// The endpoint always serialized the named CveExposureSummary record (never anonymous).
|
||||
// The anonymous side pins the wire shape: key names, order and the TotalAffectedTenants /
|
||||
// EpssScore omission. D-307 added distinctSoftware, kevAffectedSessions and topSoftware.
|
||||
|
||||
[Fact]
|
||||
public void CveExposureSummary_wire_shape_is_unchanged_by_the_IApiResponse_marker()
|
||||
public void CveExposureSummary_pins_the_cross_tenant_wire_shape()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 1, High = 2, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 1, Attend = 1, Track = 1 };
|
||||
@@ -686,10 +686,32 @@ public class VulnerabilityWireParityTests
|
||||
totalAffectedSessions = 11,
|
||||
totalAffectedTenants = (int?)3,
|
||||
distinctCves = 3,
|
||||
distinctSoftware = 2,
|
||||
kevCves = 1,
|
||||
kevAffectedSessions = 7,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
topSoftware = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
product = "7-zip:7-zip",
|
||||
name = "7-Zip",
|
||||
affectedSessions = 7,
|
||||
cveCount = 2,
|
||||
kevCount = 1,
|
||||
cvssSeverity = "critical",
|
||||
cvssScore = 9.8,
|
||||
priority = "act",
|
||||
titles = new[] { "7-Zip 24.08 (x64)", "7-Zip 25.01 (x64)" },
|
||||
cves = new object[]
|
||||
{
|
||||
new { cveId = "CVE-2026-12345", cvssSeverity = "critical", cvssScore = 9.8, isKev = true, epssScore = (double?)0.94, priority = "act", affectedSessions = 7 },
|
||||
new { cveId = "CVE-2026-23456", cvssSeverity = "low", cvssScore = 3.3, isKev = false, epssScore = (double?)null, priority = "track", affectedSessions = 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
truncated = false,
|
||||
},
|
||||
new CveExposureSummary
|
||||
@@ -698,10 +720,88 @@ public class VulnerabilityWireParityTests
|
||||
TotalAffectedSessions = 11,
|
||||
TotalAffectedTenants = 3,
|
||||
DistinctCves = 3,
|
||||
DistinctSoftware = 2,
|
||||
KevCves = 1,
|
||||
KevAffectedSessions = 7,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopSoftware = new List<TopSoftware>
|
||||
{
|
||||
new TopSoftware
|
||||
{
|
||||
Product = "7-zip:7-zip",
|
||||
Name = "7-Zip",
|
||||
AffectedSessions = 7,
|
||||
CveCount = 2,
|
||||
KevCount = 1,
|
||||
CvssSeverity = "critical",
|
||||
CvssScore = 9.8,
|
||||
Priority = "act",
|
||||
Titles = new List<string> { "7-Zip 24.08 (x64)", "7-Zip 25.01 (x64)" },
|
||||
Cves = new List<SoftwareCve>
|
||||
{
|
||||
new SoftwareCve { CveId = "CVE-2026-12345", CvssSeverity = "critical", CvssScore = 9.8, IsKev = true, EpssScore = 0.94, Priority = "act", AffectedSessions = 7 },
|
||||
new SoftwareCve { CveId = "CVE-2026-23456", CvssSeverity = "low", CvssScore = 3.3, IsKev = false, EpssScore = null, Priority = "track", AffectedSessions = 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
Truncated = false,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CveExposureSummary_carries_the_band_lists_after_topSoftware_when_requested()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 1, High = 1, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 0, Attend = 1, Track = 1 };
|
||||
var wide = new TopCve
|
||||
{
|
||||
CveId = "CVE-2026-11111", CvssSeverity = "HIGH", CvssScore = 7.5, IsKev = false, EpssScore = 0.01,
|
||||
Priority = "track", AffectedSessions = 40, SampleSoftware = new List<string> { "Contoso Agent 2.1" },
|
||||
};
|
||||
var attend = new TopCve
|
||||
{
|
||||
CveId = "CVE-2026-22222", CvssSeverity = "CRITICAL", CvssScore = 9.6, IsKev = false, EpssScore = null,
|
||||
Priority = "attend", AffectedSessions = 2, SampleSoftware = new List<string> { "Contoso Viewer 1.0" },
|
||||
};
|
||||
var topCves = new List<TopCve> { wide };
|
||||
var topSoftware = new List<TopSoftware>();
|
||||
var bandCves = new List<TopCve> { attend };
|
||||
var bandSoftware = new List<TopSoftware>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
windowDays = 30,
|
||||
totalAffectedSessions = 42,
|
||||
distinctCves = 2,
|
||||
distinctSoftware = 2,
|
||||
kevCves = 0,
|
||||
kevAffectedSessions = 0,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
topSoftware,
|
||||
bandCves,
|
||||
bandSoftware,
|
||||
truncated = false,
|
||||
},
|
||||
new CveExposureSummary
|
||||
{
|
||||
WindowDays = 30,
|
||||
TotalAffectedSessions = 42,
|
||||
TotalAffectedTenants = null,
|
||||
DistinctCves = 2,
|
||||
DistinctSoftware = 2,
|
||||
KevCves = 0,
|
||||
KevAffectedSessions = 0,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopSoftware = topSoftware,
|
||||
BandCves = bandCves,
|
||||
BandSoftware = bandSoftware,
|
||||
Truncated = false,
|
||||
});
|
||||
}
|
||||
@@ -712,6 +812,7 @@ public class VulnerabilityWireParityTests
|
||||
var breakdown = new SeverityBreakdown { Critical = 0, High = 0, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 0, Attend = 0, Track = 0 };
|
||||
var topCves = new List<TopCve>();
|
||||
var topSoftware = new List<TopSoftware>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
@@ -720,10 +821,13 @@ public class VulnerabilityWireParityTests
|
||||
totalAffectedSessions = 0,
|
||||
totalAffectedTenants = (int?)null,
|
||||
distinctCves = 0,
|
||||
distinctSoftware = 0,
|
||||
kevCves = 0,
|
||||
kevAffectedSessions = 0,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
topSoftware,
|
||||
truncated = true,
|
||||
},
|
||||
new CveExposureSummary
|
||||
@@ -732,10 +836,13 @@ public class VulnerabilityWireParityTests
|
||||
TotalAffectedSessions = 0,
|
||||
TotalAffectedTenants = null,
|
||||
DistinctCves = 0,
|
||||
DistinctSoftware = 0,
|
||||
KevCves = 0,
|
||||
KevAffectedSessions = 0,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopSoftware = topSoftware,
|
||||
Truncated = true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
+353
-56
@@ -7,7 +7,7 @@ using AutopilotMonitor.Shared.Models.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
{
|
||||
// The wire types (CveExposureSummary, SeverityBreakdown, TopCve) live in
|
||||
// The wire types (CveExposureSummary, SeverityBreakdown, TopCve, TopSoftware) live in
|
||||
// AutopilotMonitor.Shared.Models/VulnerabilityApiModels.cs so the manifest export sees them.
|
||||
|
||||
/// <summary>
|
||||
@@ -17,6 +17,38 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
/// </summary>
|
||||
public static class VulnerabilitySummaryAggregator
|
||||
{
|
||||
// The caps keep the response under the MCP tool's 50k-character result cap at topN = 100
|
||||
// (a TopCve is ~210 characters, a TopSoftware entry ~800); the MCP passes it through verbatim.
|
||||
|
||||
/// <summary>
|
||||
/// Cap on the KEV CVEs appended below the topN cut. Known-exploited CVEs are "patch first"
|
||||
/// whatever their device count, so a list ranked by devices must not drop them.
|
||||
/// </summary>
|
||||
public const int MaxKevAdditions = 25;
|
||||
|
||||
/// <summary>Products ranked by devices, before the KEV additions (the list is min(topN, this) long).</summary>
|
||||
public const int MaxSoftware = 20;
|
||||
|
||||
/// <summary>Cap on the products with a KEV CVE appended below the product cut.</summary>
|
||||
public const int MaxKevSoftwareAdditions = 10;
|
||||
|
||||
/// <summary>CVEs carried per product entry; <see cref="TopSoftware.CveCount"/> keeps the full count.</summary>
|
||||
public const int MaxCvesPerSoftware = 3;
|
||||
|
||||
/// <summary>Raw titles carried per product entry.</summary>
|
||||
public const int MaxTitlesPerSoftware = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound of the per-band representatives (<see cref="CveExposureSummary.BandCves"/>).
|
||||
/// Only the portal asks for them; the MCP tool never does, so its response stays within its cap.
|
||||
/// </summary>
|
||||
public const int MaxPerBand = 25;
|
||||
|
||||
private static readonly HashSet<string> ArchitectureTokens = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"x64", "x86", "amd64", "arm64", "64-bit", "32-bit",
|
||||
};
|
||||
|
||||
private static int SeverityRank(string? severity) => severity?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => 4,
|
||||
@@ -26,15 +58,39 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static string SeverityKey(int rank) => rank switch
|
||||
{
|
||||
4 => "critical",
|
||||
3 => "high",
|
||||
2 => "medium",
|
||||
1 => "low",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static string PriorityKey(string? priority) => CvePriority.Rank(priority) switch
|
||||
{
|
||||
3 => CvePriority.Act,
|
||||
2 => CvePriority.Attend,
|
||||
1 => CvePriority.Track,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <param name="perBand">
|
||||
/// When above 0, the summary also carries <see cref="CveExposureSummary.BandCves"/> and
|
||||
/// <see cref="CveExposureSummary.BandSoftware"/>: per severity and priority band its top
|
||||
/// entries outside the lists, so a band filter never comes up empty while the band has entries.
|
||||
/// </param>
|
||||
public static CveExposureSummary Aggregate(
|
||||
IReadOnlyList<CveExposureEntry> rows,
|
||||
bool truncated,
|
||||
int topN,
|
||||
int days,
|
||||
bool crossTenant,
|
||||
DateTime nowUtc)
|
||||
DateTime nowUtc,
|
||||
int perBand = 0)
|
||||
{
|
||||
if (topN < 1) topN = 1;
|
||||
perBand = Math.Clamp(perBand, 0, MaxPerBand);
|
||||
var windowDays = Math.Clamp(days, 1, 365);
|
||||
var cutoff = nowUtc.AddDays(-windowDays);
|
||||
|
||||
@@ -44,73 +100,62 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
var inWindow = rows
|
||||
.Where(r => !r.DetectedAt.HasValue || r.DetectedAt.Value >= cutoff)
|
||||
.ToList();
|
||||
var withCve = inWindow.Where(r => !string.IsNullOrEmpty(r.CveId)).ToList();
|
||||
|
||||
var groups = inWindow
|
||||
var cves = withCve
|
||||
.GroupBy(r => r.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => !string.IsNullOrEmpty(g.Key))
|
||||
.Select(g =>
|
||||
{
|
||||
var topSeverityRow = g.OrderByDescending(r => SeverityRank(r.CvssSeverity)).First();
|
||||
return new
|
||||
{
|
||||
CveId = g.Key,
|
||||
Severity = topSeverityRow.CvssSeverity,
|
||||
SeverityRank = SeverityRank(topSeverityRow.CvssSeverity),
|
||||
CvssScore = g.Max(r => r.CvssScore),
|
||||
IsKev = g.Any(r => r.IsKev),
|
||||
// Max over a nullable ignores nulls and yields null when nothing is scored.
|
||||
EpssScore = g.Max(r => r.EpssScore),
|
||||
Priority = g.Select(r => r.Priority)
|
||||
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
|
||||
AffectedSessions = g.Select(r => r.SessionId)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
SampleSoftware = g.Select(r => r.SoftwareName)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(3).ToList(),
|
||||
};
|
||||
})
|
||||
.Select(g => FoldCve(g.Key, g.ToList()))
|
||||
.ToList();
|
||||
|
||||
var topCves = groups
|
||||
var rankedCves = cves
|
||||
.OrderByDescending(c => c.AffectedSessions)
|
||||
.ThenByDescending(c => c.CvssScore)
|
||||
.ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(topN)
|
||||
.Select(c => new TopCve
|
||||
{
|
||||
CveId = c.CveId,
|
||||
CvssSeverity = c.Severity,
|
||||
CvssScore = c.CvssScore,
|
||||
IsKev = c.IsKev,
|
||||
EpssScore = c.EpssScore,
|
||||
Priority = c.Priority,
|
||||
AffectedSessions = c.AffectedSessions,
|
||||
SampleSoftware = c.SampleSoftware,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var listedCves = WithKevAdditions(rankedCves, topN, c => c.IsKev, MaxKevAdditions);
|
||||
|
||||
var software = FoldSoftware(withCve);
|
||||
var rankedSoftware = software
|
||||
.OrderByDescending(s => s.AffectedSessions)
|
||||
.ThenByDescending(s => CvePriority.Rank(s.Priority))
|
||||
.ThenByDescending(s => s.CvssScore)
|
||||
.ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(s => s.Product, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var topSoftware = WithKevAdditions(
|
||||
rankedSoftware, Math.Min(topN, MaxSoftware), s => s.KevCount > 0, MaxKevSoftwareAdditions);
|
||||
|
||||
// A product is banded by its worst values, the only ones it carries.
|
||||
List<TopCve>? bandCves = null;
|
||||
List<TopSoftware>? bandSoftware = null;
|
||||
if (perBand > 0)
|
||||
{
|
||||
bandCves = BandRepresentatives(
|
||||
rankedCves, listedCves, perBand,
|
||||
c => SeverityKey(c.SeverityRank), c => PriorityKey(c.Priority))
|
||||
.Select(ToTopCve)
|
||||
.ToList();
|
||||
bandSoftware = BandRepresentatives(
|
||||
rankedSoftware, topSoftware, perBand,
|
||||
s => SeverityKey(SeverityRank(s.CvssSeverity)), s => PriorityKey(s.Priority));
|
||||
}
|
||||
|
||||
var breakdown = new SeverityBreakdown
|
||||
{
|
||||
Critical = groups.Count(c => c.SeverityRank == 4),
|
||||
High = groups.Count(c => c.SeverityRank == 3),
|
||||
Medium = groups.Count(c => c.SeverityRank == 2),
|
||||
Low = groups.Count(c => c.SeverityRank == 1),
|
||||
Critical = cves.Count(c => c.SeverityRank == 4),
|
||||
High = cves.Count(c => c.SeverityRank == 3),
|
||||
Medium = cves.Count(c => c.SeverityRank == 2),
|
||||
Low = cves.Count(c => c.SeverityRank == 1),
|
||||
};
|
||||
|
||||
var priorities = new PriorityBreakdown
|
||||
{
|
||||
Act = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Act)),
|
||||
Attend = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Attend)),
|
||||
Track = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Track)),
|
||||
Act = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Act)),
|
||||
Attend = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Attend)),
|
||||
Track = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Track)),
|
||||
};
|
||||
|
||||
var totalAffectedSessions = inWindow.Select(r => r.SessionId)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||
|
||||
int? totalAffectedTenants = crossTenant
|
||||
? inWindow.Select(r => r.TenantId)
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
@@ -120,15 +165,267 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
return new CveExposureSummary
|
||||
{
|
||||
WindowDays = windowDays,
|
||||
TotalAffectedSessions = totalAffectedSessions,
|
||||
TotalAffectedSessions = DistinctSessions(inWindow),
|
||||
TotalAffectedTenants = totalAffectedTenants,
|
||||
DistinctCves = groups.Count,
|
||||
KevCves = groups.Count(c => c.IsKev),
|
||||
DistinctCves = cves.Count,
|
||||
DistinctSoftware = software.Count,
|
||||
KevCves = cves.Count(c => c.IsKev),
|
||||
KevAffectedSessions = DistinctSessions(withCve.Where(r => r.IsKev)),
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopCves = listedCves.Select(ToTopCve).ToList(),
|
||||
TopSoftware = topSoftware,
|
||||
BandCves = bandCves,
|
||||
BandSoftware = bandSoftware,
|
||||
Truncated = truncated,
|
||||
};
|
||||
}
|
||||
|
||||
private static TopCve ToTopCve(CveFold c) => new()
|
||||
{
|
||||
CveId = c.CveId,
|
||||
CvssSeverity = c.Severity,
|
||||
CvssScore = c.CvssScore,
|
||||
IsKev = c.IsKev,
|
||||
EpssScore = c.EpssScore,
|
||||
Priority = c.Priority,
|
||||
AffectedSessions = c.AffectedSessions,
|
||||
SampleSoftware = c.Rows.Select(r => r.SoftwareName)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(3).ToList(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// For every band of every banding (severity, priority): the band's first
|
||||
/// <paramref name="perBand"/> entries of the ranked list, minus the entries already
|
||||
/// <paramref name="listed"/>. Entries without a band add nothing; the ranked order is kept.
|
||||
/// </summary>
|
||||
internal static List<T> BandRepresentatives<T>(
|
||||
IReadOnlyList<T> ranked,
|
||||
IEnumerable<T> listed,
|
||||
int perBand,
|
||||
params Func<T, string>[] bandings) where T : class
|
||||
{
|
||||
var alreadyListed = new HashSet<T>(listed, ReferenceEqualityComparer.Instance);
|
||||
var picked = new SortedSet<int>();
|
||||
foreach (var bandOf in bandings)
|
||||
{
|
||||
var taken = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < ranked.Count; i++)
|
||||
{
|
||||
var band = bandOf(ranked[i]);
|
||||
if (string.IsNullOrEmpty(band)) continue;
|
||||
taken.TryGetValue(band, out var count);
|
||||
if (count >= perBand) continue;
|
||||
taken[band] = count + 1;
|
||||
if (!alreadyListed.Contains(ranked[i])) picked.Add(i);
|
||||
}
|
||||
}
|
||||
return picked.Select(i => ranked[i]).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first <paramref name="topN"/> entries of a ranked list plus the KEV entries below the
|
||||
/// cut (at most <paramref name="maxAdditions"/>). The input order is kept, so a list ranked by
|
||||
/// affected sessions stays ranked.
|
||||
/// </summary>
|
||||
internal static List<T> WithKevAdditions<T>(IReadOnlyList<T> ranked, int topN, Func<T, bool> isKev, int maxAdditions)
|
||||
=> ranked.Take(topN)
|
||||
.Concat(ranked.Skip(topN).Where(isKev).Take(maxAdditions))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Display name for a product from its detected titles, most sessions first: the leading
|
||||
/// words every title shares when that is at least two words, else the most common title.
|
||||
/// A trailing version or architecture suffix is dropped ("7-Zip 24.08 (x64 edition)" →
|
||||
/// "7-Zip"); a name that would end up empty keeps its title.
|
||||
/// </summary>
|
||||
internal static string DisplayName(IReadOnlyList<string> titles)
|
||||
{
|
||||
var cleaned = titles.Select(t => t?.Trim() ?? string.Empty).Where(t => t.Length > 0).ToList();
|
||||
if (cleaned.Count == 0) return string.Empty;
|
||||
|
||||
var words = cleaned.Select(t => t.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToList();
|
||||
var shared = 0;
|
||||
while (shared < words[0].Length
|
||||
&& words.All(w => w.Length > shared && string.Equals(w[shared], words[0][shared], StringComparison.OrdinalIgnoreCase)))
|
||||
shared++;
|
||||
|
||||
var prefix = TrimTrailingSeparators(words[0].Take(shared).ToList());
|
||||
var baseName = cleaned.Count > 1 && prefix.Count >= 2 ? string.Join(" ", prefix) : cleaned[0];
|
||||
var stripped = StripVersionSuffix(baseName);
|
||||
return stripped.Length > 0 ? stripped : baseName;
|
||||
}
|
||||
|
||||
private static string StripVersionSuffix(string name)
|
||||
{
|
||||
var current = name.Trim();
|
||||
while (true)
|
||||
{
|
||||
var before = current;
|
||||
|
||||
// A trailing parenthetical: "(x64 edition)", "(64-bit)", "(x64 en-US)".
|
||||
if (current.EndsWith(")", StringComparison.Ordinal))
|
||||
{
|
||||
var open = current.LastIndexOf('(');
|
||||
if (open > 0) current = current.Substring(0, open).TrimEnd();
|
||||
}
|
||||
|
||||
var tokens = current.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||
if (tokens.Count > 1 && (IsVersionToken(tokens[^1]) || ArchitectureTokens.Contains(tokens[^1])))
|
||||
tokens.RemoveAt(tokens.Count - 1);
|
||||
current = string.Join(" ", TrimTrailingSeparators(tokens));
|
||||
|
||||
if (current == before) return current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>"24.08", "v1.2.3", "127.0.6533.74" — dotted digits, optional leading v. A bare integer ("2016", "7") is part of the name.</summary>
|
||||
private static bool IsVersionToken(string token)
|
||||
{
|
||||
var s = token.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? token.Substring(1) : token;
|
||||
if (s.Length < 3 || !char.IsDigit(s[0]) || !char.IsDigit(s[^1])) return false;
|
||||
var dots = 0;
|
||||
foreach (var ch in s)
|
||||
{
|
||||
if (ch == '.') dots++;
|
||||
else if (!char.IsDigit(ch)) return false;
|
||||
}
|
||||
return dots > 0;
|
||||
}
|
||||
|
||||
private static List<string> TrimTrailingSeparators(List<string> tokens)
|
||||
{
|
||||
var list = tokens.ToList();
|
||||
while (list.Count > 0)
|
||||
{
|
||||
var last = list[^1].TrimEnd('-', ':', ',', '|', '/', '–', '—');
|
||||
if (last.Length == 0) { list.RemoveAt(list.Count - 1); continue; }
|
||||
list[^1] = last;
|
||||
break;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static int DistinctSessions(IEnumerable<CveExposureEntry> rows)
|
||||
=> rows.Select(r => r.SessionId)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||
|
||||
private static CveFold FoldCve(string cveId, List<CveExposureEntry> rows)
|
||||
{
|
||||
var topSeverityRow = rows.OrderByDescending(r => SeverityRank(r.CvssSeverity)).First();
|
||||
return new CveFold(
|
||||
CveId: cveId,
|
||||
Severity: topSeverityRow.CvssSeverity,
|
||||
SeverityRank: SeverityRank(topSeverityRow.CvssSeverity),
|
||||
CvssScore: rows.Max(r => r.CvssScore),
|
||||
IsKev: rows.Any(r => r.IsKev),
|
||||
// Max over a nullable ignores nulls and yields null when nothing is scored.
|
||||
EpssScore: rows.Max(r => r.EpssScore),
|
||||
Priority: rows.Select(r => r.Priority)
|
||||
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
|
||||
AffectedSessions: DistinctSessions(rows),
|
||||
Rows: rows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds the rows per product. A row without a product (written before the index carried
|
||||
/// it, or a finding without a CPE) borrows the product of another row with the same title —
|
||||
/// so the transition converges as new sessions are correlated — and otherwise is keyed by
|
||||
/// its title. Rows with neither are left out of the product view.
|
||||
/// </summary>
|
||||
private static List<TopSoftware> FoldSoftware(List<CveExposureEntry> rows)
|
||||
{
|
||||
var productByTitle = rows
|
||||
.Where(r => !string.IsNullOrEmpty(r.Product) && !string.IsNullOrWhiteSpace(r.SoftwareName))
|
||||
.GroupBy(r => r.SoftwareName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.GroupBy(r => r.Product, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(p => p.Count())
|
||||
.ThenBy(p => p.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.First().Key,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
string ProductKey(CveExposureEntry r)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(r.Product)) return r.Product;
|
||||
var title = r.SoftwareName?.Trim() ?? string.Empty;
|
||||
return title.Length > 0 && productByTitle.TryGetValue(title, out var product) ? product : title;
|
||||
}
|
||||
|
||||
return rows
|
||||
.Select(r => (Row: r, Key: ProductKey(r)))
|
||||
.Where(x => x.Key.Length > 0)
|
||||
.GroupBy(x => x.Key, x => x.Row, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g =>
|
||||
{
|
||||
var productRows = g.ToList();
|
||||
var productCves = productRows
|
||||
.GroupBy(r => r.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(cg => FoldCve(cg.Key, cg.ToList()))
|
||||
.ToList();
|
||||
var worst = productCves
|
||||
.OrderByDescending(c => c.SeverityRank)
|
||||
.ThenByDescending(c => c.CvssScore)
|
||||
.First();
|
||||
var titles = productRows
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.SoftwareName))
|
||||
.GroupBy(r => r.SoftwareName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(tg => (Title: tg.Key, Sessions: DistinctSessions(tg)))
|
||||
.OrderByDescending(t => t.Sessions)
|
||||
.ThenBy(t => t.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(t => t.Title)
|
||||
.ToList();
|
||||
|
||||
return new TopSoftware
|
||||
{
|
||||
Product = g.Key,
|
||||
Name = DisplayName(titles),
|
||||
AffectedSessions = DistinctSessions(productRows),
|
||||
CveCount = productCves.Count,
|
||||
KevCount = productCves.Count(c => c.IsKev),
|
||||
CvssSeverity = worst.Severity,
|
||||
CvssScore = worst.CvssScore,
|
||||
Priority = productCves.Select(c => c.Priority)
|
||||
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
|
||||
Titles = titles.Take(MaxTitlesPerSoftware).ToList(),
|
||||
Cves = productCves
|
||||
.OrderByDescending(c => CvePriority.Rank(c.Priority))
|
||||
.ThenByDescending(c => c.EpssScore ?? -1)
|
||||
.ThenByDescending(c => c.CvssScore)
|
||||
.ThenByDescending(c => c.AffectedSessions)
|
||||
.ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxCvesPerSoftware)
|
||||
.Select(c => new SoftwareCve
|
||||
{
|
||||
CveId = c.CveId,
|
||||
CvssSeverity = c.Severity,
|
||||
CvssScore = c.CvssScore,
|
||||
IsKev = c.IsKev,
|
||||
EpssScore = c.EpssScore,
|
||||
Priority = c.Priority,
|
||||
AffectedSessions = c.AffectedSessions,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>One CVE folded across its rows (the whole scope, or one product's rows).</summary>
|
||||
private sealed record CveFold(
|
||||
string CveId,
|
||||
string Severity,
|
||||
int SeverityRank,
|
||||
double CvssScore,
|
||||
bool IsKev,
|
||||
double? EpssScore,
|
||||
string Priority,
|
||||
int AffectedSessions,
|
||||
List<CveExposureEntry> Rows);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/metrics/vulnerability?days=30&topN=20
|
||||
/// GET /api/metrics/vulnerability?days=30&topN=20&perBand=10
|
||||
/// Fleet vulnerability summary for the caller's tenant (MemberRead).
|
||||
/// </summary>
|
||||
[Function("GetVulnerabilitySummary")]
|
||||
@@ -47,7 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/vulnerability?days=30&topN=20&tenantId=...
|
||||
/// GET /api/global/metrics/vulnerability?days=30&topN=20&perBand=10&tenantId=...
|
||||
/// Cross-tenant summary (Global Admin). With tenantId, scopes to one tenant.
|
||||
/// </summary>
|
||||
[Function("GetGlobalVulnerabilitySummary")]
|
||||
@@ -67,14 +67,17 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
var topN = QueryParams.Int(query["topN"], @default: 20, min: 1, max: 100);
|
||||
// Per-band representatives for the portal's band filters; callers that omit it
|
||||
// (the MCP tool) get the lists without them.
|
||||
var perBand = QueryParams.Int(query["perBand"], @default: 0, min: 0, max: VulnerabilitySummaryAggregator.MaxPerBand);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN})",
|
||||
crossTenant ? "cross-tenant" : tenantId, days, topN);
|
||||
"Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN}, perBand={PerBand})",
|
||||
crossTenant ? "cross-tenant" : tenantId, days, topN, perBand);
|
||||
|
||||
var (rows, truncated) = await _sessionRepo.ScanCveIndexAsync(tenantId, MaxScanRows);
|
||||
var summary = VulnerabilitySummaryAggregator.Aggregate(
|
||||
rows, truncated, topN, days, crossTenant, DateTime.UtcNow);
|
||||
rows, truncated, topN, days, crossTenant, DateTime.UtcNow, perBand);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(summary);
|
||||
|
||||
@@ -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})",
|
||||
|
||||
+67
-37
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -495,7 +524,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 +637,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 +651,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 +690,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 +747,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))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user