Compare commits

...

14 Commits

Author SHA1 Message Date
Oliver Kieselbach 6b92e4e28c bump pointer: internal (D-316/D-317, concept docs, plan review, B-qrz/B-cwy/B-shx/B-96m), .claude/commands (platform-health marker)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:20:45 +02:00
Oliver Kieselbach f392819b97 Portal, MCP and backend apply script_output_reconciliation: a corrected platform run shows its own output and "exit n/a" (D-316)
Web: the script panel applies the correction to the run its runId names: the saved output replaces the emitted one, a foreign end block's exit code shows as n/a with an explanation, the state follows the corrected data instead of the emitted event type, and the output carries a short "from IME's saved result" note. The timeline marks the original row as corrected later, and the correction event falls under the script-output setting like the other script events. The session page passes the event to the panel; the gather-rule event list knows the type.

MCP: get_session_summary no longer counts a script_failed as an error, ranks it as one or attaches the exit code's catalog text when a foreign correction with IME result Success names its run.

Backend: the correction never heals a stalled session and never counts as a script run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:15:07 +02:00
Oliver Kieselbach 672570df04 Platform script output is checked against IME's saved result and corrected when the log attributed another script's end block (D-316)
After reporting a script batch, IME saves every run under HKLM\SOFTWARE\Microsoft\IntuneManagementExtension\Policies\<user>\<policy>; ResultDetails carries the stdout IME read from that policy's own result file (the stderr when it failed with error code 99). The agent now checks each emitted platform run against it: a match confirms the output and the exit code of the same end block, a contradiction emits script_output_reconciliation (Warning, outcome "foreign": the saved output replaces the emitted one, the exit code is unverified), and output the log delivered incomplete is completed (Info, outcome "repaired").

A save is paired with the latest run of its user and policy only after the tracker completed a pass that started two seconds after the save was seen, so a stalled poll loop cannot pair it with an older run. Platform completions carry a runId the correction names. Two restart-safe counters (verified, corrected) ride agent_metrics_snapshot and ime_pattern_hits. The new event type is registered in the shared constants, the generated manifests and the MCP event catalog.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:08:22 +02:00
Oliver Kieselbach 6f7ececa1f Platform scripts no longer take another executor's output and exit code: a cut line in AgentExecutor.log ends the invocation in front of it (D-317)
Two AgentExecutor processes that open the log at the same end write their start blocks over each other. The end block of the one whose start block lost then sat behind the other executor's start block, and position-based ownership handed a Win32 detection script's stdout and exit code to the platform script.

An AgentExecutor.log entry that is not exactly one CMTrace record (a fragment, a merged line or an empty line) is where one writer's bytes end and another's begin: it now closes the platform invocation in front of it and is never matched. Invocation markers live as long as the bytes of their entry: a rewind sets them aside and gives an entry that reads unchanged its markers back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:08:21 +02:00
Oliver Kieselbach af7e9d3650 bump pointer: internal (D-315 live, B-crw field verify)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 13:51:00 +02:00
Oliver Kieselbach b3b122a23c bump pointer: internal (session start anchor D-315, plan, B-crw)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 13:41:19 +02:00
Oliver Kieselbach 064b670c75 Update and servicing rows backfilled from before the agent no longer set the session start (D-315)
Since agent 2.0.1473 the Windows Update and servicing watchers backfill rows with their event-log
time up to 60 min before the agent started, and the session anchor only skipped the system
timeline watcher, so session starts and durations grew by 22 min at the median.

- Shared: Constants.EventSources.SessionAnchorIneligible and IsSessionAnchorEligible are the one
  rule; the agent writes the same constants as Source.
- Backend: the ingest batch (ClassifyEvents, now internal static) and the Events-table probe use it.
- Web: the session duration reads the list from shared-manifests.json.
- Recorded sessions keep their stored start; the rule applies to new ingests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 13:40:54 +02:00
Oliver Kieselbach e5eb6dc4fb bump pointer: internal (OOBE update field measurement 2, B-cqa session start regression, B-93v)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 13:05:18 +02:00
Oliver Kieselbach 9f46adcdfa ci: the storage auth helper masks its token in the run log, like the Key Vault token
GitHub masks only secrets.* by itself. The bearer token Get-StorageAuthHeaders.ps1
mints at run time was never registered as a mask, unlike the Key Vault signing token,
which build-agent, build-msi and publish-scripts mask before exporting it. No caller
prints it today -- every call site captures the hashtable and passes it only as
-Headers -- but this repository's run logs are public, and a single debugging line
would have published it: an uncaptured call of the helper, or Get-Error /
Format-List on a failed request, whose TargetObject is the HttpRequestMessage
including the Authorization header (reproduced locally with a dummy token).

The helper now emits ::add-mask:: for every token it hands out, which covers all six
workflows in one place. Write-Host, never Write-Output: the output stream is the
script's return value. Verified with a mocked az that the result stays one hashtable
and that a nested caller in the Request-BuildNumber.ps1 pattern still returns only
its build number. Only under GITHUB_ACTIONS -- locally the line would print the token
instead of hiding it.

Prompted by an external AI review of the public scripts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:52:01 +02:00
Claude cd1319a486 The GA tenant editor's Members card drops its icon column, so its content lines up with the other cards
- The Members card was the only card in the edit modal with a separate
  icon column: its title, add form and member list sat 32px in from the
  card edge, which on a phone also took 32px from the email field and the
  member rows.
- It now uses the layout of its sibling cards: the title on top, the
  content at full card width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNLaZbFQTdhQJU9jh37Hn6
2026-10-03 09:17:08 +00:00
Claude ca10f36624 Landing stats band takes the versioned stats file from the browser cache again; the build log names the baked file
Since e537a4b the backend writes a new versioned file per publish, so its
immutable header holds again. The browser revalidates only the manifest
(no-cache) and serves the file it names from its cache (default mode),
instead of revalidating both, the workaround in 762fef4 for the per-day name.

The build log names the baked file ("Platform stats baked into the landing
page from platform-stats.<date>T<time>Z.json (as of ...)"), so every web
deploy shows which publish it carries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ddfosgdeCwXo7NvB9xd2
2026-10-03 04:57:38 +00:00
Claude e537a4b074 Platform stats publish writes a new versioned file each run, so its immutable header holds again
The versioned stats file is served public, max-age=31536000, immutable, and
the manifest platform-stats.json names the current one. Its per-day name
(19e9713a) was unique while maintenance ran once a night; since 82919d47 it
runs every 2 hours and rewrote that file up to 12 times a day, so caches kept
the first copy of the day. The name now carries the publish instant to the
second in invariant culture (platform-stats.2026-10-03T060004Z.json).

Headers and access tier travel with the upload instead of two follow-up
calls, which left the blob briefly without Cache-Control and content type,
and kept it that way (with the manifest still on the old file) when one of
them failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ddfosgdeCwXo7NvB9xd2
2026-10-03 04:54:54 +00:00
Claude 762fef4e24 Landing page bakes the platform stats of the deploy into its static HTML; browsers then swap in the live numbers
Crawlers and LLM fetchers run no JS and saw only the skeleton of the stats
band. app/page.tsx now awaits one read of the public stats blob during
next build (lib/platformStats.ts: manifest, then the versioned file it
names) and renders the numbers into the export. StatsBand starts from that
snapshot and replaces it with the live numbers; a failed live read keeps
the baked numbers instead of hiding the band.

The read is awaited at page level, not under Suspense: React moves a
resolved boundary of a page this size into a hidden div at the end of the
body. It uses no-store (route force-static), so Next's fetch cache cannot
hand a later build the previous numbers. Fail-soft: without a snapshot the
page ships the skeleton and loads live as before, and deploy-web.yml warns.

The browser now revalidates both files (no-cache instead of force-cache):
the backend rewrites the per-day file every 2 hours under immutable, so a
copy cached earlier that day would have made the numbers drop after load.

The page body moved to components/landing/LandingPage.tsx, which the
marketing guards render synchronously; StatsBand left the .json() ratchet
baseline; the sitemap lastmod of / includes the body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ddfosgdeCwXo7NvB9xd2
2026-10-03 03:27:20 +00:00
Oliver Kieselbach bca492ba05 bump pointer: internal (agent v2.0.1474 released, B-6dx field verify, time-attribution deploy steps)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 23:06:40 +02:00
63 changed files with 3003 additions and 300 deletions
@@ -11,6 +11,7 @@
Identity comes from whatever `az login` established: the federated GitHub OIDC principal
in CI (azure/login, subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main), the
operator's own account for the local build. No secret is read, stored or passed around.
Under GitHub Actions the token is masked in the run log before it is returned.
x-ms-version is MANDATORY here, not decoration: Entra-authorized requests are rejected
below 2017-11-09, and Table requests without a SAS must carry the header at all. 2021-08-06
@@ -45,6 +46,15 @@ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
'on the table -- a missing role shows up later as HTTP 403 AuthorizationPermissionMismatch.')
}
# GitHub masks only secrets.* on its own; a token minted at run time is clear text in the
# (public) run log the moment anything prints it -- an uncaptured call of this script, or
# Get-Error / Format-List on a failed request, whose TargetObject carries this header.
# Write-Host, never Write-Output: the output stream is this script's return value. Only
# under Actions -- anywhere else the line would print the token instead of hiding it.
if ($env:GITHUB_ACTIONS -eq 'true') {
Write-Host "::add-mask::$token"
}
return @{
'Authorization' = "Bearer $token"
'x-ms-version' = '2021-08-06'
+5
View File
@@ -108,6 +108,8 @@ jobs:
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
# Browsers load the live stats through it, and the build reads it once to bake the
# numbers of this moment into the landing page's static HTML (lib/platformStats.ts).
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
@@ -121,6 +123,9 @@ jobs:
npm run build
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
# Fail-soft like the star count: browsers still load the stats live; only crawlers and
# LLM fetchers, which run no JS, see no numbers until the next deploy.
grep -q 'data-stats-snapshot=' out/index.html || echo "::warning::Platform stats were not baked into the landing page (reason in the build log) - crawlers and LLM fetchers see no numbers until the next deploy"
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
- name: Package export
@@ -140,6 +140,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
Assert.Equal(1, fired); // the persisted flag suppressed the second emission
}
[Fact]
public void Registry_reconciliation_counters_survive_a_restart()
{
using var h = new Harness();
h.Tracker.ScriptRuns.CountVerified();
h.Tracker.ScriptRuns.CountVerified();
h.Tracker.ScriptRuns.CountCorrected();
h.Tracker.SaveStateForTest();
h.Restart();
var health = h.Tracker.GetHealthSnapshot();
Assert.Equal(2, health.PlatformOutputVerified);
Assert.Equal(1, health.PlatformOutputCorrected);
}
[Fact]
public async Task Handler_time_does_not_count_against_the_line_budget()
{
@@ -0,0 +1,368 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// Session 756970cf: IME launched a Win32 detection executor and a platform-script executor 15 ms apart, and both
/// opened AgentExecutor.log at the same end-of-file offset. The platform executor's shorter start block overwrote
/// the detection executor's, whose identity line the tracker never saw; the detection's end block (exit 1 and its
/// stdout), written at its own position behind its own start block, then sat behind the PLATFORM start block, and
/// "the nearest preceding marker owns the line" handed another script's output and exit code to the platform
/// script. These tests pin the writer boundary: an entry that is not exactly one CMTrace record is where one
/// writer's bytes end and another's begin, and nothing behind it belongs to the invocation in front of it.
/// </summary>
public sealed partial class ImeLogTrackerOverwriteRewindTests
{
private const string RaceUserId = "00000000-0000-0000-0000-000000000000";
private const string RacePlatformId = "1d6f0a52-3c4e-4b7a-9e21-5f8c7d2b9a10";
private const string RaceDetectionAppId = "8b2e4c71-6a9d-4f03-b5e8-2c7a1d9f4e36";
private const string RaceFillerAppId = "5e1c2b7d-0a4f-4c8e-9d36-7b2f1e8a4c50";
private const string ImeDir = @"C:\Program Files (x86)\Microsoft Intune Management Extension";
private const string DetectionStdout = "VERBOSE: === Sample Suite Detection Started ===\r\nWARNING: === DETECTION FAILED: Sample Suite is not installed ===";
private const string PlatformStdout = "=== bootstrap loader started ===\r\nInstaller finished (exit code 0).";
private static string EntryAt(string time, string message)
=> $"<![LOG[{message}]LOG]!><time=\"{time}\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">\r\n";
/// <summary>The four lines every AgentExecutor invocation starts with — identical length for every executor.</summary>
private static string ExecutorBanner(string time)
=> EntryAt(time, "ExecutorLog AgentExecutor gets invoked")
+ EntryAt(time, "Creating command line parser, name delimiter is - and value separator is .")
+ EntryAt(time, "Getting Ordered Parameters")
+ EntryAt(time, "Parsing Ordered Parameters.");
private static string ExecutorLaunch(string time, string scriptPath, string pid)
=> EntryAt(time, "Prepare to run Powershell Script ..")
+ EntryAt(time, $"cmd line for running powershell is -NoProfile -executionPolicy bypass -file \"{scriptPath}\" ")
+ EntryAt(time, "runAs32BitOn64 = False, so Disable Wow64FsRedirection")
+ EntryAt(time, @"PowerShell path is C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe")
+ EntryAt(time, $"[Executor] created powershell with process id {pid}");
private static string PlatformStartBlock(string time, string policyId, string pid)
{
var path = $@"{ImeDir}\Policies\Scripts\{RaceUserId}_{policyId}.ps1";
var results = $@"{ImeDir}\Policies\Results\{RaceUserId}_{policyId}";
return ExecutorBanner(time)
+ EntryAt(time, $"Adding argument powershell with value {path} to the named argument list.")
+ EntryAt(time, "Powershell option gets invoked")
+ EntryAt(time, path)
+ EntryAt(time, results + ".output")
+ EntryAt(time, results + ".error")
+ EntryAt(time, results + ".timeout")
+ ExecutorLaunch(time, path, pid);
}
private static string DetectionStartBlock(string time, string appId, string pid)
{
var path = $@"{ImeDir}\Content\DetectionScripts\{appId}_2.ps1";
return ExecutorBanner(time)
+ EntryAt(time, $"Adding argument powershellDetection with value {path} to the named argument list.")
+ EntryAt(time, "PowershellDetection option gets invoked")
+ EntryAt(time, path)
+ EntryAt(time, path + "quotedResultFilePath.txt")
+ EntryAt(time, path + "quotedErrorFilePath.txt")
+ EntryAt(time, path + "quotedTimeoutFilePath.txt")
+ EntryAt(time, path + "quotedExitCodeFilePath.txt")
+ ExecutorLaunch(time, path, pid);
}
private static string ExecutorEndBlock(string time, int exitCode, string stdout)
=> EntryAt(time, $"Powershell exit code is {exitCode}")
+ EntryAt(time, $"length of out={stdout.Length}")
+ EntryAt(time, "length of error=0")
+ EntryAt(time, "error from script =")
+ EntryAt(time, exitCode == 0 ? "Powershell script is successfully executed." : "Powershell script is failed to execute")
+ EntryAt(time, $"write output done. output = {stdout}, error = ")
+ EntryAt(time, "Revert Wow64FsRedirection")
+ EntryAt(time, "Agent executor completed.");
private static string PlatformResultLine(string policyId)
=> $"[PowerShell] User Id = {RaceUserId}, Policy id = {policyId}, policy result = Success";
/// <summary>The file offsets at which the entries of an ASCII <paramref name="block"/> start when it is written at <paramref name="at"/>.</summary>
private static HashSet<long> EntryStarts(string block, long at)
{
Assert.True(block.All(c => c < 128), "offset arithmetic assumes ASCII test content");
var starts = new HashSet<long>();
for (var i = block.IndexOf("<![LOG[", StringComparison.Ordinal); i >= 0; i = block.IndexOf("<![LOG[", i + 1, StringComparison.Ordinal))
starts.Add(at + i);
return starts;
}
[Fact]
public async Task Executor_that_opened_at_the_same_offset_never_lends_its_end_block_to_the_platform_script()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
// An earlier, finished detection run: the race starts mid-file like in the field log.
h.Append(DetectionStartBlock("12:00:00.0000000", RaceFillerAppId, "1548") + ExecutorEndBlock("12:00:00.5000000", 0, "detected"));
await h.Pass();
var x = h.Length;
// Detection executor C and platform executor D open the file at the same end x. C writes first; the tracker
// reads C's banner, then D writes its own start block over C's.
var cStart = DetectionStartBlock("12:00:01.0000000", RaceDetectionAppId, "6416");
var dStart = PlatformStartBlock("12:00:01.2000000", RacePlatformId, "2524");
var cHead = cStart.Substring(0, cStart.IndexOf("<![LOG[Getting Ordered Parameters", StringComparison.Ordinal));
h.Append(cHead);
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
h.Append(cStart.Substring(cHead.Length));
h.Overwrite(x, dStart);
// The field geometry: D's start block is shorter and ends inside one of C's lines, so the tail of that line
// sits between D's start block and C's remaining bytes.
Assert.True(dStart.Length < cStart.Length);
Assert.DoesNotContain(x + dStart.Length, EntryStarts(cStart, x));
h.Now = h.Now.AddMilliseconds(150);
await h.Pass();
Assert.True(h.Health.OverwriteRewinds >= 1);
// C ends: its end block lands at its own position, right behind its own (overwritten) start block.
Assert.Equal(x + cStart.Length, h.Length);
var cEnd = ExecutorEndBlock("12:00:07.0000000", 1, DetectionStdout);
h.Append(cEnd);
h.Now = h.Now.AddSeconds(6);
await h.Pass();
// IME's result for D reaches the tracker before D's own end block: the completion must wait for it.
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(RacePlatformId), sourceFileName: "IntuneManagementExtension.log");
Assert.Empty(h.Completed);
// D ends: its end block lands at ITS position, over the fragment and C's bytes; C's last line survives.
var dEnd = ExecutorEndBlock("12:00:09.5000000", 0, PlatformStdout);
Assert.True(dEnd.Length < cStart.Length - dStart.Length + cEnd.Length);
h.Overwrite(x + dStart.Length, dEnd);
h.Now = h.Now.AddMilliseconds(130);
await h.Pass();
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
var script = Assert.Single(h.Completed);
Assert.Equal(RacePlatformId, script.PolicyId);
Assert.Equal("Success", script.Result);
Assert.Equal(0, script.ExitCode);
Assert.Equal(PlatformStdout.Replace("\r\n", "\n"), script.Stdout);
Assert.DoesNotContain(h.Completed, s => (s.Stdout ?? string.Empty).Contains("Detection"));
// The run is known to the registry reconciliation under the user id of its own lines (D-316).
Assert.Equal(RaceUserId, script.UserId);
Assert.False(string.IsNullOrEmpty(script.RunId));
h.Tracker.ScriptRuns.NotePassCompleted(DateTime.MaxValue.AddDays(-1));
Assert.Equal(PlatformRunClaim.Claimed, h.Tracker.ScriptRuns.TryClaim(RaceUserId, RacePlatformId, h.Now, out var run));
Assert.Equal(script.RunId, run!.RunId);
}
/// <summary>
/// The mirror image: a foreign executor whose stream position lies inside the platform start block (it opened
/// at the same end with a shorter start block) writes its end block there. Its first line merges with the head of
/// a platform line — cut inside the message or inside the trailer, which the parser would otherwise read up to
/// the foreign trailer — and its output line follows without any marker in between.
/// </summary>
[Theory]
[InlineData(15)]
[InlineData(-30)]
public async Task Foreign_end_block_merged_into_the_platform_start_block_never_reaches_the_platform_script(int cut)
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(DetectionStartBlock("12:00:00.0000000", RaceFillerAppId, "1548") + ExecutorEndBlock("12:00:00.5000000", 0, "detected"));
await h.Pass();
var x = h.Length;
var dStart = PlatformStartBlock("12:00:01.0000000", RacePlatformId, "2524");
h.Append(dStart);
await h.Pass();
// The foreign end block starts inside the line after D's argument line.
var lineAfterArgument = x + dStart.IndexOf("<![LOG[Powershell option gets invoked", StringComparison.Ordinal);
var lineLength = EntryAt("12:00:01.0000000", "Powershell option gets invoked").Length;
var at = lineAfterArgument + (cut > 0 ? cut : lineLength + cut);
var foreignEnd = ExecutorEndBlock("12:00:03.0000000", 1, "foreign output");
Assert.True(at + foreignEnd.Length < x + dStart.Length, "the foreign block must end inside D's start block");
h.Overwrite(at, foreignEnd);
h.Tracker.RequestOverwriteCheckForTest();
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(RacePlatformId), sourceFileName: "IntuneManagementExtension.log");
h.Now = h.Now.Add(ImeLogTracker.PlatformScriptEndBlockGrace);
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
// No owner is provable behind the cut: the completion goes out without end-block data, never with the foreign one.
var script = Assert.Single(h.Completed);
Assert.Equal("Success", script.Result);
Assert.Null(script.ExitCode);
Assert.Null(script.Stdout);
}
[Fact]
public async Task Entry_read_unchanged_after_a_rewind_keeps_its_platform_marker()
{
const string platformId = "2b7d9e41-8c3a-4f60-a5d2-9e1b7c4f3a08";
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
// Detection W waits for its script; detection X runs completely behind it; platform F starts behind X.
var wStart = DetectionStartBlock("12:00:00.0000000", RaceFillerAppId, "1111");
var xRun = DetectionStartBlock("12:00:00.2000000", RaceDetectionAppId, "2222") + ExecutorEndBlock("12:00:00.4000000", 0, "x detected");
h.Append(wStart + xRun + PlatformStartBlock("12:00:01.0000000", platformId, "3333"));
await h.Pass();
// W ends at its own position, over the head of X's run; F's start block keeps its bytes and offset.
var wEnd = ExecutorEndBlock("12:00:02.0000000", 0, "w detected");
Assert.True(wEnd.Length < xRun.Length);
h.Overwrite(wStart.Length, wEnd);
h.Tracker.RequestOverwriteCheckForTest();
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(1, h.Health.OverwriteRewinds);
// F's end block lands behind its start block: still F's, because F's marker survived with its bytes.
h.Append(ExecutorEndBlock("12:00:05.0000000", 7, "Hello from F"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(platformId), sourceFileName: "IntuneManagementExtension.log");
var script = Assert.Single(h.Completed);
Assert.Equal(platformId, script.PolicyId);
Assert.Equal(7, script.ExitCode);
Assert.Equal("Hello from F", script.Stdout);
}
[Fact]
public async Task Writer_boundary_read_unchanged_after_a_rewind_still_closes_the_platform_invocation()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
// Detection Q waits for its script while detection R runs completely behind it.
var qStart = DetectionStartBlock("12:00:00.0000000", RaceFillerAppId, "1111");
var rRun = DetectionStartBlock("12:00:00.2000000", "6c3f8a20-1d5e-4b97-8f4a-0e2d6b9c7a13", "2222") + ExecutorEndBlock("12:00:00.4000000", 0, "r detected");
h.Append(qStart + rRun);
await h.Pass();
var x = h.Length;
// The race of the field case behind them: the boundary closes D.
var cStart = DetectionStartBlock("12:00:01.0000000", RaceDetectionAppId, "6416");
var dStart = PlatformStartBlock("12:00:01.2000000", RacePlatformId, "2524");
h.Append(cStart);
h.Overwrite(x, dStart);
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
// Q ends at its own position, over R's run: the rewind starts in front of the race, whose bytes read unchanged.
var qEnd = ExecutorEndBlock("12:00:02.0000000", 0, "q detected");
Assert.True(qEnd.Length < rRun.Length);
h.Overwrite(qStart.Length, qEnd);
h.Tracker.RequestOverwriteCheckForTest();
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
// C's end block behind the boundary stays unowned; D's own end block completes D.
var cEnd = ExecutorEndBlock("12:00:07.0000000", 1, DetectionStdout);
h.Append(cEnd);
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(RacePlatformId), sourceFileName: "IntuneManagementExtension.log");
Assert.Empty(h.Completed);
h.Overwrite(x + dStart.Length, ExecutorEndBlock("12:00:09.5000000", 0, PlatformStdout));
h.Now = h.Now.AddMilliseconds(130);
await h.Pass();
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
var script = Assert.Single(h.Completed);
Assert.Equal(0, script.ExitCode);
Assert.Equal(PlatformStdout.Replace("\r\n", "\n"), script.Stdout);
}
[Fact]
public async Task Raw_text_in_the_executor_log_is_never_matched_and_closes_the_platform_invocation()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(PlatformStartBlock("12:00:00.0000000", RacePlatformId, "2524"));
await h.Pass();
// The tail of a cut line that happens to read like an exit line, then a foreign end block.
h.Append("Powershell exit code is 5\r\n" + ExecutorEndBlock("12:00:02.0000000", 6, "foreign output"));
// A fragment right at the bookmark stops the first pass for a check; the second one reads it.
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(RacePlatformId), sourceFileName: "IntuneManagementExtension.log");
h.Now = h.Now.Add(ImeLogTracker.PlatformScriptEndBlockGrace);
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
var script = Assert.Single(h.Completed);
Assert.Null(script.ExitCode);
Assert.Null(script.Stdout);
}
[Fact]
public async Task Fragment_in_the_ime_log_does_not_close_the_platform_run_context()
{
using var h = new Harness("IntuneManagementExtension.log", ScriptPatterns());
// In the IME log the service writes the platform marker and the context line itself; the fragment in
// between is the tail of an executor's telemetry line and separates nothing.
h.Append(Entry(@"Script file C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\" + RaceUserId + "_" + RacePlatformId + ".ps1 is generated.", "17")
+ "ing mananger...]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">\r\n"
+ Entry("Launch powershell executor in user session", "17"));
await h.Pass();
h.Tracker.ProcessLogMessageForTest(PlatformResultLine(RacePlatformId));
h.Now = h.Now.Add(ImeLogTracker.PlatformScriptEndBlockGrace);
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
var script = Assert.Single(h.Completed);
Assert.Equal("User", script.RunContext);
}
[Fact]
public async Task Cut_multi_line_output_adds_one_boundary_not_one_per_line()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(PlatformStartBlock("12:00:00.0000000", RacePlatformId, "2524"));
await h.Pass();
var markersBefore = h.Tracker.InvocationMarkerCountForTest("AgentExecutor.log");
var lines = string.Concat(Enumerable.Range(1, 600).Select(i => $"orphaned output line {i}\r\n"));
h.Append(lines);
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(markersBefore + 1, h.Tracker.InvocationMarkerCountForTest("AgentExecutor.log"));
}
[Theory]
[InlineData("<![LOG[Powershell exit code is 0]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", true)]
[InlineData("<![LOG[write output done. output = line one\nline two, error = ]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", true)]
[InlineData("\uFEFF<![LOG[ExecutorLog AgentExecutor gets invoked]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", true)]
[InlineData("t code is 3]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", false)]
[InlineData("", false)]
[InlineData("<![LOG[Powe<![LOG[Powershell exit code is 1]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", false)]
[InlineData("<![LOG[Powershell option gets invoked]LOG]!><time=\"12:00:00.0000000\" da<![LOG[Powershell exit code is 1]LOG]!><time=\"12:00:01.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", false)]
[InlineData("<![LOG[write output done. output = line one\nline<![LOG[Agent executor completed.]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"AgentExecutor\" context=\"\" type=\"1\" thread=\"1\" file=\"\">", false)]
public void Single_cmtrace_record_is_told_apart_from_the_cut_of_two_writers(string text, bool expected)
{
Assert.Equal(expected, ImeLogTracker.IsSingleCmTraceRecord(text));
}
[Theory]
[InlineData("AgentExecutor.log", true)]
[InlineData("AgentExecutor-20261003-233348.log", true)]
[InlineData("IntuneManagementExtension.log", false)]
[InlineData("IntuneManagementExtension-20261003-233348.log", false)]
[InlineData("HealthScripts.log", false)]
[InlineData("AppWorkload.log", false)]
public void Writer_boundaries_apply_to_the_executor_log_live_and_rotated(string fileName, bool expected)
{
Assert.Equal(expected, ImeLogTracker.IsWriterBoundaryLogFile(fileName));
}
}
}
@@ -25,7 +25,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
/// contract: unchanged bytes never rewind, changed bytes are processed exactly once, and the
/// executor lines a rewind uncovers belong to the invocation that owns their file position.
/// </summary>
public sealed class ImeLogTrackerOverwriteRewindTests
public sealed partial class ImeLogTrackerOverwriteRewindTests
{
private static readonly DateTime T0 = new DateTime(2026, 9, 10, 19, 30, 0, DateTimeKind.Utc);
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
private static readonly string[] ScriptPatternIds =
{
"PS-AGENT-INVOCATION", "PS-AGENT-ARG", "PS-AGENT-SCRIPT-START", "PS-AGENT-EXITCODE",
"PS-AGENT-OUTPUT", "PS-AGENT-COMPLETED", "PS-SCRIPT-GENERATED", "PS-SCRIPT-CONTEXT", "PS-SCRIPT-RESULT",
"PS-AGENT-OUTPUT", "PS-AGENT-ERROR", "PS-AGENT-COMPLETED", "PS-SCRIPT-GENERATED", "PS-SCRIPT-CONTEXT", "PS-SCRIPT-RESULT",
};
/// <summary>The shipped pattern JSON — the same source combine.js embeds — so the contract is the real one.</summary>
@@ -0,0 +1,411 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using Xunit;
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// D-316: IME saves every platform script's result under Policies\&lt;user&gt;\&lt;policy&gt; after reporting the
/// batch; the observer checks the tracker's emitted run against it. The field case (session 756970cf): the run
/// had been given a Win32 detection script's end block — exit 1 and "Detection Started" output — while IME saved
/// the bootstrap output of the platform script itself.
/// </summary>
public sealed class ImeRegistryScriptResultObserverTests
{
private const string DeviceUser = "00000000-0000-0000-0000-000000000000";
private const string PolicyA = "1d6f0a52-3c4e-4b7a-9e21-5f8c7d2b9a10";
private const string PolicyB = "2b7d9e41-8c3a-4f60-a5d2-9e1b7c4f3a08";
private const string OwnStdout = "=== bootstrap loader started ===\nInstaller finished (exit code 0).";
private const string ForeignStdout = "VERBOSE: === Sample Suite Detection Started ===\nWARNING: === DETECTION FAILED ===";
private static readonly DateTime T0 = new DateTime(2026, 10, 3, 20, 59, 0, DateTimeKind.Utc);
// ── PlatformOutputJudge ─────────────────────────────────────────────────
private static PlatformScriptRun Run(string? stdout, string? stderr = null, int? exitCode = 0, string result = "Success", string resultSource = "ime_policy_result")
=> new PlatformScriptRun { PolicyId = PolicyA, UserId = DeviceUser, RunId = "run-1", Emitted = true, Result = result, ResultSource = resultSource, ExitCode = exitCode, Stdout = stdout, Stderr = stderr };
private static string Details(string executionMessage)
=> "{\"Version\":1,\"SigningCode\":15,\"EncryptionCode\":19,\"ExecutionMsg\":" + System.Text.Json.JsonSerializer.Serialize(executionMessage) + "}";
private static SavedScriptResult Saved(string? executionMessage, string result = "Success", int errorCode = 0, string user = DeviceUser, string policy = PolicyA, string stamp = "10/3/2026 9:00:00 PM")
=> new SavedScriptResult
{
UserId = user,
PolicyId = policy,
Result = result,
ErrorCode = errorCode,
ResultDetails = executionMessage == null ? null : Details(executionMessage),
LastUpdatedTimeUtc = stamp,
};
[Fact]
public void Field_case_detection_output_on_the_platform_run_is_foreign_and_the_saved_stdout_replaces_it()
{
var judgement = PlatformOutputJudge.Judge(Run(ForeignStdout, string.Empty, exitCode: 1), Saved(OwnStdout.Replace("\n", "\r\n")));
Assert.Equal(PlatformOutputVerdict.Foreign, judgement.Verdict);
Assert.Equal("stdout_differs", judgement.Reason);
Assert.Equal(OwnStdout, judgement.Stdout);
Assert.Equal(string.Empty, judgement.Stderr);
}
[Fact]
public void Matching_output_is_verified_across_line_ending_and_trim_differences()
{
var judgement = PlatformOutputJudge.Judge(Run(OwnStdout, string.Empty), Saved(" " + OwnStdout.Replace("\n", "\r\n") + "\r\n"));
Assert.Equal(PlatformOutputVerdict.Verified, judgement.Verdict);
}
[Fact]
public void Output_cut_by_the_tracker_is_verified_against_the_longer_saved_text()
{
var full = string.Concat(Enumerable.Repeat("line of output\n", 1000)).TrimEnd();
var emitted = ImeLogTracker.TruncateOutput(full);
Assert.EndsWith(ImeLogTracker.OutputTruncationMarker, emitted);
Assert.Equal(PlatformOutputVerdict.Verified, PlatformOutputJudge.Judge(Run(emitted), Saved(full)).Verdict);
}
[Fact]
public void Both_empty_is_verified_but_stderr_on_a_success_is_another_executors()
{
Assert.Equal(PlatformOutputVerdict.Verified, PlatformOutputJudge.Judge(Run(string.Empty), Saved(string.Empty)).Verdict);
var judgement = PlatformOutputJudge.Judge(Run(string.Empty, "Get-Item : not found"), Saved(string.Empty));
Assert.Equal(PlatformOutputVerdict.Foreign, judgement.Verdict);
Assert.Equal("stderr_on_success", judgement.Reason);
}
[Fact]
public void Explicitly_empty_emitted_output_against_saved_text_is_foreign()
{
Assert.Equal(PlatformOutputVerdict.Foreign, PlatformOutputJudge.Judge(Run(string.Empty), Saved(OwnStdout)).Verdict);
Assert.Equal(PlatformOutputVerdict.Foreign, PlatformOutputJudge.Judge(Run(OwnStdout), Saved(string.Empty)).Verdict);
}
[Fact]
public void Output_never_read_is_completed_from_the_saved_text_and_the_exit_code_stands()
{
var judgement = PlatformOutputJudge.Judge(Run(null), Saved(OwnStdout));
Assert.Equal(PlatformOutputVerdict.Repaired, judgement.Verdict);
Assert.Equal("stdout_missing", judgement.Reason);
Assert.Equal(OwnStdout, judgement.Stdout);
Assert.Equal(PlatformOutputVerdict.NotComparable, PlatformOutputJudge.Judge(Run(null), Saved(string.Empty)).Verdict);
}
[Fact]
public void Stdout_containing_the_output_error_separator_is_repaired_not_foreign()
{
// "write output done. output = X, error = " with X = "Copying files, error = 0 errors\nDone": the
// lazy split leaves "Copying files" as stdout and the rest as stderr.
var judgement = PlatformOutputJudge.Judge(
Run("Copying files", "0 errors\nDone, error ="),
Saved("Copying files, error = 0 errors\r\nDone"));
Assert.Equal(PlatformOutputVerdict.Repaired, judgement.Verdict);
Assert.Equal("stdout_split", judgement.Reason);
Assert.Equal("Copying files, error = 0 errors\nDone", judgement.Stdout);
Assert.Equal(string.Empty, judgement.Stderr);
}
[Fact]
public void A_prefix_without_the_split_explanation_is_foreign()
{
Assert.Equal(PlatformOutputVerdict.Foreign, PlatformOutputJudge.Judge(Run("Detected"), Saved("Detected and repaired")).Verdict);
}
[Fact]
public void Failed_with_stderr_is_judged_on_stderr()
{
Assert.Equal(PlatformOutputVerdict.Verified,
PlatformOutputJudge.Judge(Run("partial", "Access denied", 1, "Failed"), Saved("Access denied\r\n", "Failed", 99)).Verdict);
var missing = PlatformOutputJudge.Judge(Run(null, null, 1, "Failed"), Saved("Access denied", "Failed", 99));
Assert.Equal(PlatformOutputVerdict.Repaired, missing.Verdict);
Assert.Null(missing.Stdout);
Assert.Equal("Access denied", missing.Stderr);
var foreign = PlatformOutputJudge.Judge(Run("Detected", string.Empty, 0, "Failed"), Saved("Access denied", "Failed", 99));
Assert.Equal(PlatformOutputVerdict.Foreign, foreign.Verdict);
Assert.Equal(string.Empty, foreign.Stdout);
Assert.Equal("Access denied", foreign.Stderr);
}
[Fact]
public void Results_without_saved_output_or_from_another_run_are_not_compared()
{
Assert.Equal("no_saved_output", PlatformOutputJudge.Judge(Run(OwnStdout, null, 1, "Failed"), Saved("1", "Failed", 1)).Reason);
var hashMismatch = new SavedScriptResult { UserId = DeviceUser, PolicyId = PolicyA, Result = "Failed", ErrorCode = 2, ResultDetails = "Hash mismatch" };
Assert.Equal("no_execution_message", PlatformOutputJudge.Judge(Run(OwnStdout, null, 1, "Failed"), hashMismatch).Reason);
Assert.Equal("result_differs", PlatformOutputJudge.Judge(Run(OwnStdout), Saved("Access denied", "Failed", 99)).Reason);
// A result derived from the exit code (fallback) is not IME's — the saved one is.
Assert.Equal(PlatformOutputVerdict.Verified,
PlatformOutputJudge.Judge(Run(OwnStdout, null, 1, "Failed", "agentexecutor_fallback"), Saved(OwnStdout)).Verdict);
}
// ── PlatformScriptRunRegister ───────────────────────────────────────────
private static ScriptExecutionState Emitted(string policyId, string? stdout, int? exitCode = 0, string? userId = DeviceUser, string runId = "run-1")
=> new ScriptExecutionState { PolicyId = policyId, ScriptType = "platform", UserId = userId, RunId = runId, Result = "Success", ResultSource = "ime_policy_result", ExitCode = exitCode, Stdout = stdout };
[Fact]
public void A_save_is_paired_only_after_the_tracker_read_past_it_and_the_run_was_emitted()
{
var runs = new PlatformScriptRunRegister();
runs.NoteStarted(DeviceUser, PolicyA, T0);
var seen = T0.AddSeconds(30);
Assert.Equal(PlatformRunClaim.Wait, runs.TryClaim(DeviceUser, PolicyA, seen, out _));
runs.NotePassCompleted(seen.AddSeconds(1));
Assert.Equal(PlatformRunClaim.Wait, runs.TryClaim(DeviceUser, PolicyA, seen, out _)); // pass started too early
runs.NotePassCompleted(seen + PlatformScriptRunRegister.TrackerCatchUp);
Assert.Equal(PlatformRunClaim.Wait, runs.TryClaim(DeviceUser, PolicyA, seen, out _)); // run not emitted yet
runs.NoteEmitted(Emitted(PolicyA, OwnStdout), T0.AddSeconds(40));
Assert.Equal(PlatformRunClaim.Claimed, runs.TryClaim(DeviceUser, PolicyA, seen, out var run));
Assert.Equal("run-1", run!.RunId);
Assert.Equal(OwnStdout, run.Stdout);
Assert.Equal(PlatformRunClaim.None, runs.TryClaim(DeviceUser, PolicyA, seen, out _)); // never twice
}
[Fact]
public void A_new_start_replaces_the_latest_run_of_its_key_and_other_users_stay_apart()
{
var runs = new PlatformScriptRunRegister();
runs.NoteStarted(DeviceUser, PolicyA, T0);
runs.NoteEmitted(Emitted(PolicyA, "first run"), T0.AddSeconds(5));
runs.NoteStarted(DeviceUser, PolicyA, T0.AddMinutes(30));
runs.NoteEmitted(Emitted(PolicyA, "second run", runId: "run-2"), T0.AddMinutes(30).AddSeconds(5));
const string user = "6c3f8a20-1d5e-4b97-8f4a-0e2d6b9c7a13";
runs.NoteStarted(user, PolicyA, T0.AddMinutes(31));
runs.NotePassCompleted(T0.AddHours(1));
Assert.Equal(PlatformRunClaim.Claimed, runs.TryClaim(DeviceUser, PolicyA, T0.AddMinutes(31), out var run));
Assert.Equal("second run", run!.Stdout);
Assert.Equal(PlatformRunClaim.Wait, runs.TryClaim(user, PolicyA, T0.AddMinutes(32), out _));
Assert.Equal(PlatformRunClaim.None, runs.TryClaim(DeviceUser, PolicyB, T0.AddMinutes(32), out _));
}
[Fact]
public void A_run_without_a_readable_user_id_is_claimed_by_its_policy()
{
var runs = new PlatformScriptRunRegister();
runs.NoteStarted(null, PolicyA, T0);
runs.NoteEmitted(Emitted(PolicyA, OwnStdout, userId: null), T0.AddSeconds(5));
runs.NotePassCompleted(T0.AddMinutes(5));
Assert.Equal(PlatformRunClaim.Claimed, runs.TryClaim(DeviceUser, PolicyA, T0.AddMinutes(1), out _));
}
[Fact]
public void Register_keeps_the_newest_runs_and_its_counters_report_each_change_once()
{
var runs = new PlatformScriptRunRegister();
for (var i = 0; i <= PlatformScriptRunRegister.MaxRuns; i++)
runs.NoteStarted(DeviceUser, Guid.NewGuid().ToString("D"), T0.AddSeconds(i));
runs.NoteStarted(DeviceUser, PolicyA, T0.AddHours(1));
runs.NoteEmitted(Emitted(PolicyA, OwnStdout), T0.AddHours(1));
runs.NotePassCompleted(T0.AddHours(2));
Assert.Equal(PlatformRunClaim.Claimed, runs.TryClaim(DeviceUser, PolicyA, T0.AddHours(1), out _));
Assert.False(runs.ConsumeCountersChanged());
runs.CountVerified();
runs.CountCorrected();
Assert.True(runs.ConsumeCountersChanged());
Assert.False(runs.ConsumeCountersChanged());
Assert.Equal(1, runs.Verified);
runs.RestoreCounters(7, 3);
Assert.Equal(7, runs.Verified);
Assert.Equal(3, runs.Corrected);
}
// ── observer flow ───────────────────────────────────────────────────────
private sealed class Fixture
{
public readonly FakeSignalIngressSink Sink = new FakeSignalIngressSink();
public readonly VirtualClock Clock = new VirtualClock(T0);
public readonly PlatformScriptRunRegister Runs = new PlatformScriptRunRegister();
public Dictionary<string, SavedScriptResult> Registry = new Dictionary<string, SavedScriptResult>(StringComparer.OrdinalIgnoreCase);
public readonly ImeRegistryScriptResultObserver Observer;
public Fixture()
{
Observer = new ImeRegistryScriptResultObserver(Runs, new InformationalEventPost(Sink, Clock), null, Clock,
() => new Dictionary<string, SavedScriptResult>(Registry, StringComparer.OrdinalIgnoreCase));
}
public void Save(SavedScriptResult saved) => Registry[ImeRegistryScriptResultObserver.KeyOf(saved.UserId, saved.PolicyId)] = saved;
/// <summary>The tracker emitted a run, then kept polling past the save.</summary>
public void EmitRun(string policyId, string? stdout, int? exitCode, string runId)
{
Runs.NoteStarted(DeviceUser, policyId, Clock.UtcNow);
Runs.NoteEmitted(Emitted(policyId, stdout, exitCode, runId: runId), Clock.UtcNow);
}
public void TrackerCaughtUp() => Runs.NotePassCompleted(Clock.UtcNow);
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> Corrections()
=> Sink.Posted.Where(p => p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == SharedEventTypes.ScriptOutputReconciliation).ToList();
}
[Fact]
public void Field_case_end_to_end_the_save_corrects_the_run_with_a_warning_naming_it()
{
var f = new Fixture();
f.Save(Saved("an older run of another policy", policy: PolicyB, stamp: "10/2/2026 8:00:00 AM"));
f.Observer.Tick("baseline");
f.EmitRun(PolicyA, ForeignStdout, 1, "run-a");
f.Save(Saved(OwnStdout));
f.Clock.Advance(TimeSpan.FromSeconds(1));
f.Observer.Tick("registry_change");
Assert.Empty(f.Corrections()); // the tracker has not read past the save yet
f.Clock.Advance(PlatformScriptRunRegister.TrackerCatchUp);
f.TrackerCaughtUp();
f.Observer.Tick("periodic");
var correction = Assert.Single(f.Corrections());
var data = correction.Payload!;
Assert.Equal(PolicyA, data["policyId"]);
Assert.Equal("platform", data["scriptType"]);
Assert.Equal("run-a", data["runId"]);
Assert.Equal("foreign", data["outcome"]);
Assert.Equal("stdout_differs", data["reason"]);
Assert.Equal("Success", data["result"]);
Assert.Equal(OwnStdout, data["stdout"]);
Assert.Equal(string.Empty, data["stderr"]);
Assert.Equal("1", data["previousExitCode"]);
Assert.Equal("0", data["registryErrorCode"]);
Assert.Equal("Warning", data[SignalPayloadKeys.Severity]);
Assert.Equal("true", data[SignalPayloadKeys.ImmediateUpload]);
Assert.DoesNotContain("exitCode", data.Keys);
Assert.Equal(1, f.Runs.Corrected);
Assert.Equal(0, f.Runs.Verified);
// The same save read again changes nothing.
f.Clock.Advance(TimeSpan.FromMinutes(1));
f.TrackerCaughtUp();
f.Observer.Tick("periodic");
Assert.Single(f.Corrections());
}
[Fact]
public void Confirmed_output_is_counted_and_emits_nothing()
{
var f = new Fixture();
f.Observer.Tick("baseline");
f.EmitRun(PolicyA, OwnStdout, 0, "run-a");
f.Save(Saved(OwnStdout));
f.Clock.Advance(TimeSpan.FromSeconds(1));
f.Observer.Tick("registry_change");
f.Clock.Advance(TimeSpan.FromSeconds(3));
f.TrackerCaughtUp();
f.Observer.Tick("periodic");
Assert.Empty(f.Corrections());
Assert.Equal(1, f.Runs.Verified);
}
[Fact]
public void Completed_output_is_an_info_event_that_keeps_the_exit_code()
{
var f = new Fixture();
f.Observer.Tick("baseline");
f.EmitRun(PolicyA, null, 0, "run-a");
f.Save(Saved(OwnStdout));
f.Clock.Advance(TimeSpan.FromSeconds(1));
f.Observer.Tick("registry_change");
f.Clock.Advance(TimeSpan.FromSeconds(3));
f.TrackerCaughtUp();
f.Observer.Tick("periodic");
var data = Assert.Single(f.Corrections()).Payload!;
Assert.Equal("repaired", data["outcome"]);
Assert.Equal("Info", data[SignalPayloadKeys.Severity]);
Assert.DoesNotContain("previousExitCode", data.Keys);
}
[Fact]
public void A_save_seen_before_its_run_was_emitted_waits_for_the_emit()
{
var f = new Fixture();
f.Observer.Tick("baseline");
f.Runs.NoteStarted(DeviceUser, PolicyA, f.Clock.UtcNow); // result held for its end block
f.Save(Saved(OwnStdout));
f.Observer.Tick("registry_change");
f.Clock.Advance(TimeSpan.FromSeconds(3));
f.TrackerCaughtUp();
f.Observer.Tick("periodic");
Assert.Equal(0, f.Runs.Verified + f.Runs.Corrected);
f.Runs.NoteEmitted(Emitted(PolicyA, ForeignStdout, 1, runId: "run-a"), f.Clock.UtcNow);
f.Clock.Advance(TimeSpan.FromSeconds(60));
f.Observer.Tick("periodic");
Assert.Equal("foreign", Assert.Single(f.Corrections()).Payload!["outcome"]);
}
[Fact]
public void A_save_no_run_claims_within_the_expiry_is_dropped()
{
var f = new Fixture();
f.Observer.Tick("baseline");
f.Save(Saved(OwnStdout));
f.Observer.Tick("registry_change");
f.Clock.Advance(ImeRegistryScriptResultObserver.PendingExpiry);
f.TrackerCaughtUp();
f.Runs.NoteStarted(DeviceUser, PolicyA, f.Clock.UtcNow);
f.Observer.Tick("periodic"); // waits for the emit — and expires
f.Runs.NoteEmitted(Emitted(PolicyA, ForeignStdout, 1, runId: "run-a"), f.Clock.UtcNow);
f.Clock.Advance(TimeSpan.FromSeconds(60));
f.TrackerCaughtUp();
f.Observer.Tick("periodic");
Assert.Empty(f.Corrections());
}
[Fact]
public void Saves_from_before_the_agent_started_are_the_silent_baseline()
{
var f = new Fixture();
f.EmitRun(PolicyA, ForeignStdout, 1, "run-a");
f.Save(Saved(OwnStdout));
f.TrackerCaughtUp();
f.Observer.Tick("baseline");
f.Clock.Advance(TimeSpan.FromMinutes(1));
f.TrackerCaughtUp();
f.Observer.Tick("periodic");
Assert.Empty(f.Corrections());
Assert.Equal(0, f.Runs.Verified + f.Runs.Corrected);
}
[Fact]
public void Corrections_beyond_the_cap_are_counted_but_not_emitted()
{
var f = new Fixture();
f.Observer.Tick("baseline");
var policies = Enumerable.Range(0, ImeRegistryScriptResultObserver.MaxCorrectionsPerSession + 3).Select(_ => Guid.NewGuid().ToString("D")).ToList();
foreach (var policy in policies)
{
f.EmitRun(policy, ForeignStdout, 1, "run-" + policy);
f.Save(Saved(OwnStdout, policy: policy));
}
f.Observer.Tick("registry_change");
f.Clock.Advance(TimeSpan.FromSeconds(3));
f.TrackerCaughtUp();
f.Observer.Tick("periodic");
Assert.Equal(ImeRegistryScriptResultObserver.MaxCorrectionsPerSession, f.Corrections().Count);
Assert.Equal(policies.Count, f.Runs.Corrected);
}
}
}
@@ -0,0 +1,54 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
{
/// <summary>
/// D-316: every emitted platform-script completion carries a run id, and the tracker's run register holds what
/// was emitted for it under the IME user id — what the registry reconciliation pairs IME's saved result with.
/// </summary>
public sealed class ImeLogTrackerAdapterPlatformRunTests
{
private static readonly DateTime ClockNow = new DateTime(2026, 10, 3, 21, 0, 0, DateTimeKind.Utc);
private const string DeviceUser = "00000000-0000-0000-0000-000000000000";
private const string PolicyId = "1d6f0a52-3c4e-4b7a-9e21-5f8c7d2b9a10";
[Fact]
public void Platform_completion_carries_its_run_id_and_the_register_holds_the_emitted_run()
{
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddSeconds(-10);
f.Tracker.HandlePlatformScriptStarted(PolicyId, "ime", DeviceUser);
f.Tracker.RecordPlatformScriptExitCodeForTesting(PolicyId, 0, ClockNow.AddSeconds(-1));
f.Tracker.LastMatchedLogTimestamp = ClockNow;
f.Tracker.CompletePlatformScriptFromImeResultForTesting(PolicyId, "Success", ClockNow);
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
Assert.True(Guid.TryParse(info.Payload!["runId"], out _));
f.Tracker.ScriptRuns.NotePassCompleted(ClockNow.AddMinutes(1));
Assert.Equal(PlatformRunClaim.Claimed, f.Tracker.ScriptRuns.TryClaim(DeviceUser, PolicyId, ClockNow.AddSeconds(5), out var run));
Assert.Equal(info.Payload["runId"], run!.RunId);
Assert.Equal(0, run.ExitCode);
Assert.Equal("Success", run.Result);
}
[Theory]
[InlineData("[PowerShell] User Id = 00000000-0000-0000-0000-000000000000, Policy id = d94468af-6ebe-4e94-9901-2a62d33ea6c1, policy result = Success", "00000000-0000-0000-0000-000000000000")]
[InlineData(@"Adding argument powershell with value C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\6C3F8A20-1D5E-4B97-8F4A-0E2D6B9C7A13_d94468af-6ebe-4e94-9901-2a62d33ea6c1.ps1", "6c3f8a20-1d5e-4b97-8f4a-0e2d6b9c7a13")]
[InlineData(@"Script file C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\00000000-0000-0000-0000-000000000000_d94468af-6ebe-4e94-9901-2a62d33ea6c1.ps1 is generated", "00000000-0000-0000-0000-000000000000")]
[InlineData(@"Adding argument powershellDetection with value C:\Program Files (x86)\Microsoft Intune Management Extension\Content\DetectionScripts\8b2e4c71-6a9d-4f03-b5e8-2c7a1d9f4e36_2.ps1", null)]
[InlineData("[PowerShell] User Id = not-a-guid, Policy id = d94468af", null)]
[InlineData("", null)]
public void Ime_user_id_is_read_from_the_result_line_and_the_script_path(string text, string? expected)
{
Assert.Equal(expected, ImeLogTracker.ImeUserIdFrom(text));
}
}
}
@@ -271,14 +271,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
else
{
HandlePlatformScriptStarted(id, source);
HandlePlatformScriptStarted(id, source, ImeUserIdFrom(match.Value));
}
}
/// <summary>
/// The IME user id on a platform-script line — <c>User Id = {id}</c> on the result line,
/// <c>…\Policies\Scripts\{id}_{policy}.ps1</c> on the start lines — or null. Read from the
/// matched text, not from a capture group, so it works with every pattern pack the agent may
/// be served.
/// </summary>
internal static string ImeUserIdFrom(string text)
{
if (string.IsNullOrEmpty(text)) return null;
const string resultTag = "User Id = ";
const string pathTag = @"\Policies\Scripts\";
var i = text.IndexOf(resultTag, StringComparison.OrdinalIgnoreCase);
if (i >= 0) return GuidAt(text, i + resultTag.Length);
i = text.IndexOf(pathTag, StringComparison.OrdinalIgnoreCase);
return i >= 0 ? GuidAt(text, i + pathTag.Length) : null;
}
private static string GuidAt(string text, int start)
{
if (start + 36 > text.Length) return null;
var candidate = text.Substring(start, 36);
return Guid.TryParse(candidate, out _) ? candidate.ToLowerInvariant() : null;
}
// Platform script: AgentExecutor.log entries create/enrich, IME log entries also create.
// Internal so the stale-slot hardening is directly testable (the public path arrives via
// a regex Match).
internal void HandlePlatformScriptStarted(string id, string source = null)
internal void HandlePlatformScriptStarted(string id, string source = null, string userId = null)
{
var currentStartTs = LastMatchedLogTimestamp ?? UtcNowProvider();
var currentStartProvenance = CaptureLastMatchedProvenance();
@@ -327,6 +351,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
pendingSlot.StartedAtProvenance = currentStartProvenance;
_stateDirty = true;
}
if (pendingSlot.UserId == null && userId != null) pendingSlot.UserId = userId;
}
if (!_pendingPlatformScripts.ContainsKey(id))
{
@@ -340,6 +365,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
PolicyId = id,
ScriptType = "platform",
UserId = userId,
// Prefer the source CMTrace timestamp so a script that started before the
// agent launched (replayed log content) is dated to its real start, not now.
// Set only at slot creation: the start line fires twice (agentexecutor + ime
@@ -347,6 +373,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
StartedAtUtc = currentStartTs,
StartedAtProvenance = currentStartProvenance,
};
ScriptRuns.NoteStarted(userId, id, UtcNowProvider());
// Live "running" indicator — same signal health scripts emit. Gated on slot
// creation so the duplicate start line (agentexecutor + ime source) doesn't
// double-emit for the same execution.
@@ -439,6 +466,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
ApplyExitCode(script, exitCode, LastMatchedLogTimestamp ?? UtcNowProvider(), CaptureLastMatchedProvenance());
else if (ownerPolicyId != null)
_logger.Debug($"ImeLogTracker: exit code {exitCode} belongs to platform script {ownerPolicyId}, whose completion was already emitted — dropped");
else
LogLineBehindWriterBoundary($"exit code {exitCode}");
// An exit code means the executor just wrote its end block and IME is about to
// write the result — both land at a stale stream position when another process
// appended meanwhile. Check the ledgers on the next pass.
@@ -465,7 +494,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null;
var script = GetCurrentScriptForLineUpdate(parameters);
if (script == null) return;
if (script == null)
{
LogLineBehindWriterBoundary($"script output ({outputType ?? "combined"})");
return;
}
// PS-AGENT-OUTPUT captures both stdout and stderr in one pattern
var output = match.Groups["output"]?.Value;
@@ -507,7 +540,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
if (string.IsNullOrEmpty(id)) return;
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId, CaptureLastMatchedProvenance());
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId, CaptureLastMatchedProvenance(), ImeUserIdFrom(match.Value));
}
/// <summary>
@@ -521,7 +554,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// line reopened the slot, produced a fallback duplicate (session e7f3c910).
/// <see cref="FlushPendingPlatformScriptResults"/> emits the held completion.
/// </summary>
internal void CompletePlatformScriptFromImeResult(string policyId, string result, DateTime? resultLineTimestampUtc, string patternId, CmTraceLineProvenance resultProvenance)
internal void CompletePlatformScriptFromImeResult(string policyId, string result, DateTime? resultLineTimestampUtc, string patternId, CmTraceLineProvenance resultProvenance, string userId = null)
{
// Dedup: the deadline-based fallback (FlushPendingPlatformScriptResults) may already have
// emitted this script from its AgentExecutor exit code because IME's authoritative
@@ -558,11 +591,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
script = new ScriptExecutionState
{
PolicyId = policyId,
ScriptType = "platform"
ScriptType = "platform",
UserId = userId,
};
_pendingPlatformScripts[policyId] = script;
ScriptRuns.NoteStarted(userId, policyId, UtcNowProvider());
}
if (script.UserId == null && userId != null) script.UserId = userId;
script.Result = result;
script.ResultSource = "ime_policy_result";
script.ResultObservedAtUtc = resultLineTimestampUtc ?? UtcNowProvider();
@@ -591,7 +627,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private void EmitPlatformScriptResult(ScriptExecutionState script, string note = null)
{
_logger.Info($"ImeLogTracker: platform script completed: {script.PolicyId}, result={script.Result}, exit={script.ExitCode}{(note != null ? $" ({note})" : string.Empty)}");
if (string.IsNullOrEmpty(script.RunId)) script.RunId = Guid.NewGuid().ToString("D");
EmitScriptEvent(script);
ScriptRuns.NoteEmitted(script, UtcNowProvider());
// The run's source stamp, capped at the clock: a result line cannot be read before it
// was written, so a stamp ahead of the clock is a wrong zone assumption on that line
// (a rewound result resolved in the reader's zone, session 4377911b: +1 h). Left
@@ -1163,11 +1201,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
internal static readonly TimeSpan HeldScriptStartGrace = TimeSpan.FromMinutes(30);
internal static readonly TimeSpan HeldScriptStartShutdownGrace = TimeSpan.FromMinutes(5);
private static string TruncateOutput(string output)
internal static string TruncateOutput(string output)
{
if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength)
return output;
return output.Substring(0, MaxScriptOutputLength) + "...[truncated]";
return output.Substring(0, MaxScriptOutputLength) + OutputTruncationMarker;
}
// -----------------------------------------------------------------------
@@ -123,16 +123,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
var ledger = IsMultiWriterLogFile(fileName) ? GetLedger(fileName) : null;
// Set by a rewind: entries that still read unchanged behind the old bookmark are
// skipped, and nothing below the old bookmark is fresh or a calibration anchor.
HashSet<LedgerEntry> rewoundEntries = null;
// skipped (their invocation markers are given back), and nothing below the old
// bookmark is fresh or a calibration anchor.
RewindState rewind = null;
long oldBookmark = -1;
var writerBoundaries = IsWriterBoundaryLogFile(fileName);
if (ledger != null)
{
var rewindTo = await PrepareLedgerAsync(filePath, fileName, ledger, startPos, passNowUtc, token);
if (rewindTo >= 0)
{
rewoundEntries = ApplyRewind(filePath, fileName, ledger, rewindTo, startPos);
rewind = ApplyRewind(filePath, fileName, ledger, rewindTo, startPos);
oldBookmark = startPos;
startPos = rewindTo;
}
@@ -164,7 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
// Guard zone: the file grew and is open anyway — re-check the entries just behind
// the bookmark, where a stale writer's block lands when it is short.
if (ledger != null && rewoundEntries == null && ledger.Entries.Count > 0)
if (ledger != null && rewind == null && ledger.Entries.Count > 0)
{
var guardFrom = ledger.OffsetOfEntryAtOrAfter(startPos - OverwriteGuardBytes);
if (guardFrom >= 0 && guardFrom < startPos)
@@ -178,7 +180,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_verifiedBytes += startPos - ledger.StartOffset;
_verifyPasses++;
if (earliest >= 0) divergence = earliest;
rewoundEntries = ApplyRewind(filePath, fileName, ledger, divergence, startPos);
rewind = ApplyRewind(filePath, fileName, ledger, divergence, startPos);
oldBookmark = startPos;
startPos = divergence;
}
@@ -259,7 +261,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// A fragment right at the bookmark of a multi-writer file (the previous
// entry ended exactly here, this one does not open an entry) is the tail of
// a block a stale writer laid over already-read bytes: verify before reading on.
if (ledger != null && rewoundEntries == null && entry.Offset == ledger.EndOffset
if (ledger != null && rewind == null && entry.Offset == ledger.EndOffset
&& ledger.LastFragmentOffset != entry.Offset && !entry.Text.StartsWith("<![LOG["))
{
ledger.VerifyRequested = true;
@@ -270,8 +272,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
if (ledger != null) ledger.Record(entry);
if (rewoundEntries != null && rewoundEntries.Contains(new LedgerEntry { Offset = entry.Offset, Hash = entry.Hash }))
continue; // unchanged behind the old bookmark — processed before the rewind
var unchanged = rewind != null && rewind.IsUnchanged(entry);
if (unchanged) RestoreInvocationMarkers(rewind, entry.Offset);
// Writer boundary (see ImeLogTracker.Overwrite.cs): an AgentExecutor.log entry that is
// not exactly one CMTrace record ends the bytes of the executor in front of it. Never
// matched — its text is the cut of two writers. Evaluated for unchanged entries too:
// what owns the position in front of them may have changed with the rewind.
if (writerBoundaries && !IsSingleCmTraceRecord(entry.Text))
{
CloseAtWriterBoundary(fileName, entry.Offset, entry.Text);
continue;
}
if (unchanged) continue; // unchanged behind the old bookmark — processed before the rewind
_currentPassLinesAreFresh = passLinesAreFresh && entry.Offset >= oldBookmark;
@@ -293,13 +293,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
}
/// <summary>Moves the bookmark back to the first changed entry; returns the entries to skip if they still read unchanged behind it.</summary>
private HashSet<LedgerEntry> ApplyRewind(string filePath, string fileName, FileLedger ledger, long rewindTo, long bookmark)
/// <summary>
/// What one rewind pass carries: the entries to skip if they still read unchanged behind the rewind point, and
/// the invocation markers set aside there until their entries prove unchanged. Pass-local by design — whatever
/// is not given back dies with the pass on every exit path.
/// </summary>
private sealed class RewindState
{
public RewindState(string fileName, HashSet<LedgerEntry> unchanged, List<InvocationMarker> markers, InvocationMarker lastPlatformMarker)
{
FileName = fileName;
Unchanged = unchanged;
Markers = markers;
LastPlatformMarker = lastPlatformMarker;
}
public string FileName { get; }
public HashSet<LedgerEntry> Unchanged { get; }
/// <summary>Set aside at the rewind, ascending by offset.</summary>
public List<InvocationMarker> Markers { get; }
/// <summary>The tracker-wide last platform marker, when it was among the set-aside ones.</summary>
public InvocationMarker LastPlatformMarker { get; }
public int Next { get; set; }
public bool IsUnchanged(AssembledEntry entry) => Unchanged.Contains(new LedgerEntry { Offset = entry.Offset, Hash = entry.Hash });
}
/// <summary>Moves the bookmark back to the first changed entry; returns what the re-read needs to skip and to give back.</summary>
private RewindState ApplyRewind(string filePath, string fileName, FileLedger ledger, long rewindTo, long bookmark)
{
var removed = ledger.TruncateFrom(rewindTo);
_positionTracker.SetPosition(filePath, rewindTo);
_heldTails.Remove(filePath);
InvalidateInvocationMarkersFrom(fileName, rewindTo);
var lastPlatformMarker = _lastPlatformMarker;
var markers = TakeInvocationMarkersFrom(fileName, rewindTo, out var tookLastPlatformMarker);
_overwriteRewinds++;
_overwriteBytesReprocessed += bookmark - rewindTo;
@@ -314,7 +341,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
_logger.Debug(message);
}
return removed;
return new RewindState(fileName, removed, markers, tookLastPlatformMarker ? lastPlatformMarker : null);
}
private long RetentionFloor(string fileName, long bookmark)
@@ -331,20 +358,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// -----------------------------------------------------------------------
//
// AgentExecutor.log carries "Powershell exit code is N" / "write output done" without a
// policy id. Their owner is decided by FILE POSITION, not by the order lines were read:
// an executor writes its end block at its own stream position, i.e. directly after its
// own start block in the final bytes, so the nearest preceding invocation marker names
// it — and stays right after a rewind re-reads that region. A marker is a platform start
// (policy id), some other invocation (detection, remediation, requirement: null) or a
// close ("gets invoked" banner, "Agent executor completed").
// policy id. Their owner is decided by FILE POSITION: the nearest preceding marker — a
// platform start (policy id), some other invocation (detection, remediation, requirement:
// null), a close ("gets invoked" banner, "Agent executor completed") or a writer boundary.
//
// Every executor writes its lines contiguously from its own stream position, so its end
// block follows its own start block — but that start block may be gone. Two executors that
// open the file at the same end write their start blocks over each other, and the end block
// of the one that lost then sits behind the OTHER executor's start block (session 756970cf:
// a detection script's exit code and output on a platform script). Where one writer's bytes
// meet another's, the cut leaves an entry that is not exactly one CMTrace record — a
// fragment, a merged line or an empty line — unless the line boundaries of both writers
// coincide byte-exactly. That writer boundary closes the platform invocation in front of it.
// AgentExecutor.log only: in the IME log the service writes the markers and the lines they
// own itself, and its fragments are executor telemetry lines.
//
// A marker lives exactly as long as the bytes of its entry: a rewind sets the markers from
// the rewind point on aside, and an entry that reads unchanged gets its markers back.
private sealed class InvocationMarker
{
public long Offset;
public string PolicyId;
public bool IsClose;
/// <summary>Set on a writer boundary: the platform invocation it closed.</summary>
public string ClosedPolicyId;
}
private const string ExecutorLogFileName = "AgentExecutor.log";
private readonly Dictionary<string, List<InvocationMarker>> _invocationMarkers =
new Dictionary<string, List<InvocationMarker>>(StringComparer.OrdinalIgnoreCase);
private InvocationMarker _lastPlatformMarker;
@@ -354,22 +396,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private readonly HashSet<string> _platformMarkerFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private long _testEntryOrdinal;
private void RecordInvocationMarker(string policyId, bool isClose)
/// <summary>
/// The executor log, live or rotated (<c>AgentExecutor-yyyyMMdd-HHmmss.log</c>): its invocations are one process
/// each, so a writer boundary ends an invocation — in the final bytes of a rotation as much as in the live file.
/// </summary>
internal static bool IsWriterBoundaryLogFile(string fileName)
=> string.Equals(fileName, ExecutorLogFileName, StringComparison.OrdinalIgnoreCase)
|| (fileName != null
&& fileName.StartsWith("AgentExecutor-", StringComparison.OrdinalIgnoreCase)
&& fileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// True when the entry is exactly one CMTrace record: it opens with <c>&lt;![LOG[</c> (culture-sensitive like
/// <c>CmTraceLogParser.TryParseLine</c>, so a BOM is ignorable) and holds no second one — the parser takes the
/// message up to the LAST trailer, so a line cut anywhere, its trailer included, would swallow the record
/// written over it. An empty entry is the terminator of a cut line. A script whose own output contains the
/// open tag loses that output line: the bytes cannot tell it from a merged line.
/// </summary>
internal static bool IsSingleCmTraceRecord(string text)
{
if (string.IsNullOrEmpty(text) || !text.StartsWith(LogOpenTag)) return false;
var first = text.IndexOf(LogOpenTag, StringComparison.Ordinal);
return first >= 0 && text.IndexOf(LogOpenTag, first + LogOpenTag.Length, StringComparison.Ordinal) < 0;
}
private List<InvocationMarker> GetOrCreateMarkerList(string file)
{
if (_currentEntryOffset < 0) return;
var file = _currentSourceFileName ?? TestSourceFileName;
List<InvocationMarker> list;
if (!_invocationMarkers.TryGetValue(file, out list))
{
list = new List<InvocationMarker>();
_invocationMarkers[file] = list;
}
return list;
}
private static void AppendInvocationMarker(List<InvocationMarker> list, InvocationMarker marker)
{
if (list.Count >= MaxInvocationMarkersPerFile) list.RemoveAt(0);
list.Add(marker);
}
private void RecordInvocationMarker(string policyId, bool isClose)
{
if (_currentEntryOffset < 0) return;
var file = _currentSourceFileName ?? TestSourceFileName;
var list = GetOrCreateMarkerList(file);
if (list.Count > 0 && list[list.Count - 1].Offset == _currentEntryOffset)
{
// Two patterns on one line: the platform start outranks the generic argument line.
// A writer boundary is never upgraded.
var last = list[list.Count - 1];
if (!isClose && policyId != null)
if (!isClose && policyId != null && last.ClosedPolicyId == null)
{
last.PolicyId = policyId;
last.IsClose = false;
@@ -379,9 +458,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
return;
}
if (list.Count >= MaxInvocationMarkersPerFile) list.RemoveAt(0);
var marker = new InvocationMarker { Offset = _currentEntryOffset, PolicyId = policyId, IsClose = isClose };
list.Add(marker);
AppendInvocationMarker(list, marker);
if (!isClose && policyId != null)
{
_lastPlatformMarker = marker;
@@ -390,21 +468,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
/// <summary>
/// The pending platform script that owns the entry being processed, or null.
/// <paramref name="ownerPolicyId"/> names the owning platform script even when its slot is
/// gone (completion already emitted); null when no platform invocation owns the entry.
/// An entry of <paramref name="fileName"/> at <paramref name="offset"/> that is not exactly one CMTrace record:
/// the bytes behind it were written by another process than the bytes in front of it. Closes the platform
/// invocation that owns the position; records nothing when none is open there (a cut multi-line output yields
/// one fragment per line). Idempotent at the same offset.
/// </summary>
private ScriptExecutionState ResolvePlatformScriptForCurrentEntry(out string ownerPolicyId)
private void CloseAtWriterBoundary(string fileName, long offset, string text)
{
var owner = FindOwningMarker(fileName, offset);
if (owner == null || owner.IsClose || owner.PolicyId == null) return;
var list = GetOrCreateMarkerList(fileName);
if (list.Count > 0 && list[list.Count - 1].Offset >= offset) return;
AppendInvocationMarker(list, new InvocationMarker { Offset = offset, IsClose = true, ClosedPolicyId = owner.PolicyId });
var kind = string.IsNullOrEmpty(text) ? "empty line" : text.StartsWith(LogOpenTag) ? "merged line" : "fragment";
_logger.Debug($"ImeLogTracker: writer boundary in {fileName} at offset {offset} ({kind}, {text?.Length ?? 0} chars) closes platform script {owner.PolicyId} (opened at {owner.Offset}) — the bytes behind it belong to another executor");
}
/// <summary>The marker that owns <paramref name="offset"/> in <paramref name="file"/>, or null.</summary>
private InvocationMarker FindOwningMarker(string file, long offset)
{
ownerPolicyId = null;
var file = _currentSourceFileName ?? TestSourceFileName;
InvocationMarker marker = null;
List<InvocationMarker> list = null;
if (_currentEntryOffset >= 0 && _invocationMarkers.TryGetValue(file, out list))
if (offset >= 0 && _invocationMarkers.TryGetValue(file, out list))
{
for (var i = list.Count - 1; i >= 0; i--)
{
if (list[i].Offset <= _currentEntryOffset)
if (list[i].Offset <= offset)
{
marker = list[i];
break;
@@ -420,6 +511,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// (session 24dc69d1: the health-script worker's exit line pre-filled a platform slot's
// exit code, its launch line the slot's context).
if (marker == null && (list == null || list.Count == 0) && _platformMarkerFiles.Contains(file)) marker = _lastPlatformMarker;
return marker;
}
/// <summary>
/// The pending platform script that owns the entry being processed, or null.
/// <paramref name="ownerPolicyId"/> names the owning platform script even when its slot is
/// gone (completion already emitted); null when no platform invocation owns the entry.
/// </summary>
private ScriptExecutionState ResolvePlatformScriptForCurrentEntry(out string ownerPolicyId)
{
ownerPolicyId = null;
var marker = FindOwningMarker(_currentSourceFileName ?? TestSourceFileName, _currentEntryOffset);
if (marker == null || marker.IsClose || marker.PolicyId == null) return null;
ownerPolicyId = marker.PolicyId;
@@ -427,17 +530,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
return _pendingPlatformScripts.TryGetValue(marker.PolicyId, out state) ? state : null;
}
private void InvalidateInvocationMarkersFrom(string fileName, long offset)
/// <summary>Debug trace for an executor line that a writer boundary kept away from a platform script.</summary>
private void LogLineBehindWriterBoundary(string what)
{
var marker = FindOwningMarker(_currentSourceFileName ?? TestSourceFileName, _currentEntryOffset);
if (marker?.ClosedPolicyId == null) return;
_logger.Debug($"ImeLogTracker: {what} at offset {_currentEntryOffset} lies behind the writer boundary at {marker.Offset} that closed platform script {marker.ClosedPolicyId} — another executor's end block, not attributed");
}
/// <summary>Removes the markers from <paramref name="offset"/> on and returns them in offset order.</summary>
private List<InvocationMarker> TakeInvocationMarkersFrom(string fileName, long offset, out bool tookLastPlatformMarker)
{
tookLastPlatformMarker = false;
var taken = new List<InvocationMarker>();
List<InvocationMarker> list;
if (!_invocationMarkers.TryGetValue(fileName, out list) || list.Count == 0) return;
var removedLast = false;
for (var i = list.Count - 1; i >= 0 && list[i].Offset >= offset; i--)
if (!_invocationMarkers.TryGetValue(fileName, out list) || list.Count == 0) return taken;
var from = list.Count;
while (from > 0 && list[from - 1].Offset >= offset) from--;
for (var i = from; i < list.Count; i++)
{
if (ReferenceEquals(list[i], _lastPlatformMarker)) removedLast = true;
list.RemoveAt(i);
if (ReferenceEquals(list[i], _lastPlatformMarker)) tookLastPlatformMarker = true;
taken.Add(list[i]);
}
if (removedLast)
list.RemoveRange(from, list.Count - from);
if (tookLastPlatformMarker)
{
_lastPlatformMarker = null;
for (var i = list.Count - 1; i >= 0; i--)
@@ -449,6 +567,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
}
}
return taken;
}
private void InvalidateInvocationMarkersFrom(string fileName, long offset) => TakeInvocationMarkersFrom(fileName, offset, out _);
/// <summary>
/// An entry that reads unchanged after a rewind keeps its markers: gives back the set-aside markers at exactly
/// <paramref name="entryOffset"/>. Set-aside markers in front of it belonged to entries that changed and stay
/// gone with their bytes.
/// </summary>
private void RestoreInvocationMarkers(RewindState rewind, long entryOffset)
{
var markers = rewind.Markers;
while (rewind.Next < markers.Count && markers[rewind.Next].Offset < entryOffset) rewind.Next++;
while (rewind.Next < markers.Count && markers[rewind.Next].Offset == entryOffset)
{
var marker = markers[rewind.Next++];
var list = GetOrCreateMarkerList(rewind.FileName);
if (list.Count > 0 && list[list.Count - 1].Offset >= marker.Offset) continue;
AppendInvocationMarker(list, marker);
if (ReferenceEquals(marker, rewind.LastPlatformMarker)) _lastPlatformMarker = marker;
}
}
private long OldestPendingScriptMarkerOffset(string fileName)
@@ -474,6 +614,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// <summary>What a rollover of the file does to its invocation markers.</summary>
internal void ClearInvocationMarkersForTest(string fileName) => InvalidateInvocationMarkersFrom(fileName, 0);
internal int InvocationMarkerCountForTest(string fileName)
=> _invocationMarkers.TryGetValue(fileName, out var list) ? list.Count : 0;
internal int LedgerEntryCountForTest(string fileName)
{
FileLedger ledger;
@@ -182,6 +182,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private readonly Dictionary<string, ScriptExecutionState> _pendingPlatformScripts =
new Dictionary<string, ScriptExecutionState>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The platform runs this tracker started and emitted, for the registry reconciliation that
/// checks them against IME's saved results (D-316). Shared with the registry host's thread.
/// </summary>
internal PlatformScriptRunRegister ScriptRuns { get; } = new PlatformScriptRunRegister();
// Health-script (remediation) line-by-line accumulator — single slot because IME
// executes health scripts SEQUENTIALLY within a session (verified across multiple
// diagnostic captures: ProcessScript → context → exit → stdout → stderr → compliance,
@@ -240,7 +246,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// the 32K-char Table Storage property limit that bounds the event downstream. Growth
// driver is the number of log lines the bootstrap chain writes, so re-measure before
// adding a chatty step rather than raising this again.
private const int MaxScriptOutputLength = 8192;
internal const int MaxScriptOutputLength = 8192;
internal const string OutputTruncationMarker = "...[truncated]";
private const int MaxMultiLineBufferLines = 100;
// Size cap for ONE entry — a single physical line (enforced by BoundedLineReader before
// the line is materialized) and an assembled multiline entry alike. Any process able to
@@ -389,6 +396,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
VerifiedBytes = _verifiedBytes,
PassMaxMs = _passMaxMs,
PassGapMaxMs = _passGapMaxMs,
PlatformOutputVerified = ScriptRuns.Verified,
PlatformOutputCorrected = ScriptRuns.Corrected,
PatternHits = new Dictionary<string, int>(_patternHits, StringComparer.OrdinalIgnoreCase),
};
}
@@ -1034,6 +1043,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
internal async Task RunPollPassAsync(CancellationToken token)
{
var startMs = MonotonicMillisProvider();
var passStartedUtc = UtcNowProvider();
if (_lastPassEndMs >= 0 && startMs - _lastPassEndMs > _passGapMaxMs)
{
_passGapMaxMs = startMs - _lastPassEndMs;
@@ -1045,6 +1055,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
FlushRecurringScripts(UtcNowProvider(), shuttingDown: false);
CheckPendingTokenFailure(UtcNowProvider());
// The lines written before this pass started are read now — a held tail or a fragment
// awaiting its check follows a pass or two later, which TrackerCatchUp covers. The
// registry reconciliation waits for such a pass before it pairs an IME save with a run.
if (!token.IsCancellationRequested) ScriptRuns.NotePassCompleted(passStartedUtc);
if (ScriptRuns.ConsumeCountersChanged()) _stateDirty = true;
var endMs = MonotonicMillisProvider();
if (endMs - startMs > _passMaxMs)
{
@@ -1207,6 +1223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_verifiedBytes = state.Health.VerifiedBytes;
_passMaxMs = state.Health.PassMaxMs;
_passGapMaxMs = state.Health.PassGapMaxMs;
ScriptRuns.RestoreCounters(state.Health.PlatformOutputVerified, state.Health.PlatformOutputCorrected);
}
if (state.PatternHits != null)
{
@@ -1283,6 +1300,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
VerifiedBytes = _verifiedBytes,
PassMaxMs = _passMaxMs,
PassGapMaxMs = _passGapMaxMs,
PlatformOutputVerified = ScriptRuns.Verified,
PlatformOutputCorrected = ScriptRuns.Corrected,
};
state.PatternHits = new Dictionary<string, int>(_patternHits, StringComparer.OrdinalIgnoreCase);
}
@@ -81,6 +81,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// </summary>
public long PassGapMaxMs { get; set; }
/// <summary>
/// Platform-script runs whose emitted output IME's saved result (registry, after the batch
/// report) confirmed — the exit code of the same end block with it. Together with
/// <see cref="PlatformOutputCorrected"/> the field measure of the registry reconciliation (D-316).
/// </summary>
public long PlatformOutputVerified { get; set; }
/// <summary>Platform-script runs whose emitted output IME's saved result corrected (script_output_reconciliation).</summary>
public long PlatformOutputCorrected { get; set; }
/// <summary>Match count per enabled pattern ID — every enabled pattern is present, zeros included.</summary>
public IReadOnlyDictionary<string, int> PatternHits { get; set; }
@@ -185,6 +185,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// Poll-loop timing maxima (null-defaulting on older state files).
public long PassMaxMs { get; set; }
public long PassGapMaxMs { get; set; }
// Registry reconciliation of platform-script output (null-defaulting on older state files).
public long PlatformOutputVerified { get; set; }
public long PlatformOutputCorrected { get; set; }
}
public class PackageStateData
@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>What the registry reconciliation may do with a saved IME result right now.</summary>
internal enum PlatformRunClaim
{
/// <summary>The run is handed out for reconciliation; it is never handed out again.</summary>
Claimed,
/// <summary>Not decidable yet: the tracker has not read past the save, or the run is not emitted yet.</summary>
Wait,
/// <summary>No run of this agent owns the save (none seen, or already reconciled).</summary>
None,
}
/// <summary>One platform-script run as the tracker emitted it; the register hands out copies.</summary>
internal sealed class PlatformScriptRun
{
public string PolicyId;
public string UserId;
public string RunId;
public DateTime StartSeenUtc;
public bool Emitted;
public bool Claimed;
public string Result;
public string ResultSource;
public int? ExitCode;
/// <summary>As emitted: null when no output line of the run was read, empty when it said so.</summary>
public string Stdout;
public string Stderr;
public PlatformScriptRun Clone() => (PlatformScriptRun)MemberwiseClone();
}
/// <summary>
/// The platform-script runs the tracker has seen, the latest per (IME user id, policy id), for the
/// registry reconciliation (D-316). IME saves a run's output under
/// <c>Policies\&lt;UserId&gt;\&lt;PolicyId&gt;</c> only after it reported the whole script batch, so the
/// observer reading it must know which run a changed key belongs to and what was emitted for it.
/// Written on the tracker's poll thread, claimed on the registry host's timer thread: every member
/// locks, and nothing calls out while holding the lock.
/// <para>
/// A save belongs to the latest run of its key: IME runs a policy once per batch and saves the
/// batch after its last script, so the next run of the same key starts minutes later at the
/// earliest. A save is paired only after the tracker completed a pass that started
/// <see cref="TrackerCatchUp"/> after the save was seen — every line written before the save, the
/// run's start among them, has been read by then, a stalled poll loop included.
/// </para>
/// </summary>
internal sealed class PlatformScriptRunRegister
{
/// <summary>
/// How far behind a seen save the tracker must have started a completed pass before the save
/// is paired; covers the ledger's verify cadence (1 s) for a start line hidden by an overwrite.
/// </summary>
internal static readonly TimeSpan TrackerCatchUp = TimeSpan.FromSeconds(2);
internal const int MaxRuns = 256;
private readonly object _sync = new object();
private readonly Dictionary<string, PlatformScriptRun> _latest =
new Dictionary<string, PlatformScriptRun>(StringComparer.OrdinalIgnoreCase);
private DateTime _lastCompletedPassStartUtc = DateTime.MinValue;
private long _verified;
private long _corrected;
private bool _countersChanged;
private static string KeyOf(string userId, string policyId) => (userId ?? string.Empty) + "|" + policyId;
/// <summary>A platform run began: its start line, or its result when no start line was seen.</summary>
public void NoteStarted(string userId, string policyId, DateTime seenUtc)
{
if (string.IsNullOrEmpty(policyId)) return;
lock (_sync)
{
_latest[KeyOf(userId, policyId)] = new PlatformScriptRun { PolicyId = policyId, UserId = userId, StartSeenUtc = seenUtc };
TrimLocked();
}
}
/// <summary>The tracker emitted the run with these end-block values.</summary>
public void NoteEmitted(ScriptExecutionState script, DateTime emittedUtc)
{
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
lock (_sync)
{
var key = KeyOf(script.UserId, script.PolicyId);
if (!_latest.TryGetValue(key, out var run) || run.Emitted)
{
run = new PlatformScriptRun { PolicyId = script.PolicyId, UserId = script.UserId, StartSeenUtc = emittedUtc };
_latest[key] = run;
}
run.Emitted = true;
run.RunId = script.RunId;
run.Result = script.Result;
run.ResultSource = script.ResultSource;
run.ExitCode = script.ExitCode;
run.Stdout = script.Stdout;
run.Stderr = script.Stderr;
TrimLocked();
}
}
/// <summary>The tracker finished a poll pass that started at <paramref name="passStartedUtc"/>.</summary>
public void NotePassCompleted(DateTime passStartedUtc)
{
lock (_sync)
{
if (passStartedUtc > _lastCompletedPassStartUtc) _lastCompletedPassStartUtc = passStartedUtc;
}
}
/// <summary>
/// Hands out the run a save of (<paramref name="userId"/>, <paramref name="policyId"/>) seen at
/// <paramref name="savedSeenUtc"/> belongs to — once. A run whose user id the tracker could not
/// read is keyed without one.
/// </summary>
public PlatformRunClaim TryClaim(string userId, string policyId, DateTime savedSeenUtc, out PlatformScriptRun run)
{
run = null;
lock (_sync)
{
if (_lastCompletedPassStartUtc < savedSeenUtc + TrackerCatchUp) return PlatformRunClaim.Wait;
if (!_latest.TryGetValue(KeyOf(userId, policyId), out var latest)
&& !_latest.TryGetValue(KeyOf(null, policyId), out latest))
return PlatformRunClaim.None;
if (latest.Claimed) return PlatformRunClaim.None;
if (!latest.Emitted) return PlatformRunClaim.Wait;
latest.Claimed = true;
run = latest.Clone();
return PlatformRunClaim.Claimed;
}
}
public void CountVerified()
{
lock (_sync) { _verified++; _countersChanged = true; }
}
public void CountCorrected()
{
lock (_sync) { _corrected++; _countersChanged = true; }
}
/// <summary>Runs whose emitted output IME's saved result confirmed (restart-safe, session-cumulative).</summary>
public long Verified { get { lock (_sync) return _verified; } }
/// <summary>Runs whose emitted output IME's saved result corrected (restart-safe, session-cumulative).</summary>
public long Corrected { get { lock (_sync) return _corrected; } }
public void RestoreCounters(long verified, long corrected)
{
lock (_sync)
{
_verified = verified;
_corrected = corrected;
}
}
/// <summary>True once after a counter moved — the tracker then persists its state.</summary>
public bool ConsumeCountersChanged()
{
lock (_sync)
{
var changed = _countersChanged;
_countersChanged = false;
return changed;
}
}
private void TrimLocked()
{
while (_latest.Count > MaxRuns)
{
string oldestKey = null;
var oldest = DateTime.MaxValue;
foreach (var kv in _latest)
{
if (kv.Value.StartSeenUtc < oldest)
{
oldest = kv.Value.StartSeenUtc;
oldestKey = kv.Key;
}
}
if (oldestKey == null) return;
_latest.Remove(oldestKey);
}
}
}
}
@@ -15,6 +15,19 @@ public class ScriptExecutionState
/// <summary>"platform" or "remediation".</summary>
public string ScriptType { get; set; }
/// <summary>
/// IME user id a platform script ran for (the all-zero GUID in device context), read from its
/// script path or its result line. Keys IME's saved result
/// (<c>Policies\&lt;UserId&gt;\&lt;PolicyId&gt;</c>) that the registry reconciliation compares against.
/// </summary>
public string UserId { get; set; }
/// <summary>
/// Identity of one emitted platform-script run, surfaced as <c>runId</c> on the event: what a
/// later <c>script_output_reconciliation</c> names when it corrects that run.
/// </summary>
public string RunId { get; set; }
/// <summary>
/// UTC timestamp of the script's first observed start line, taken from the source CMTrace log
/// timestamp (so it dates correctly even when the agent replays historic IME log content that
@@ -0,0 +1,401 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Registry second pillar for platform scripts (D-316). After IME reported a script batch to
/// Intune it saves every run of the batch under
/// <c>HKLM\SOFTWARE\Microsoft\IntuneManagementExtension\Policies\&lt;UserId&gt;\&lt;PolicyId&gt;</c>:
/// <c>Result</c>, <c>ErrorCode</c> and <c>ResultDetails</c>, whose <c>ExecutionMsg</c> is what IME read
/// from the result file of exactly that policy — the stdout when the run succeeded (IME's Success
/// means an empty stderr file, whatever the exit code), the stderr when it failed with ErrorCode 99.
/// The log tracker assigns a run's exit code and output by file position in AgentExecutor.log, where
/// no policy id anchors them; this observer checks what was emitted against the keyed value and
/// emits <c>script_output_reconciliation</c> when they disagree.
/// <para>
/// Snapshot-and-diff like <see cref="ImeRegistryAppStateObserver"/>, ticked by the same host: the
/// first tick is the silent baseline, a later save is paired with its run through
/// <see cref="PlatformScriptRunRegister"/>. No exit code lives in the registry: a run whose output
/// matches proves the exit code of the same end block; a contradicted run loses its exit code.
/// </para>
/// </summary>
internal sealed class ImeRegistryScriptResultObserver
{
public const string SourceLabel = "RegistryScriptResult";
internal const string PoliciesKeyPath = ImeRegistryAppStateObserver.ImeRootKeyPath + @"\Policies";
/// <summary>A save whose run never became claimable is dropped after this long (send failure of a later batch, agent restart).</summary>
internal static readonly TimeSpan PendingExpiry = TimeSpan.FromMinutes(10);
/// <summary>Safety cap on correction events per agent run; corrections beyond it are counted only.</summary>
internal const int MaxCorrectionsPerSession = 100;
private readonly PlatformScriptRunRegister _runs;
private readonly Func<IReadOnlyDictionary<string, SavedScriptResult>> _snapshotSource;
private readonly InformationalEventPost _post;
private readonly AgentLogger? _logger;
private readonly IClock _clock;
private readonly object _sync = new object();
private IReadOnlyDictionary<string, SavedScriptResult>? _last;
private readonly Dictionary<string, (SavedScriptResult Saved, DateTime SeenUtc)> _pending =
new Dictionary<string, (SavedScriptResult, DateTime)>(StringComparer.OrdinalIgnoreCase);
private int _correctionsEmitted;
/// <param name="snapshotSource">Replaces the live registry walk in tests; per instance, so parallel test classes never share it.</param>
public ImeRegistryScriptResultObserver(
PlatformScriptRunRegister runs,
InformationalEventPost post,
AgentLogger? logger,
IClock clock,
Func<IReadOnlyDictionary<string, SavedScriptResult>>? snapshotSource = null)
{
_runs = runs ?? throw new ArgumentNullException(nameof(runs));
_post = post ?? throw new ArgumentNullException(nameof(post));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_logger = logger;
_snapshotSource = snapshotSource ?? ReadSnapshot;
}
/// <summary>One observation pass: snapshot → new saves → pair → judge → correct. Fail-soft: never throws.</summary>
public void Tick(string reason)
{
try
{
lock (_sync)
{
var snapshot = _snapshotSource();
var nowUtc = _clock.UtcNow;
if (_last == null)
{
// Saves that predate this agent run belong to runs it never emitted.
_last = snapshot;
_logger?.Info($"RegistryScriptResult: baseline captured ({snapshot.Count} saved platform-script results, trigger={reason})");
return;
}
foreach (var kv in snapshot)
{
if (_last.TryGetValue(kv.Key, out var previous) && previous.SameSaveAs(kv.Value)) continue;
_pending[kv.Key] = (kv.Value, nowUtc); // a newer save of the key replaces a waiting one
}
_last = snapshot;
ProcessPending(nowUtc);
}
}
catch (Exception ex)
{
_logger?.Debug($"RegistryScriptResult: tick failed (fail-soft): {ex.Message}");
}
}
private void ProcessPending(DateTime nowUtc)
{
foreach (var key in _pending.Keys.ToList())
{
var (saved, seenUtc) = _pending[key];
var claim = _runs.TryClaim(saved.UserId, saved.PolicyId, seenUtc, out var run);
if (claim == PlatformRunClaim.Wait)
{
if (nowUtc - seenUtc >= PendingExpiry) _pending.Remove(key);
continue;
}
_pending.Remove(key);
if (claim == PlatformRunClaim.Claimed && run != null) Reconcile(run, saved, nowUtc);
}
}
private void Reconcile(PlatformScriptRun run, SavedScriptResult saved, DateTime nowUtc)
{
var judgement = PlatformOutputJudge.Judge(run, saved);
switch (judgement.Verdict)
{
case PlatformOutputVerdict.Verified:
_runs.CountVerified();
_logger?.Debug($"RegistryScriptResult: platform script {run.PolicyId} output confirmed by IME's saved result");
return;
case PlatformOutputVerdict.NotComparable:
_logger?.Debug($"RegistryScriptResult: platform script {run.PolicyId} not comparable ({judgement.Reason})");
return;
}
_runs.CountCorrected();
if (_correctionsEmitted >= MaxCorrectionsPerSession)
{
_logger?.Warning($"RegistryScriptResult: correction for {run.PolicyId} not emitted — cap of {MaxCorrectionsPerSession} per agent run reached");
return;
}
_correctionsEmitted++;
var foreign = judgement.Verdict == PlatformOutputVerdict.Foreign;
var culture = CultureInfo.InvariantCulture;
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["policyId"] = run.PolicyId,
["scriptType"] = "platform",
["outcome"] = foreign ? "foreign" : "repaired",
["reason"] = judgement.Reason,
};
if (!string.IsNullOrEmpty(run.RunId)) data["runId"] = run.RunId!;
if (!string.IsNullOrEmpty(saved.Result)) data["result"] = saved.Result!;
if (saved.ErrorCode.HasValue) data["registryErrorCode"] = saved.ErrorCode.Value.ToString(culture);
// A present key replaces the emitted value, an empty one clears it; an absent key keeps it.
if (judgement.Stdout != null) data["stdout"] = judgement.Stdout;
if (judgement.Stderr != null) data["stderr"] = judgement.Stderr;
if (foreign && run.ExitCode.HasValue) data["previousExitCode"] = run.ExitCode.Value.ToString(culture);
var shortId = run.PolicyId.Length > 8 ? run.PolicyId.Substring(0, 8) : run.PolicyId;
var message = foreign
? $"Platform script {shortId}: output corrected from IME's saved result; the log had attributed another script's output, exit code unverified"
: $"Platform script {shortId}: output completed from IME's saved result";
_post.Emit(
eventType: SharedEventTypes.ScriptOutputReconciliation,
source: SourceLabel,
message: message,
severity: foreign ? EventSeverity.Warning : EventSeverity.Info,
immediateUpload: foreign,
data: data,
occurredAtUtc: nowUtc);
if (foreign)
_logger?.Warning($"RegistryScriptResult: platform script {run.PolicyId} run {run.RunId} carried another script's end block ({judgement.Reason}) — corrected from IME's saved result");
else
_logger?.Info($"RegistryScriptResult: platform script {run.PolicyId} run {run.RunId} output completed from IME's saved result ({judgement.Reason})");
}
// ── snapshot ────────────────────────────────────────────────────────────
internal static string KeyOf(string userId, string policyId) => userId + "|" + policyId;
internal static IReadOnlyDictionary<string, SavedScriptResult> ReadSnapshot()
{
var snapshot = new Dictionary<string, SavedScriptResult>(StringComparer.OrdinalIgnoreCase);
using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
using var policies = baseKey.OpenSubKey(PoliciesKeyPath);
if (policies == null) return snapshot;
foreach (var userId in policies.GetSubKeyNames())
{
if (!Guid.TryParse(userId, out _)) continue;
using var userKey = policies.OpenSubKey(userId);
if (userKey == null) continue;
foreach (var policyId in userKey.GetSubKeyNames())
{
if (!Guid.TryParse(policyId, out _)) continue;
using var policyKey = userKey.OpenSubKey(policyId);
if (policyKey == null) continue;
snapshot[KeyOf(userId, policyId)] = new SavedScriptResult
{
UserId = userId,
PolicyId = policyId,
Result = policyKey.GetValue("Result") as string,
ErrorCode = TryReadInt(policyKey.GetValue("ErrorCode")),
ResultDetails = policyKey.GetValue("ResultDetails") as string,
LastUpdatedTimeUtc = policyKey.GetValue("LastUpdatedTimeUtc") as string,
};
}
}
return snapshot;
}
private static int? TryReadInt(object? value)
{
switch (value)
{
case int i: return i;
case long l when l >= int.MinValue && l <= int.MaxValue: return (int)l;
case string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed): return parsed;
default: return null;
}
}
}
/// <summary>One platform-script result as IME saved it under <c>Policies\&lt;UserId&gt;\&lt;PolicyId&gt;</c>.</summary>
internal sealed class SavedScriptResult
{
public string UserId { get; set; } = string.Empty;
public string PolicyId { get; set; } = string.Empty;
public string? Result { get; set; }
public int? ErrorCode { get; set; }
public string? ResultDetails { get; set; }
public string? LastUpdatedTimeUtc { get; set; }
/// <summary>The same save: IME stamps every save with a new <c>LastUpdatedTimeUtc</c>.</summary>
public bool SameSaveAs(SavedScriptResult other)
=> string.Equals(LastUpdatedTimeUtc, other.LastUpdatedTimeUtc, StringComparison.Ordinal)
&& string.Equals(Result, other.Result, StringComparison.Ordinal)
&& ErrorCode == other.ErrorCode
&& string.Equals(ResultDetails, other.ResultDetails, StringComparison.Ordinal);
/// <summary>
/// <c>ExecutionMsg</c> of the CryptoResult JSON in <c>ResultDetails</c> (IME 1.73 and later), or null
/// when the value is not that JSON ("Hash mismatch", a launch failure).
/// </summary>
public string? ExecutionMessage()
{
if (string.IsNullOrWhiteSpace(ResultDetails)) return null;
try
{
using var doc = JsonDocument.Parse(ResultDetails!);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
return doc.RootElement.TryGetProperty("ExecutionMsg", out var msg) && msg.ValueKind == JsonValueKind.String
? msg.GetString()
: null;
}
catch (JsonException)
{
return null;
}
}
}
internal enum PlatformOutputVerdict
{
/// <summary>IME's saved result cannot confirm or contradict the run (no output saved, other failure kind).</summary>
NotComparable,
/// <summary>The emitted output is what IME saved: the end block, its exit code included, is the run's own.</summary>
Verified,
/// <summary>The log delivered the run's output incomplete; IME's saved text completes it, the exit code stands.</summary>
Repaired,
/// <summary>The emitted output contradicts IME's saved result: the end block was another executor's.</summary>
Foreign,
}
internal sealed class PlatformOutputJudgement
{
public PlatformOutputVerdict Verdict { get; set; }
public string Reason { get; set; } = string.Empty;
/// <summary>Value to publish (Repaired/Foreign): null keeps the emitted stdout, empty clears it.</summary>
public string? Stdout { get; set; }
/// <summary>Value to publish (Repaired/Foreign): null keeps the emitted stderr, empty clears it.</summary>
public string? Stderr { get; set; }
}
/// <summary>
/// Compares a platform-script run as the tracker emitted it with IME's saved result. Comparable are
/// a Success (ExecutionMsg = stdout; IME's Success means the stderr file was empty) and a Failed with
/// ErrorCode 99 (ExecutionMsg = stderr). Both texts are compared after CR/LF normalisation and Trim;
/// the tracker's own 8192-character cut is a prefix of the saved text.
/// </summary>
internal static class PlatformOutputJudge
{
internal const int ImeStderrErrorCode = 99;
private const string OutputErrorSeparator = ", error =";
public static PlatformOutputJudgement Judge(PlatformScriptRun run, SavedScriptResult saved)
{
var executionMessage = saved.ExecutionMessage();
if (executionMessage == null) return NotComparable("no_execution_message");
// The run's IME result line and the save come from the same IME run.
if (string.Equals(run.ResultSource, "ime_policy_result", StringComparison.Ordinal)
&& !string.Equals(run.Result, saved.Result, StringComparison.OrdinalIgnoreCase))
return NotComparable("result_differs");
if (string.Equals(saved.Result, "Success", StringComparison.OrdinalIgnoreCase))
return JudgeSuccess(run, Normalize(executionMessage)!);
if (string.Equals(saved.Result, "Failed", StringComparison.OrdinalIgnoreCase) && saved.ErrorCode == ImeStderrErrorCode)
return JudgeStderrFailure(run, Normalize(executionMessage)!);
return NotComparable("no_saved_output");
}
private static PlatformOutputJudgement JudgeSuccess(PlatformScriptRun run, string savedStdout)
{
var stdout = NormalizeEmitted(run.Stdout, out var stdoutCut);
var stderr = Normalize(run.Stderr);
var stderrClean = string.IsNullOrEmpty(stderr);
if (!string.IsNullOrEmpty(stdout) && Agrees(stdout!, stdoutCut, savedStdout))
return stderrClean ? Verified() : Correct(PlatformOutputVerdict.Repaired, "stderr_cleared", savedStdout, string.Empty);
if (stdout != null && stdout.Length == 0 && savedStdout.Length == 0)
return stderrClean ? Verified() : Correct(PlatformOutputVerdict.Foreign, "stderr_on_success", string.Empty, string.Empty);
if (stdout == null)
{
if (!stderrClean) return Correct(PlatformOutputVerdict.Foreign, "stderr_on_success", savedStdout, string.Empty);
return savedStdout.Length > 0
? Correct(PlatformOutputVerdict.Repaired, "stdout_missing", savedStdout, string.Empty)
: NotComparable("nothing_read");
}
// "write output done. output = X, error = Y" splits at the first ", error =" — a stdout that
// contains the separator ends up partly in stderr. The saved text shows the cut right there.
if (stdout.Length > 0 && !stdoutCut && !stderrClean
&& savedStdout.StartsWith(stdout, StringComparison.Ordinal)
&& savedStdout.Substring(stdout.Length).TrimStart().StartsWith(OutputErrorSeparator, StringComparison.Ordinal))
return Correct(PlatformOutputVerdict.Repaired, "stdout_split", savedStdout, string.Empty);
return Correct(PlatformOutputVerdict.Foreign, "stdout_differs", savedStdout, string.Empty);
}
private static PlatformOutputJudgement JudgeStderrFailure(PlatformScriptRun run, string savedStderr)
{
var stderr = NormalizeEmitted(run.Stderr, out var stderrCut);
if (!string.IsNullOrEmpty(stderr) && Agrees(stderr!, stderrCut, savedStderr)) return Verified();
// Nothing of the end block read: complete the stderr, the run's stdout stays as emitted.
if (run.Stderr == null && run.Stdout == null)
return Correct(PlatformOutputVerdict.Repaired, "stderr_missing", null, savedStderr);
// The end block read said something else — an empty stderr included: IME read stderr from this
// run's error file. The block's stdout is the other executor's as well.
return Correct(PlatformOutputVerdict.Foreign, "stderr_differs", string.Empty, savedStderr);
}
private static bool Agrees(string emitted, bool emittedCut, string saved)
=> string.Equals(emitted, saved, StringComparison.Ordinal)
|| (emittedCut && saved.StartsWith(emitted, StringComparison.Ordinal));
/// <summary>CR LF and lone CR to LF, then Trim — what the tracker's line reader and handlers make of a text.</summary>
internal static string? Normalize(string? text)
=> text?.Replace("\r\n", "\n").Replace('\r', '\n').Trim();
private static string? NormalizeEmitted(string? text, out bool cut)
{
cut = false;
if (text == null) return null;
if (text.EndsWith(ImeLogTracker.OutputTruncationMarker, StringComparison.Ordinal))
{
cut = true;
text = text.Substring(0, text.Length - ImeLogTracker.OutputTruncationMarker.Length);
}
return Normalize(text);
}
private static PlatformOutputJudgement Verified()
=> new PlatformOutputJudgement { Verdict = PlatformOutputVerdict.Verified, Reason = "match" };
private static PlatformOutputJudgement NotComparable(string reason)
=> new PlatformOutputJudgement { Verdict = PlatformOutputVerdict.NotComparable, Reason = reason };
private static PlatformOutputJudgement Correct(PlatformOutputVerdict verdict, string reason, string? stdout, string? stderr)
=> new PlatformOutputJudgement
{
Verdict = verdict,
Reason = reason,
// The tracker's own output cap: a corrected value is never longer than an emitted one.
Stdout = stdout == null ? null : ImeLogTracker.TruncateOutput(stdout),
Stderr = stderr == null ? null : ImeLogTracker.TruncateOutput(stderr),
};
}
}
@@ -280,7 +280,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
EventType = Constants.EventTypes.WindowsUpdateServicing,
Severity = severity,
Source = "ServicingWatcher",
Source = Constants.EventSources.ServicingWatcher,
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data,
@@ -340,7 +340,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
EventType = Constants.EventTypes.SystemClockChanged,
Severity = isLargeStep ? EventSeverity.Warning : EventSeverity.Info,
Source = "SystemTimelineWatcher",
Source = Constants.EventSources.SystemTimelineWatcher,
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data,
@@ -467,7 +467,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
EventType = Constants.EventTypes.SystemSleepEpisode,
Severity = EventSeverity.Info,
Source = "SystemTimelineWatcher",
Source = Constants.EventSources.SystemTimelineWatcher,
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data,
@@ -405,7 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
EventType = shape.EventType,
Severity = shape.Severity,
Source = "WindowsUpdateWatcher",
Source = Constants.EventSources.WindowsUpdateWatcher,
Phase = EnrollmentPhase.Unknown,
Message = BuildMessage(eventId, title, updateCount, data),
Data = data,
@@ -707,7 +707,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
TenantId = _tenantId,
EventType = Constants.EventTypes.WindowsUpdateChannelCensus,
Severity = EventSeverity.Debug,
Source = "WindowsUpdateWatcher",
Source = Constants.EventSources.WindowsUpdateWatcher,
Phase = EnrollmentPhase.Unknown,
Message = "Windows Update channel census: OS build changed across restart but no targeted WU events were captured — EventID histogram attached",
Data = data,
@@ -215,6 +215,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
data["ime_verified_bytes"] = health.VerifiedBytes;
data["ime_pass_max_ms"] = health.PassMaxMs;
data["ime_pass_gap_max_ms"] = health.PassGapMaxMs;
data["ime_platform_output_verified"] = health.PlatformOutputVerified;
data["ime_platform_output_corrected"] = health.PlatformOutputCorrected;
}
}
catch (Exception ex)
@@ -524,14 +524,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// EspTrackingWin32Apps, StatusServiceReports) via snapshot-and-diff. Emits
// registry_app_state on real changes and app_state_reconciliation when the
// registry outcome diverges from the IME-log-derived state (= built-in pattern-drift
// alarm). Always-on observability host; the tracker probe is read-only.
// alarm). Always-on observability host; the tracker probe is read-only. Under the same
// root IME saves each platform script's result after reporting the batch: the script
// observer checks the tracker's emitted runs against it (script_output_reconciliation).
hosts.Add(new ImeRegistryAppStateHost(
sessionId: sessionId,
tenantId: tenantId,
logger: logger,
ingress: ingress,
clock: clock,
trackerStateProbe: () => imeLogHost.AllKnownPackageStates));
trackerStateProbe: () => imeLogHost.AllKnownPackageStates,
scriptRuns: imeLogHost.Tracker.ScriptRuns));
// Deterministic update corroboration — compares the persisted OS build (CurrentBuild.UBR)
// across agent restarts and emits os_build_changed when it differs (session 7443317c:
@@ -15,9 +15,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Hosts the <see cref="ImeRegistryAppStateObserver"/> (registry second pillar, audit
/// 2026-08-17): one recursive <see cref="RegistryWatcher"/> on the IME root — all three
/// observed surfaces (Win32Apps, EspTrackingWin32Apps, SideCarPolicies\StatusServiceReports)
/// live under it — debounced into snapshot-and-diff ticks, plus a 60-s periodic fallback
/// 2026-08-17) and the <see cref="ImeRegistryScriptResultObserver"/> (D-316): one recursive
/// <see cref="RegistryWatcher"/> on the IME root — every observed surface (Win32Apps,
/// EspTrackingWin32Apps, SideCarPolicies\StatusServiceReports, Policies) lives under it —
/// debounced into snapshot-and-diff ticks, plus a 60-s periodic fallback
/// tick. The fallback both evaluates the reconciliation settle-delay when the registry goes
/// quiet AND keeps the pillar alive if the watcher fails to arm (poll-only degraded mode,
/// announced once via <c>collector_degraded</c>). Always-on observability host, no config
@@ -31,6 +32,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private static readonly TimeSpan PeriodicInterval = TimeSpan.FromSeconds(60);
private readonly ImeRegistryAppStateObserver _observer;
// Platform-script results under the same root (D-316); null when no run register was supplied.
private readonly ImeRegistryScriptResultObserver? _scriptObserver;
private readonly InformationalEventPost _post;
private readonly AgentLogger? _logger;
private readonly string _sessionId;
@@ -47,7 +50,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
AgentLogger? logger,
ISignalIngressSink ingress,
IClock clock,
Func<IReadOnlyList<AppPackageState>>? trackerStateProbe)
Func<IReadOnlyList<AppPackageState>>? trackerStateProbe,
PlatformScriptRunRegister? scriptRuns = null)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
@@ -56,6 +60,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_logger = logger;
_post = new InformationalEventPost(ingress, clock);
_observer = new ImeRegistryAppStateObserver(_post, logger, clock, trackerStateProbe);
if (scriptRuns != null) _scriptObserver = new ImeRegistryScriptResultObserver(scriptRuns, _post, logger, clock);
}
public void Start()
@@ -63,6 +68,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// Baseline immediately: pre-existing registry state must be captured silently
// BEFORE the first change edge, or stale entries would replay as fresh events.
_observer.Tick("baseline");
_scriptObserver?.Tick("baseline");
try
{
@@ -120,6 +126,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
try
{
_observer.Tick(reason);
_scriptObserver?.Tick(reason);
}
catch (Exception ex)
{
@@ -197,7 +197,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
TenantId = _tenantId,
EventType = SharedConstants.EventTypes.WindowsUpdateActivitySummary,
Severity = EventSeverity.Debug,
Source = "WindowsUpdateWatcher",
Source = SharedConstants.EventSources.WindowsUpdateWatcher,
Phase = EnrollmentPhase.Unknown,
Message = $"Update activity counted instead of listed: {total} event(s) " +
"(Store, Defender and other non-OS updates, repeated scan results, servicing of non-OS packages)",
@@ -223,7 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_ingress.Post(
kind: DecisionSignalKind.OsUpdateActivity,
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
sourceOrigin: source == "servicing" ? "ServicingWatcher" : "WindowsUpdateWatcher",
sourceOrigin: source == "servicing" ? SharedConstants.EventSources.ServicingWatcher : SharedConstants.EventSources.WindowsUpdateWatcher,
evidence: new Evidence(
kind: EvidenceKind.Raw,
identifier: $"os-update-activity:{source}",
@@ -92,6 +92,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
SharedConstants.EventTypes.ImePatternHits,
// Bookkeeping of runs that were deliberately not reported as activity.
SharedConstants.EventTypes.ScriptRecurrenceSummary,
// A correction of an already reported script run from IME's saved result (D-316):
// registry observation after the fact, not progress.
SharedConstants.EventTypes.ScriptOutputReconciliation,
// Same for the update watchers' tally of Store/Defender/other update events (D-310):
// emitted on stop, never progress. Windows/.NET update steps stay activity.
SharedConstants.EventTypes.WindowsUpdateActivitySummary,
@@ -1263,6 +1263,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
// logged its result line before the deadline). Lets the UI/MCP flag fallback-grounded
// platform-script completions. Platform scripts only — null for health scripts.
if (!string.IsNullOrEmpty(script.ResultSource)) data["resultSource"] = script.ResultSource!;
// The run a later script_output_reconciliation corrects names it by this id (platform only).
if (!string.IsNullOrEmpty(script.RunId)) data["runId"] = script.RunId!;
if (!string.IsNullOrEmpty(script.ComplianceResult)) data["complianceResult"] = script.ComplianceResult!;
if (script.RemediationStatus.HasValue) data["remediationStatus"] = script.RemediationStatus.Value.ToString(culture);
if (script.TargetType.HasValue) data["targetType"] = script.TargetType.Value.ToString(culture);
@@ -599,6 +599,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
["verifiedBytes"] = health.VerifiedBytes,
["passMaxMs"] = health.PassMaxMs,
["passGapMaxMs"] = health.PassGapMaxMs,
["platformOutputVerified"] = health.PlatformOutputVerified,
["platformOutputCorrected"] = health.PlatformOutputCorrected,
["agentVersion"] = _agentVersion ?? string.Empty,
};
if (!string.IsNullOrEmpty(health.ImeAgentVersion))
@@ -0,0 +1,45 @@
using System.Globalization;
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The versioned platform-stats file is served immutable for a year, so every publish must write
/// a new name. Maintenance publishes every 2 hours; the per-day name it used to write was rewritten
/// up to 12 times a day, and caches kept the first copy of the day.
/// </summary>
public class PlatformStatsPublishTests
{
private static readonly DateTime Publish = new(2026, 10, 3, 6, 0, 4, DateTimeKind.Utc);
[Fact]
public void Versioned_file_name_carries_the_publish_instant_to_the_second()
{
Assert.Equal("platform-stats.2026-10-03T060004Z.json", MaintenanceService.PlatformStatsVersionedFileName(Publish));
}
[Fact]
public void Two_publishes_of_one_day_get_different_names()
{
var early = MaintenanceService.PlatformStatsVersionedFileName(new DateTime(2026, 10, 3, 2, 0, 3, DateTimeKind.Utc));
var later = MaintenanceService.PlatformStatsVersionedFileName(new DateTime(2026, 10, 3, 4, 0, 3, DateTimeKind.Utc));
Assert.NotEqual(early, later);
}
[Fact]
public void Versioned_file_name_ignores_the_current_culture()
{
var previous = CultureInfo.CurrentCulture;
try
{
// th-TH counts years in the Buddhist era (2569 for 2026).
CultureInfo.CurrentCulture = new CultureInfo("th-TH");
Assert.Equal("platform-stats.2026-10-03T060004Z.json", MaintenanceService.PlatformStatsVersionedFileName(Publish));
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
}
@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// D-316: the agent corrects an already reported platform run from IME's saved result with a
/// script_output_reconciliation. It is observation of an earlier run: it never heals a Stalled session and never
/// counts as another script run.
/// </summary>
public class ScriptOutputReconciliationClassificationTests
{
private static readonly DateTime At = new(2026, 10, 3, 20, 59, 8, DateTimeKind.Utc);
[Fact]
public void A_correction_alone_is_no_real_activity_and_no_script_run()
{
var classification = EventIngestProcessor.ClassifyEvents(new List<EnrollmentEvent>
{
Event(Constants.EventTypes.ScriptOutputReconciliation, new Dictionary<string, object> { ["scriptType"] = "platform", ["runId"] = "run-1", ["outcome"] = "foreign" }),
});
Assert.False(classification.HasNonPeriodicRealEvent);
Assert.Equal(0, classification.PlatformScriptCount);
}
[Fact]
public void The_corrected_run_counts_once_as_a_platform_script()
{
var classification = EventIngestProcessor.ClassifyEvents(new List<EnrollmentEvent>
{
Event("script_failed", new Dictionary<string, object> { ["scriptType"] = "platform", ["runId"] = "run-1", ["exitCode"] = "1" }),
Event(Constants.EventTypes.ScriptOutputReconciliation, new Dictionary<string, object> { ["scriptType"] = "platform", ["runId"] = "run-1", ["outcome"] = "foreign" }),
});
Assert.True(classification.HasNonPeriodicRealEvent);
Assert.Equal(1, classification.PlatformScriptCount);
}
private static EnrollmentEvent Event(string eventType, Dictionary<string, object> data) => new()
{
EventId = Guid.NewGuid().ToString(),
Timestamp = At,
EventType = eventType,
Source = "RegistryScriptResult",
Data = data,
};
}
@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Backfilled environment observations never set the session start (D-315). Since agent 2.0.1473
/// the update and servicing watchers deliver rows with their original event time up to 60 min
/// before the agent started; anchoring on them stretched StartedAt by 22 min at the median
/// (p90 57 min). Source strings are the persisted wire values, spelled out on purpose.
/// </summary>
public class SessionAnchorSourceTests
{
private static readonly DateTime AgentStart = new(2026, 10, 3, 9, 6, 46, DateTimeKind.Utc);
[Theory]
[InlineData("SystemTimelineWatcher")]
[InlineData("WindowsUpdateWatcher")]
[InlineData("ServicingWatcher")]
public void Backfill_sources_are_not_anchor_eligible(string source)
{
Assert.False(Constants.EventSources.IsSessionAnchorEligible(source));
}
[Theory]
[InlineData("ImeLogTracker")]
[InlineData("Agent")]
[InlineData("MdmRebootPolicyWatcher")]
[InlineData("windowsupdatewatcher")] // ordinal: a differently cased source is not the watcher
[InlineData(null)] // legacy row without a source
public void Activity_sources_are_anchor_eligible(string? source)
{
Assert.True(Constants.EventSources.IsSessionAnchorEligible(source));
}
[Fact]
public void Ingest_batch_anchors_on_the_first_activity_not_on_backfilled_update_rows()
{
// Shape of session 0f1d1f58: update scans and CBS servicing backfilled from before the
// IME replay, a clock step even earlier, then IME replay, agent start, a live update row.
var firstImeLine = AgentStart.AddMinutes(-10);
var liveUpdate = AgentStart.AddMinutes(25);
var events = new List<EnrollmentEvent>
{
Event(AgentStart.AddMinutes(-16), "system_clock_changed", "SystemTimelineWatcher"),
Event(AgentStart.AddMinutes(-15), "windows_update_started", "WindowsUpdateWatcher"),
Event(AgentStart.AddMinutes(-14), "windows_update_servicing", "ServicingWatcher"),
Event(firstImeLine, "ime_agent_version", "ImeLogTracker"),
Event(AgentStart, "agent_started", "Agent"),
Event(liveUpdate, "windows_update_started", "WindowsUpdateWatcher"),
};
var classification = EventIngestProcessor.ClassifyEvents(events);
Assert.Equal(firstImeLine, classification.EarliestEventTimestamp);
// The latest timestamp still counts every row: a live update row is a real agent report.
Assert.Equal(liveUpdate, classification.LatestEventTimestamp);
}
[Fact]
public void Ingest_batch_of_only_backfilled_rows_carries_no_anchor()
{
var events = new List<EnrollmentEvent>
{
Event(AgentStart.AddMinutes(-50), "windows_update_started", "WindowsUpdateWatcher"),
Event(AgentStart.AddMinutes(-40), "windows_update_servicing", "ServicingWatcher"),
};
var classification = EventIngestProcessor.ClassifyEvents(events);
Assert.Null(classification.EarliestEventTimestamp);
Assert.Equal(AgentStart.AddMinutes(-40), classification.LatestEventTimestamp);
}
private static EnrollmentEvent Event(DateTime timestamp, string eventType, string source) => new()
{
EventId = Guid.NewGuid().ToString(),
Timestamp = timestamp,
EventType = eventType,
Source = source,
};
}
@@ -104,10 +104,14 @@ public class SessionStartedAtBackwardShiftGuardTests
// =========================== system-timeline rows are not session anchors ====
[Fact]
public void IsSessionAnchorEligible_excludes_system_timeline_watcher_rows()
public void IsSessionAnchorEligible_excludes_backfilled_environment_rows()
{
Assert.False(TableStorageService.IsSessionAnchorEligible(
new TableEntity("p", "r") { ["Source"] = "SystemTimelineWatcher" }));
Assert.False(TableStorageService.IsSessionAnchorEligible(
new TableEntity("p", "r") { ["Source"] = "WindowsUpdateWatcher" }));
Assert.False(TableStorageService.IsSessionAnchorEligible(
new TableEntity("p", "r") { ["Source"] = "ServicingWatcher" }));
Assert.True(TableStorageService.IsSessionAnchorEligible(
new TableEntity("p", "r") { ["Source"] = "ImeLogTracker" }));
Assert.True(TableStorageService.IsSessionAnchorEligible(
@@ -140,6 +144,29 @@ public class SessionStartedAtBackwardShiftGuardTests
Assert.Equal((int)(lastEvent - firstActivity).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
}
[Fact]
public async Task Pending_duration_skips_backfilled_update_rows_and_anchors_on_activity()
{
// D-315: update scans and CBS servicing backfilled with their event time up to 60 min
// before the agent started were the partition's earliest rows and re-anchored StartedAt.
var firstActivity = StartedAt.AddMinutes(-5);
var lastEvent = StartedAt.AddMinutes(70);
var harness = new Harness(SessionRow(), eventRows: new[]
{
EventRow(StartedAt.AddMinutes(-55), sequence: 27, source: "WindowsUpdateWatcher"),
EventRow(StartedAt.AddMinutes(-50), sequence: 34, source: "ServicingWatcher"),
EventRow(firstActivity, sequence: 2, source: "ImeLogTracker"),
});
var ok = await harness.Sut.UpdateSessionStatusAsync(
TenantId, SessionId, SessionStatus.Pending, VerdictPaths.AgentWhiteGlovePending,
latestEventTimestamp: lastEvent);
Assert.True(ok);
Assert.NotNull(harness.Written);
Assert.Equal((int)(lastEvent - firstActivity).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
}
private static TableEntity EventRow(DateTime occurredUtc, int sequence, string source) =>
new($"{TenantId}_{SessionId}", $"{occurredUtc:yyyyMMddHHmmssfff}_{sequence:D10}")
{
@@ -90,6 +90,8 @@ public sealed class SharedManifestParityTests
["annotationVerdicts"] = AnnotationVerdicts.All,
// Install channels of the app rows (AppInstallSummary.Source and the per-app DTOs).
["appInstallSources"] = AppInstallSources.All,
// Event sources whose rows never set a session's start; the portal's session duration applies them.
["sessionAnchorIneligibleSources"] = Constants.EventSources.SessionAnchorIneligible,
// Community rule submissions: kinds, effective statuses (incl. the derived "published"),
// attribution modes and reviewer decisions — the MCP tool schemas enumerate these.
["ruleSubmissionKinds"] = RuleSubmissionKinds.All,
@@ -1,4 +1,5 @@
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
@@ -9,19 +10,18 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
public sealed partial class EventIngestProcessor
{
private EventClassification ClassifyEvents(List<EnrollmentEvent> storedEvents)
internal static EventClassification ClassifyEvents(List<EnrollmentEvent> storedEvents)
{
var classification = new EventClassification();
foreach (var evt in storedEvents)
{
// System-timeline-watcher rows (clock steps, sleep episodes) are backfilled
// from the Windows event log with original timestamps up to 24h before the
// agent started — environment observation, not enrollment activity. They must
// not pull the session anchor toward pre-enrollment OOBE idle time (mirrors
// TableStorageService.IsSessionAnchorEligible on the Events-table probe;
// field case d0c5b672).
var anchorEligible = evt.Source != "SystemTimelineWatcher";
// Backfilled environment observations (clock steps, sleep, update and servicing
// activity from before the agent started) are timeline rows, not enrollment
// activity: they must not pull the session anchor into pre-enrollment time
// (Constants.EventSources.SessionAnchorIneligible; the Events-table probe applies
// the same rule).
var anchorEligible = Constants.EventSources.IsSessionAnchorEligible(evt.Source);
if (anchorEligible &&
(!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value))
classification.EarliestEventTimestamp = evt.Timestamp;
@@ -480,6 +480,9 @@ namespace AutopilotMonitor.Functions.Services
// observation too — a device that slept through its stall must stay Stalled.
"system_clock_changed" => true,
"system_sleep_episode" => true,
// A platform run corrected after the fact from IME's saved result (D-316): registry
// observation of an earlier run, not progress — a stalled session stays Stalled.
Constants.EventTypes.ScriptOutputReconciliation => true,
_ => false
};
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
@@ -795,7 +796,7 @@ namespace AutopilotMonitor.Functions.Services
var containerClient = new BlobContainerClient(new Uri(containerSasUrl));
var generatedAtUtc = DateTime.UtcNow;
var versionedFileName = $"platform-stats.{generatedAtUtc:yyyy-MM-dd}.json";
var versionedFileName = PlatformStatsVersionedFileName(generatedAtUtc);
var versionedPayload = new
{
@@ -831,19 +832,34 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Name of the versioned stats file of one publish. The file is served immutable for a year
/// (<see cref="PlatformStatsVersionedCacheControl"/>), so a name must never be written twice:
/// maintenance publishes every 2 hours, and the per-day name of the once-a-night schedule was
/// rewritten up to 12 times a day under that header — caches kept the first copy of the day.
/// Browsers revalidate only the manifest, which names the current file.
/// </summary>
internal static string PlatformStatsVersionedFileName(DateTime generatedAtUtc) =>
$"platform-stats.{generatedAtUtc.ToString("yyyy-MM-dd'T'HHmmss'Z'", CultureInfo.InvariantCulture)}.json";
private async Task UploadJsonBlobAsync(BlobContainerClient containerClient, string blobName, object payload, string cacheControl)
{
var blobClient = containerClient.GetBlobClient(blobName);
var json = JsonConvert.SerializeObject(payload);
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
await blobClient.UploadAsync(stream, overwrite: true);
await blobClient.SetHttpHeadersAsync(new BlobHttpHeaders
// Headers and tier travel with the upload, which still overwrites (the manifest relies on
// it). Set in follow-up calls, the blob was briefly served without Cache-Control and
// content type, and stayed that way when a follow-up call failed.
await blobClient.UploadAsync(stream, new BlobUploadOptions
{
ContentType = "application/json; charset=utf-8",
CacheControl = cacheControl
HttpHeaders = new BlobHttpHeaders
{
ContentType = "application/json; charset=utf-8",
CacheControl = cacheControl
},
AccessTier = AccessTier.Hot
});
await blobClient.SetAccessTierAsync(AccessTier.Hot);
}
/// <summary>
@@ -60,6 +60,7 @@ namespace AutopilotMonitor.Functions.Services
private const string PlatformStatsAliasFileName = "platform-stats.json";
private const string PlatformStatsAliasCacheControl = "public, max-age=300, stale-while-revalidate=86400";
// Holds only because every publish writes a new name (PlatformStatsVersionedFileName).
private const string PlatformStatsVersionedCacheControl = "public, max-age=31536000, immutable";
public MaintenanceService(
@@ -3540,18 +3540,15 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Event sources whose rows never anchor the session. The system timeline watcher
/// backfills environment observations (clock steps, sleep episodes) from the Windows
/// event log with their ORIGINAL timestamps — up to 24h before the agent started.
/// They belong on the timeline, but they are not enrollment activity: anchoring on
/// them silently stretches StartedAt/DurationSeconds toward pre-enrollment OOBE idle
/// time (field case d0c5b672: a pre-agent clock correction pulled StartedAt ~14min
/// before the first real activity; the 2h backward-shift guard only caps, not
/// prevents, this). User decision 2026-08-21: the session anchor stays on real
/// enrollment activity.
/// Whether an event row may anchor the session (<see cref="Constants.EventSources.SessionAnchorIneligible"/>).
/// Backfilled environment observations carry their ORIGINAL event time, up to 24h before
/// the agent started; anchoring on them silently stretches StartedAt/DurationSeconds into
/// pre-enrollment time (field case d0c5b672: a pre-agent clock correction, ~14 min; the 2h
/// backward-shift guard only caps this). User decision 2026-08-21: the session anchor
/// stays on real enrollment activity.
/// </summary>
internal static bool IsSessionAnchorEligible(TableEntity eventRow)
=> eventRow.GetString("Source") != "SystemTimelineWatcher";
=> Constants.EventSources.IsSessionAnchorEligible(eventRow.GetString("Source"));
/// <summary>
/// Returns the earliest ANCHOR-ELIGIBLE event timestamp persisted for a session, if
@@ -0,0 +1,95 @@
/**
* D-316: a platform run whose script_failed came from another script's end block is corrected by a later
* script_output_reconciliation (outcome "foreign", IME result Success). get_session_summary must not count the
* stored script_failed as an error, must not rank it as one and must not attach the foreign exit code's catalog
* text; the correction itself stays a Warning key event.
*
* Pure unit tests: fetch is stubbed and the registered tool handler is invoked directly.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { McpServer } from '@modelcontextprotocol/server';
import { registerTools } from '../tools.js';
import { runWithCaller } from '../client.js';
import { foreignSuccessRunIds, isCorrectedForeignFailure } from '../tools/shared.js';
type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }>;
const SESSION = 'e259c121-1234-4abc-9def-0123456789ab';
const GA = { token: 'ga', isGlobalAdmin: true };
const extra = { signal: new AbortController().signal };
function summaryHandler(): ToolHandler {
const server = new McpServer({ name: 'test', version: '0.0.0' });
registerTools(server, undefined, undefined, undefined, true, true, false);
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
return registry['get_session_summary'].handler;
}
function stubBackend(events: unknown[]): void {
const body = {
success: true,
session: { sessionId: SESSION, tenantId: 't', status: 'Succeeded', startedAt: '2026-10-03T20:49:00Z' },
events,
results: [],
annotations: [],
count: events.length,
};
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) }) as unknown as Response));
}
afterEach(() => vi.unstubAllGlobals());
const foreignFailure = {
timestamp: '2026-10-03T20:59:04Z',
eventType: 'script_failed',
severity: 'Error',
source: 'ImeLogTracker',
message: 'Platform script d94468af: Success (exit: 1)',
data: { scriptType: 'platform', result: 'Success', exitCode: '1', runId: 'run-1' },
};
const correction = (data: Record<string, unknown>) => ({
timestamp: '2026-10-03T20:59:08Z',
eventType: 'script_output_reconciliation',
severity: 'Warning',
source: 'RegistryScriptResult',
message: "Platform script d94468af: output corrected from IME's saved result",
data: { scriptType: 'platform', outcome: 'foreign', result: 'Success', runId: 'run-1', ...data },
});
async function summary(events: unknown[]): Promise<Record<string, any>> {
stubBackend(events);
const result = await runWithCaller(GA, () => summaryHandler()({ sessionId: SESSION }, extra));
return JSON.parse((result.content ?? []).map((c) => c.text ?? '').join(''));
}
describe('get_session_summary with a corrected platform run', () => {
it('does not count the foreign script_failed as an error and drops its exit-code text', async () => {
const body = await summary([foreignFailure, correction({})]);
expect(body.stats.errorCount).toBe(0);
expect(body.stats.warningCount).toBe(1);
const failed = body.keyEvents.find((e: { eventType: string }) => e.eventType === 'script_failed');
expect(failed.correctedByImeSavedResult).toBe(true);
expect(failed.errorCode).toBeUndefined();
expect(body.keyEvents.some((e: { eventType: string }) => e.eventType === 'script_output_reconciliation')).toBe(true);
});
it('keeps counting a failure IME itself reported, and an uncorrected one', async () => {
expect((await summary([foreignFailure, correction({ result: 'Failed' })])).stats.errorCount).toBe(1);
expect((await summary([foreignFailure, correction({ outcome: 'repaired' })])).stats.errorCount).toBe(1);
expect((await summary([foreignFailure, correction({ runId: 'another-run' })])).stats.errorCount).toBe(1);
expect((await summary([foreignFailure])).stats.errorCount).toBe(1);
});
});
describe('foreignSuccessRunIds / isCorrectedForeignFailure', () => {
it('names only foreign corrections whose IME result is Success, and only script_failed rows', () => {
const runs = foreignSuccessRunIds([correction({}), correction({ runId: 'r2', result: 'Failed' }), correction({ runId: 'r3', outcome: 'repaired' })]);
expect([...runs]).toEqual(['run-1']);
expect(isCorrectedForeignFailure('script_failed', { runId: 'run-1' }, runs)).toBe(true);
expect(isCorrectedForeignFailure('script_completed', { runId: 'run-1' }, runs)).toBe(false);
expect(isCorrectedForeignFailure('script_failed', { runId: 'r2' }, runs)).toBe(false);
expect(isCorrectedForeignFailure('script_failed', undefined, runs)).toBe(false);
});
});
@@ -96,7 +96,7 @@ export const EVENT_TYPES_CATALOG = {
'office_install_failed',
'office_preinstalled_detected',
],
script_events: ['script_started', 'script_completed', 'script_failed', 'script_timeout_suspected', 'script_recurrence_summary', 'historic_ime_replay_detected'],
script_events: ['script_started', 'script_completed', 'script_failed', 'script_output_reconciliation', 'script_timeout_suspected', 'script_recurrence_summary', 'historic_ime_replay_detected'],
network_events: [
'network_state_change',
'network_connectivity_check',
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';
import { ApiError, apiFetch, buildQuery, jsonBody, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, pageSizeForCall, pickGlobalOrTenantPath, scanUntilMatch, scanWithTimeoutFallback } from '../client.js';
import { withToolTelemetry } from '../telemetry.js';
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, foreignSuccessRunIds, isCorrectedForeignFailure, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
import { toolError } from './error-handler.js';
import { lookupErrorCode } from '../error-code-catalog.js';
import { withEventTypeNote, assertKnownDevicePropertyKeys } from '../resource-catalog.js';
@@ -534,6 +534,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
// Observation coverage: what the agent could and could not see, folded from the
// health/lifecycle events that the triage timeline below ranks low or drops.
const coverage = buildSessionCoverage(s, allEvents);
// Platform runs IME's saved result proved to carry another script's exit code (D-316).
const foreignRuns = foreignSuccessRunIds(allEvents);
let errorCount = 0;
let warningCount = 0;
@@ -545,7 +547,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
const sev = String(e.severity ?? '');
// A compliant health-script detection mis-stamped script_failed/Error is benign —
// exclude it so a green session's errorCount isn't inflated by routine compliance reports.
const benign = isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data);
const benign = isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data)
|| isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns);
if (!benign && (sev === 'Error' || sev === 'Critical')) errorCount++;
if (sev === 'Warning') warningCount++;
const et = String(e.eventType ?? '');
@@ -571,6 +574,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
const relevanceScore = (e: Partial<EnrollmentEvent>): number => {
// Benign compliant detection mis-stamped Error → rank as info-level, not top.
if (isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data)) return 10;
// A failure that was another script's exit code is not a failure of this one.
if (isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns)) return 10;
const sev = SEVERITY_RANK[String(e.severity ?? '')] ?? -1;
if (sev >= 3) return 100; // Error/Critical
if (PHASE_EVENT_TYPES.has(String(e.eventType ?? ''))) return 60;
@@ -597,7 +602,10 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
phase: phaseName(e.phase, s.enrollmentType),
message: e.message,
source: e.source,
...keyEventErrorCode(e.data, e.source),
// The exit code on a corrected run was another script's: no catalog text for it.
...(isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns)
? { correctedByImeSavedResult: true }
: keyEventErrorCode(e.data, e.source)),
}));
let analysis = null;
@@ -75,6 +75,8 @@ export function leanFieldSelection(
export const SUMMARY_EVENT_FIELDS =
'eventType,severity,source,phase,timestamp,message,sequence,' +
'data.scriptType,data.script_type,data.scriptPart,data.script_part,data.result,' +
// Platform-run corrections from IME's saved result (foreignSuccessRunIds below).
'data.runId,data.outcome,' +
'data.rejectedSourceTimestamp,data.rejected_source_timestamp,' +
'data.errorCode,data.exitCode,data.hresult,data.hresultFromWin32,data.hresultSymbol,' +
'data.errorCodeInfo,data.exitCodeInfo,data.hresultInfo,data.hresultFromWin32Info,' +
@@ -125,6 +127,38 @@ export function isBenignHealthDetectionReport(
return true;
}
/**
* Run ids of platform runs whose `script_failed` came from another script's end block while IME
* itself reported Success: `script_output_reconciliation` with outcome "foreign" and result Success
* (D-316 — the agent had attributed a foreign executor's exit code and output; IME's saved result
* proved it). The stored `script_failed` stays as written; read sides treat it like the benign
* detection reports above: no error count, info-level rank, no exit-code catalog text.
*/
export function foreignSuccessRunIds(
events: ReadonlyArray<{ eventType?: string | null; data?: Record<string, unknown> }>,
): Set<string> {
const runs = new Set<string>();
for (const e of events) {
if (e.eventType !== 'script_output_reconciliation' || !e.data) continue;
if (String(e.data.outcome ?? '') !== 'foreign') continue;
if (String(e.data.result ?? '').toLowerCase() !== 'success') continue;
const runId = e.data.runId;
if (typeof runId === 'string' && runId.length > 0) runs.add(runId);
}
return runs;
}
/** True for a `script_failed` whose run `foreignSuccessRunIds` names. */
export function isCorrectedForeignFailure(
eventType: string | undefined,
data: Record<string, unknown> | undefined,
foreignRuns: ReadonlySet<string>,
): boolean {
if (eventType !== 'script_failed' || !data || foreignRuns.size === 0) return false;
const runId = data.runId;
return typeof runId === 'string' && foreignRuns.has(runId);
}
/**
* Builds the role-aware description for a tenant-boundable tool's `tenantId` argument. MCP clients weigh
* the per-arg schema heavily, so a delegated (MSP) caller — for whom omitting tenantId is REJECTED, not
@@ -543,6 +543,13 @@ namespace AutopilotMonitor.Shared
// policies on its own schedule for as long as the device is up; the first run and
// every run with a different result are still emitted in full.
public const string ScriptRecurrenceSummary = "script_recurrence_summary";
// A platform script's emitted output disagreed with IME's own saved result
// (HKLM\...\IntuneManagementExtension\Policies\<user>\<policy>, written after IME reported the
// script batch): Data.outcome "foreign" (Warning, immediate upload — the run had been given
// another executor's end block; Data.stdout/stderr replace it, the exit code is unverified) or
// "repaired" (Info — the log had delivered the output incomplete). Data.runId names the corrected
// script_completed/script_failed. Observability only, never DecisionEngine input.
public const string ScriptOutputReconciliation = "script_output_reconciliation";
// One-shot per agent run: the IME adapter detected replayed log content from a
// previous enrollment (source lines > 24 h stale, e.g. IME logs surviving a
// re-enrollment) and suppressed the historic script (script_started/completed/
@@ -913,6 +920,37 @@ namespace AutopilotMonitor.Shared
/// author-defined (no error-code enrichment unless <see cref="GatherRuleDataKeys.EnrichErrorCodes"/>).
/// </summary>
public const string GatherRuleExecutor = "GatherRuleExecutor";
/// <summary>Clock steps and sleep episodes from the Windows System log.</summary>
public const string SystemTimelineWatcher = "SystemTimelineWatcher";
/// <summary>Windows Update client events (System and Operational log).</summary>
public const string WindowsUpdateWatcher = "WindowsUpdateWatcher";
/// <summary>CBS servicing steps from the Setup log.</summary>
public const string ServicingWatcher = "ServicingWatcher";
/// <summary>
/// Sources whose rows never set a session's start. Their watchers backfill the Windows
/// event logs with the original event time — clock steps and sleep up to 24 h, update
/// and servicing activity up to 60 min before the agent started — so their earliest row
/// is environment observation from before the enrollment, not its first step. Every
/// place that derives the start from events applies this list: backend ingest and the
/// Events-table probe, and the portal's session duration via shared-manifests.json.
/// </summary>
public static readonly string[] SessionAnchorIneligible =
{
SystemTimelineWatcher,
WindowsUpdateWatcher,
ServicingWatcher,
};
/// <summary>
/// False for <see cref="SessionAnchorIneligible"/> sources; true otherwise, including a
/// row without a source.
/// </summary>
public static bool IsSessionAnchorEligible(string? source) =>
System.Array.IndexOf(SessionAnchorIneligible, source) < 0;
}
/// <summary>
@@ -170,44 +170,37 @@ export function TenantAdminSection({
};
return (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<svg className="w-5 h-5 text-purple-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<div className="flex-1 min-w-0 space-y-4">
<p className="font-semibold text-purple-900">
Members
{loadingAdmins && <span className="ml-2 text-sm font-normal text-purple-700">(Loading...)</span>}
</p>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 space-y-4">
<h3 className="font-semibold text-purple-900">
Members
{loadingAdmins && <span className="ml-2 text-sm font-normal text-purple-700">(Loading...)</span>}
</h3>
<AddMemberForm
value={newAdminEmail}
onValueChange={setNewAdminEmail}
role={newMemberRole}
onRoleChange={setNewMemberRole}
kind={newMemberKind}
onKindChange={setNewMemberKind}
adding={addingAdmin}
onAdd={handleAddTenantAdmin}
disabled={!canMutate}
stacked
/>
<AddMemberForm
value={newAdminEmail}
onValueChange={setNewAdminEmail}
role={newMemberRole}
onRoleChange={setNewMemberRole}
kind={newMemberKind}
onKindChange={setNewMemberKind}
adding={addingAdmin}
onAdd={handleAddTenantAdmin}
disabled={!canMutate}
stacked
/>
{/* key: a tenant switch starts on page 1 with no filter, search or open row. */}
<MemberList
key={tenantId}
members={tenantAdmins}
loading={loadingAdmins}
canMutate={canMutate}
removingUpn={removingAdmin}
updatingUpn={togglingAdmin}
onRemove={handleRemoveTenantAdmin}
onToggleEnabled={handleToggleTenantAdmin}
onUpdatePermissions={handleUpdatePermissions}
/>
</div>
</div>
{/* key: a tenant switch starts on page 1 with no filter, search or open row. */}
<MemberList
key={tenantId}
members={tenantAdmins}
loading={loadingAdmins}
canMutate={canMutate}
removingUpn={removingAdmin}
updatingUpn={togglingAdmin}
onRemove={handleRemoveTenantAdmin}
onToggleEnabled={handleToggleTenantAdmin}
onUpdatePermissions={handleUpdatePermissions}
/>
</div>
);
}
@@ -152,6 +152,8 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
description: "A platform script completed, or one phase (detection / remediation / post-detection) of a health script completed. Health scripts emit up to three of these per policy run." },
{ value: "script_failed", label: "script_failed", category: "script",
description: "A platform script crashed (exit code != 0), or the remediation phase of a health script crashed. Health-script detection / post-detection phases use exit code as a compliance verdict (non-zero = non-compliant) and emit script_completed even on non-zero exit; only a true script crash in those phases would surface here." },
{ value: "script_output_reconciliation", label: "script_output_reconciliation", category: "script",
description: "A platform script's reported output disagreed with the result IME saved after reporting the script batch. Outcome \"foreign\" (Warning): the run had been given another script's output and exit code; the saved output replaces it and the exit code is unknown. Outcome \"repaired\" (Info): the output had arrived incomplete. Carries runId (the corrected run), policyId, outcome, reason, result, stdout and stderr." },
{ value: "script_timeout_suspected", label: "script_timeout_suspected", category: "script",
description: "A platform script ran to the IME script-execution timeout (~30 min; threshold 25 min) and was marked Failed while the enrollment was in progress. IME runs platform scripts serially, so a hung script starves app installs and the Autopilot-Monitor bootstrap — the prime suspect behind a late agent start / pre-failed ESP. Advisory (one per policyId); carries durationSeconds, exitCode and espPhase." },
{ value: "script_recurrence_summary", label: "script_recurrence_summary", category: "script",
+13 -37
View File
@@ -1,41 +1,17 @@
import { AuthGate } from "../components/landing/AuthGate";
import { LandingNavbar } from "../components/landing/LandingNavbar";
import { Hero } from "../components/landing/Hero";
import { StatsBand } from "../components/landing/StatsBand";
import { JustAsk } from "../components/landing/JustAsk";
import { Story } from "../components/landing/Story";
import { CapabilitiesStrip } from "../components/landing/CapabilitiesStrip";
import { Comparison } from "../components/landing/Comparison";
import { HowItWorks } from "../components/landing/HowItWorks";
import { Testimonials } from "../components/landing/Testimonials";
import { FinalCta } from "../components/landing/FinalCta";
import { SiteFooter } from "../components/SiteFooter";
import { MarketingTracker } from "../components/MarketingTracker";
import { LandingPage } from "../components/landing/LandingPage";
import { loadPlatformStatsSnapshot } from "../lib/platformStats";
// The stats read below runs once per `next build` and bypasses Next's fetch cache (`no-store`,
// see lib/platformStats.ts). force-static keeps that legal in the static export; the landing
// tree reads no request-time API (cookies, headers, search params) that it would blank out.
export const dynamic = "force-static";
/**
* Landing page v2 — a scroll story of one enrollment.
* All theming goes through the lp-* tokens (globals.css); sections live
* in components/landing/. Auth behavior (AuthGate redirect, LoginButton
* portal handoff) is unchanged from v1.
* The landing page with the platform stats of the build baked into its static HTML, so
* crawlers and LLM fetchers that run no JS see the numbers too; browsers then load the live
* ones (components/landing/StatsBand.tsx). Awaited here, not under a Suspense boundary: React
* would move a resolved boundary of a page this size into a hidden div at the end of the body.
*/
export default function LandingPage() {
return (
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
{/* Client component: handles auth redirect + loading overlay */}
<AuthGate />
<MarketingTracker page="landing">
<LandingNavbar />
<Hero />
<StatsBand />
<JustAsk />
<Story />
<CapabilitiesStrip />
<Comparison />
<HowItWorks />
<Testimonials />
<FinalCta />
<SiteFooter />
</MarketingTracker>
</div>
);
export default async function Page() {
return <LandingPage statsSnapshot={await loadPlatformStatsSnapshot()} />;
}
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useDeferredValue } from "react";
import { createContext, useContext, useState, useMemo, useDeferredValue } from "react";
import { EnrollmentEvent, Session } from "@/types";
import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../utils/eventHelpers";
import { buildEventSearchMatcher, formatEventSearchTerm, parseEventSearchQuery } from "../utils/eventSearchQuery";
@@ -8,6 +8,11 @@ import { getEnrichedOrLookup, formatErrorCode, errorCodeTooltip, type ErrorCodeI
import { readTimeProvenance, classifyTimeJump, readClockChangeDeltaMs } from "@/lib/timeProvenance";
import { isRebootOrRetryClass } from "@/lib/installProgress";
import { formatDuration, formatUtcOffset } from "@/lib/formatting";
import { SCRIPT_OUTPUT_RECONCILIATION } from "@/lib/scriptExecutions";
// runId → outcome of every platform run that IME's saved result corrected later (D-316): the
// run's own row says so, so its emitted output is never read as the script's.
const CorrectedRunsContext = createContext<ReadonlyMap<string, "foreign" | "repaired">>(new Map());
const SEARCH_SYNTAX_HINT =
"Searches event type, message, source and the details JSON (e.g. gather output). " +
@@ -104,10 +109,20 @@ export default function EventTimeline({
[events],
);
const correctedRuns = useMemo(() => {
const runs = new Map<string, "foreign" | "repaired">();
for (const e of events) {
if (e.eventType !== SCRIPT_OUTPUT_RECONCILIATION) continue;
const runId = e.data?.runId;
if (typeof runId === "string" && runId.length > 0) runs.set(runId, e.data?.outcome === "foreign" ? "foreign" : "repaired");
}
return runs;
}, [events]);
const filterPhaseEvents = (phaseEvents: EnrollmentEvent[]) =>
matchesSearch ? phaseEvents.filter(matchesSearch) : phaseEvents;
return (
const timeline = (
<div className="space-y-6">
{/* Search + Severity filters + Expand/Collapse — shared controls above the timeline(s) */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
@@ -356,6 +371,8 @@ export default function EventTimeline({
)}
</div>
);
return <CorrectedRunsContext.Provider value={correctedRuns}>{timeline}</CorrectedRunsContext.Provider>;
}
function PhaseSection({
@@ -515,7 +532,13 @@ function EventRow({ event, showScriptOutput, prevEvent, clockDeltas }: { event:
// timeline still applies the same script-event styling/iconography.
const isScriptEvent = event.eventType === "script_started"
|| event.eventType === "script_completed"
|| event.eventType === "script_failed";
|| event.eventType === "script_failed"
|| event.eventType === SCRIPT_OUTPUT_RECONCILIATION;
const correctedRuns = useContext(CorrectedRunsContext);
const eventRunId = event.data?.runId;
const laterCorrection = event.eventType !== SCRIPT_OUTPUT_RECONCILIATION && typeof eventRunId === "string"
? correctedRuns.get(eventRunId)
: undefined;
const detailData = useMemo(() => {
if (!rawDetailData || !isScriptEvent || showScriptOutput !== false) return rawDetailData;
const filtered = { ...rawDetailData };
@@ -611,6 +634,13 @@ function EventRow({ event, showScriptOutput, prevEvent, clockDeltas }: { event:
<span className="text-sm font-medium text-gray-900">{event.eventType}</span>
</div>
<p className="mt-1 text-sm text-gray-600" title={event.message || undefined}>{shortenBuildHashInMessage(event.message)}</p>
{laterCorrection && (
<p className="mt-0.5 text-xs text-gray-500">
{laterCorrection === "foreign"
? "Corrected later from IME's saved result: the output and exit code on this row belonged to another script."
: "Output completed later from IME's saved result."}
</p>
)}
{/* Exit code / HRESULT badge for app install events */}
{(event.eventType === "app_install_failed" || event.eventType === "app_install_completed") && (() => {
const ec = (event.data?.exitCode ?? event.data?.exit_code) as string | number | undefined;
@@ -234,6 +234,8 @@ function SessionDetailContent() {
e => e.eventType === "script_started"
|| e.eventType === "script_completed"
|| e.eventType === "script_failed"
// Corrections of a platform run from IME's saved result (D-316) — applied by the reducer.
|| e.eventType === "script_output_reconciliation"
),
[events]
);
@@ -30,6 +30,14 @@ import PhaseDivider from "@/components/PhaseDivider";
// span is hours of offset error rather than run time (a 10 s script once read "1h 00m").
const DURATION_SUPPRESSED_TITLE = "Run time not measurable: the start and end log lines were resolved on different timezone assumptions, so their span would be offset error, not run time";
// IME's saved result corrected the run (script_output_reconciliation): the log had attributed
// another script's end block to it, so its exit code is unknown and the output shown is IME's.
const EXIT_UNVERIFIED_TITLE = "Exit code unknown: the IME log had attributed another script's output and exit code to this run. The output shown is this script's own, as IME saved it.";
const OUTPUT_CORRECTED_TITLE: Record<"foreign" | "repaired", string> = {
foreign: "The IME log had attributed another script's output to this run. This is the script's own output, as IME saved it after reporting the result.",
repaired: "The IME log delivered this output incomplete. This is the full output, as IME saved it after reporting the result.",
};
interface ScriptExecutionsProps {
events: ScriptInputEvent[];
showScriptOutput?: boolean;
@@ -528,6 +536,9 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
exit {item.exitCode}
</span>
)}
{item.exitCode == null && item.outputCorrection === "foreign" && (
<span className="font-mono text-gray-400" title={EXIT_UNVERIFIED_TITLE}>exit n/a</span>
)}
{durationLabel && (
<span
className={`font-mono ${isSlowDuration ? "text-amber-600 font-medium" : "text-gray-500"}`}
@@ -564,6 +575,9 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
{!nested && item.runContext && <span><span className="font-medium text-gray-700">Context:</span> {item.runContext}</span>}
{!nested && item.targetType != null && <span><span className="font-medium text-gray-700">Target:</span> {item.targetType === 2 ? "Device" : "User"}</span>}
{item.exitCode != null && <span><span className="font-medium text-gray-700">Exit Code:</span> <span className="font-mono">{item.exitCode}</span></span>}
{item.exitCode == null && item.outputCorrection === "foreign" && (
<span title={EXIT_UNVERIFIED_TITLE}><span className="font-medium text-gray-700">Exit Code:</span> <span className="font-mono text-gray-400">n/a</span></span>
)}
{detailDurationLabel && <span><span className="font-medium text-gray-700">Duration:</span> <span className={`font-mono ${isSlowDuration ? "text-amber-600" : ""}`}>{detailDurationLabel}</span></span>}
{!detailDurationLabel && item.durationSuppressedReason && (
<span title={DURATION_SUPPRESSED_TITLE}><span className="font-medium text-gray-700">Duration:</span> <span className="font-mono text-gray-400">n/a</span></span>
@@ -601,7 +615,12 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
{hasStdout && (
<div>
<div className="text-xs font-medium text-gray-500 mb-1">stdout</div>
<div className="text-xs font-medium text-gray-500 mb-1">
stdout
{item.outputCorrection && (
<span className="font-normal text-gray-400" title={OUTPUT_CORRECTED_TITLE[item.outputCorrection]}>{" · from IME's saved result"}</span>
)}
</div>
<div className="p-2 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto max-h-48 overflow-y-auto">
<pre className="whitespace-pre-wrap break-words">{item.stdout}</pre>
</div>
@@ -614,7 +633,12 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
{hasStderr && (
<div>
<div className="text-xs font-medium text-red-500 mb-1">stderr</div>
<div className="text-xs font-medium text-red-500 mb-1">
stderr
{item.outputCorrection && (
<span className="font-normal text-gray-400" title={OUTPUT_CORRECTED_TITLE[item.outputCorrection]}>{" · from IME's saved result"}</span>
)}
</div>
<div className="p-2 bg-gray-900 rounded text-xs text-red-300 font-mono overflow-x-auto max-h-48 overflow-y-auto">
<pre className="whitespace-pre-wrap break-words">{item.stderr}</pre>
</div>
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { prerender } from "react-dom/static";
// LandingNavbar and LoginButton read the auth context; the public pages render signed out.
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ login: async () => undefined, isAuthenticated: false, user: null, getAccessToken: async () => null }),
}));
// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links.
vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null }));
const { SNAPSHOT } = vi.hoisted(() => ({
SNAPSHOT: {
items: [
{ label: "enrollments monitored", value: "12,481" },
{ label: "events processed", value: "8.3M" },
],
asOf: "2026-10-02T14:00:03.1234567Z",
},
}));
// The blob read itself is covered in lib/__tests__/platformStats.test.ts.
vi.mock("@/lib/platformStats", async importOriginal => ({
...(await importOriginal<typeof import("@/lib/platformStats")>()),
loadPlatformStatsSnapshot: async () => SNAPSHOT,
}));
import LandingRoute from "@/app/page";
import { StatsBand } from "@/components/landing/StatsBand";
/**
* Crawlers and LLM fetchers run no JS: the platform stats only reach them when the build renders
* the numbers into the landing page's HTML (lib/platformStats.ts, app/page.tsx).
*/
describe("platform stats in the landing page's static HTML", () => {
it("the band renders the baked numbers and marks the snapshot", () => {
const html = renderToStaticMarkup(createElement(StatsBand, { snapshot: SNAPSHOT }));
expect(html).toContain(">12,481<");
expect(html).toContain(">enrollments monitored<");
expect(html).toContain(">8.3M<");
expect(html).toContain(`data-stats-snapshot="${SNAPSHOT.asOf}"`);
expect(html).not.toContain("animate-pulse");
});
it("without a snapshot the band shows the loading skeleton and no marker", () => {
const html = renderToStaticMarkup(createElement(StatsBand, { snapshot: null }));
expect(html).toContain('data-track-section="stats"');
expect(html).toContain("animate-pulse");
expect(html).not.toContain("data-stats-snapshot");
});
// A Suspense boundary around the read would still resolve at build time, but React moves a
// resolved boundary of a page this size into a hidden div at the end of the body.
it("the page renders the numbers in place, not in a hidden streaming segment", async () => {
const { prelude } = await prerender(createElement(LandingRoute));
const html = await new Response(prelude).text();
const bandStart = html.indexOf('data-track-section="stats"');
const nextSection = html.indexOf('data-track-section="just_ask"');
const figure = html.indexOf(">12,481<");
expect(bandStart).toBeGreaterThan(-1);
expect(figure).toBeGreaterThan(bandStart);
expect(figure).toBeLessThan(nextSection);
expect(html).toContain(`data-stats-snapshot="${SNAPSHOT.asOf}"`);
expect(html).not.toMatch(/<div hidden id="S:/);
});
});
@@ -9,7 +9,7 @@ vi.mock("@/contexts/AuthContext", () => ({
// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links.
vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null }));
import LandingPage from "@/app/page";
import { LandingPage } from "@/components/landing/LandingPage";
import GetStartedPage from "@/app/get-started/page";
import AiPage from "@/app/ai/page";
import AboutPage from "@/app/about/page";
@@ -25,7 +25,8 @@ import TermsPage from "@/app/terms/page";
*/
const PAGES: Record<string, ComponentType> = {
landing: LandingPage,
// app/page.tsx only awaits the build-time stats and renders this body; the sync renderer cannot await.
landing: () => createElement(LandingPage, { statsSnapshot: null }),
"get-started": GetStartedPage,
ai: AiPage,
about: AboutPage,
@@ -11,7 +11,7 @@ vi.mock("@/contexts/AuthContext", () => ({
// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links.
vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null }));
import LandingPage from "@/app/page";
import { LandingPage } from "@/components/landing/LandingPage";
import GetStartedPage from "@/app/get-started/page";
import AiPage from "@/app/ai/page";
import robots from "@/app/robots";
@@ -27,6 +27,8 @@ import { sitePageUrl } from "@/utils/config";
*/
const render = (Page: ComponentType) => renderToStaticMarkup(createElement(Page));
// app/page.tsx only awaits the build-time stats and renders this body; the sync renderer cannot await.
const Landing = () => createElement(LandingPage, { statsSnapshot: null });
const trackIds = (html: string) => [...html.matchAll(/data-track="([^"]+)"/g)].map(m => m[1]);
const sectionIds = (html: string) => [...html.matchAll(/data-track-section="([^"]+)"/g)].map(m => m[1]);
const untracked = (html: string) =>
@@ -37,7 +39,7 @@ const untracked = (html: string) =>
const PAGES: { name: string; Page: ComponentType; sections: string[] }[] = [
{
name: "landing",
Page: LandingPage,
Page: Landing,
sections: ["hero", "stats", "just_ask", "story", "capabilities", "comparison", "how_it_works", "testimonials", "final_cta"],
},
{ name: "get-started", Page: GetStartedPage, sections: ["steps", "signup"] },
@@ -60,7 +62,7 @@ describe("marketing page tracking", () => {
}
it("the landing links to the AI page from the navbar, Then just ask and the footer", () => {
const ids = trackIds(render(LandingPage));
const ids = trackIds(render(Landing));
expect(ids).toEqual(expect.arrayContaining(["nav_ai", "just_ask_ai", "footer_ai"]));
});
@@ -0,0 +1,46 @@
import { AuthGate } from "./AuthGate";
import { LandingNavbar } from "./LandingNavbar";
import { Hero } from "./Hero";
import { StatsBand } from "./StatsBand";
import { JustAsk } from "./JustAsk";
import { Story } from "./Story";
import { CapabilitiesStrip } from "./CapabilitiesStrip";
import { Comparison } from "./Comparison";
import { HowItWorks } from "./HowItWorks";
import { Testimonials } from "./Testimonials";
import { FinalCta } from "./FinalCta";
import { SiteFooter } from "../SiteFooter";
import { MarketingTracker } from "../MarketingTracker";
import type { PlatformStatsSnapshot } from "@/lib/platformStats";
/**
* Landing page v2 — a scroll story of one enrollment.
* All theming goes through the lp-* tokens (globals.css); sections live
* in components/landing/. Auth behavior (AuthGate redirect, LoginButton
* portal handoff) is unchanged from v1.
*
* Synchronous on purpose: app/page.tsx awaits the build-time stats and hands
* them in, and the marketing guard tests render this body with the sync
* renderToStaticMarkup.
*/
export function LandingPage({ statsSnapshot }: { statsSnapshot: PlatformStatsSnapshot | null }) {
return (
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
{/* Client component: handles auth redirect + loading overlay */}
<AuthGate />
<MarketingTracker page="landing">
<LandingNavbar />
<Hero />
<StatsBand snapshot={statsSnapshot} />
<JustAsk />
<Story />
<CapabilitiesStrip />
<Comparison />
<HowItWorks />
<Testimonials />
<FinalCta />
<SiteFooter />
</MarketingTracker>
</div>
);
}
@@ -1,61 +1,12 @@
"use client";
import { useEffect, useState } from "react";
interface PlatformStatsManifest {
latest: string;
generatedAtUtc: string;
}
interface PlatformStatsPayload {
totalEnrollments?: number;
totalUsers?: number;
totalTenants?: number;
totalSignedUpTenants?: number;
uniqueDeviceModels?: number;
totalEventsProcessed?: number;
successfulEnrollments?: number;
issuesDetected?: number;
lastFullCompute?: string;
lastUpdated?: string;
}
function resolvePlatformStatsManifestUrl(rawUrl?: string): string {
const trimmed = rawUrl?.trim();
if (!trimmed) {
return "/platform-stats.json";
}
const hashIndex = trimmed.indexOf("#");
const withoutHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
const hash = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
const queryIndex = withoutHash.indexOf("?");
const basePath = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash;
const query = queryIndex >= 0 ? withoutHash.slice(queryIndex) : "";
if (/\.json$/i.test(basePath)) {
return trimmed;
}
const normalizedBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
const manifestPath = `${normalizedBasePath}/platform-stats.json`;
return `${manifestPath}${query}${hash}`;
}
const PLATFORM_STATS_MANIFEST_URL =
resolvePlatformStatsManifestUrl(process.env.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL);
interface StatItem {
label: string;
value: string;
}
/** 8,341,206 → "8.3M"; smaller values keep their grouped form. */
function compact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
return n.toLocaleString("en-US");
}
import {
fetchLivePlatformStats,
toStatItems,
type PlatformStatsSnapshot,
type StatItem,
} from "@/lib/platformStats";
/**
* Sample values for local development only, where no stats blob is
@@ -71,81 +22,51 @@ const DEV_SAMPLE_STATS: StatItem[] = [
];
/**
* Full-bleed platform stats band under the hero shot. Skeleton while
* loading; on fetch failure it shows dev sample data locally and
* disappears entirely in production (never an endless skeleton, never
* fake numbers).
* Full-bleed platform stats band under the hero shot. Starts with the
* numbers the build baked in (`snapshot`, lib/platformStats.ts), so the
* static HTML carries them for crawlers and LLM fetchers, then swaps in the
* live numbers. A failed live read keeps the baked numbers; without either,
* it shows dev sample data locally and disappears entirely in production
* (never an endless skeleton, never fake numbers). The skeleton only shows
* while a page built without a snapshot loads.
*/
export function StatsBand() {
const [stats, setStats] = useState<StatItem[] | null>(null);
export function StatsBand({ snapshot }: { snapshot: PlatformStatsSnapshot | null }) {
const [live, setLive] = useState<StatItem[] | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
const fail = () => {
const loadLiveStats = async () => {
const payload = await fetchLivePlatformStats();
if (cancelled) return;
if (process.env.NODE_ENV === "development") {
setStats(DEV_SAMPLE_STATS);
const items = payload ? toStatItems(payload) : [];
if (items.length > 0) {
setLive(items);
} else {
setFailed(true);
}
};
const loadPlatformStats = async () => {
try {
const manifestResponse = await fetch(PLATFORM_STATS_MANIFEST_URL, { cache: "no-store" });
if (!manifestResponse.ok) return fail();
const manifest = (await manifestResponse.json()) as PlatformStatsManifest;
if (!manifest?.latest) return fail();
const versionedUrl = new URL(manifest.latest, manifestResponse.url).toString();
const statsResponse = await fetch(versionedUrl, { cache: "force-cache" });
if (!statsResponse.ok) return fail();
const payload = (await statsResponse.json()) as PlatformStatsPayload;
if (cancelled) return;
const items: StatItem[] = [];
if (payload.totalEnrollments) {
items.push({ label: "enrollments monitored", value: payload.totalEnrollments.toLocaleString("en-US") });
}
if (payload.issuesDetected) {
items.push({ label: "issues detected", value: payload.issuesDetected.toLocaleString("en-US") });
}
if (payload.totalSignedUpTenants) {
items.push({ label: "organisations", value: payload.totalSignedUpTenants.toLocaleString("en-US") });
}
if (payload.uniqueDeviceModels) {
items.push({ label: "device models", value: payload.uniqueDeviceModels.toLocaleString("en-US") });
}
if (payload.totalEventsProcessed) {
items.push({ label: "events processed", value: compact(payload.totalEventsProcessed) });
}
if (items.length > 0) {
setStats(items);
} else {
fail();
}
} catch {
// The band is decorative and must never break the page.
fail();
}
};
loadPlatformStats();
loadLiveStats();
return () => {
cancelled = true;
};
}, []);
if (failed) {
const devFallback = failed && process.env.NODE_ENV === "development" ? DEV_SAMPLE_STATS : null;
const stats = live ?? snapshot?.items ?? devFallback;
if (!stats && failed) {
return null;
}
return (
<section data-track-section="stats" className="border-y border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)]">
<section
data-track-section="stats"
// Which build snapshot the static HTML carries; deploy-web.yml warns when it is missing.
data-stats-snapshot={snapshot ? (snapshot.asOf ?? "") : undefined}
className="border-y border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)]"
>
<div className="max-w-7xl mx-auto px-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-y-6 py-8 lg:divide-x lg:divide-[var(--lp-line)]">
{(stats ?? Array.from({ length: 5 }, () => null)).map((item, i) =>
item ? (
@@ -6,7 +6,6 @@
/** Files that keep a raw site for a stated reason; they never leave the baseline. */
export const PERMANENT: Record<string, string> = {
"contexts/AuthContext.tsx": "auth/me bootstrap runs before the auth context exists: raw fetch by design",
"components/landing/StatsBand.tsx": "unauthenticated public blob manifest, not the API",
"app/health-check/page.tsx": "/version.json of the SWA itself, not the API",
"app/dashboard/hooks/deleteSessionResponse.ts": "safeJson on the 202 Response of the delete-cascade classifier (refusals arrive as ApiError)",
"app/settings/TenantConfigContext.tsx": "consent return path routes on TokenExpiredError (router.replace); the access check rethrows it as a non-outcome",
@@ -33,7 +32,6 @@ export const DEDUPEDAUTHFETCH_BASELINE: Record<string, number> = {
export const JSONPARSE_BASELINE: Record<string, number> = {
"app/dashboard/hooks/deleteSessionResponse.ts": 1,
"app/health-check/page.tsx": 1,
"components/landing/StatsBand.tsx": 2,
"contexts/AuthContext.tsx": 2,
};
@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
import {
fetchLivePlatformStats,
loadPlatformStatsSnapshot,
resolvePlatformStatsManifestUrl,
toStatItems,
} from "../platformStats";
const CONTAINER = "https://stats.example.test/publicstats";
const MANIFEST_URL = `${CONTAINER}/platform-stats.json`;
const PAYLOAD_URL = `${CONTAINER}/platform-stats.2026-10-02T140005Z.json`;
/** What the backend publishes (MaintenanceService.TryPublishPlatformStatsJsonAsync). */
const MANIFEST = { latest: "platform-stats.2026-10-02T140005Z.json", generatedAtUtc: "2026-10-02T14:00:05.1234567Z" };
const PAYLOAD = {
totalEnrollments: 12481,
totalUsers: 310,
totalTenants: 92,
totalSignedUpTenants: 87,
uniqueDeviceModels: 214,
totalEventsProcessed: 8341206,
successfulEnrollments: 11020,
issuesDetected: 1847,
lastFullCompute: "2026-10-02T14:00:03.1234567Z",
lastUpdated: "2026-10-02T14:00:03.1234567Z",
};
const BAND = [
{ label: "enrollments monitored", value: "12,481" },
{ label: "issues detected", value: "1,847" },
{ label: "organisations", value: "87" },
{ label: "device models", value: "214" },
{ label: "events processed", value: "8.3M" },
];
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
/** Answers by URL; anything not listed is a 404. */
function serve(routes: Record<string, () => Response>) {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async input =>
routes[input]?.() ?? new Response("not found", { status: 404 }));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
const BOTH_FILES = { [MANIFEST_URL]: () => json(MANIFEST), [PAYLOAD_URL]: () => json(PAYLOAD) };
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("resolvePlatformStatsManifestUrl", () => {
it("falls back to the same-origin manifest without a configured URL", () => {
expect([undefined, "", " "].map(resolvePlatformStatsManifestUrl)).toEqual(Array(3).fill("/platform-stats.json"));
});
it("appends the manifest name to a container URL, with or without a trailing slash", () => {
expect(resolvePlatformStatsManifestUrl(CONTAINER)).toBe(MANIFEST_URL);
expect(resolvePlatformStatsManifestUrl(`${CONTAINER}/`)).toBe(MANIFEST_URL);
});
it("keeps a URL that already names a JSON file", () => {
expect(resolvePlatformStatsManifestUrl(MANIFEST_URL)).toBe(MANIFEST_URL);
expect(resolvePlatformStatsManifestUrl(`${CONTAINER}/custom.JSON`)).toBe(`${CONTAINER}/custom.JSON`);
});
it("keeps query and hash behind the appended manifest name", () => {
expect(resolvePlatformStatsManifestUrl(`${CONTAINER}?v=1#x`)).toBe(`${MANIFEST_URL}?v=1#x`);
});
});
describe("toStatItems", () => {
it("maps the band's figures in display order", () => {
expect(toStatItems(PAYLOAD)).toEqual(BAND);
});
it("leaves out figures that are missing, zero, negative or not a finite number", () => {
const payload = {
totalEnrollments: 0,
issuesDetected: -5,
totalSignedUpTenants: Number.POSITIVE_INFINITY,
uniqueDeviceModels: "214" as unknown as number,
totalEventsProcessed: 999_999,
};
expect(toStatItems(payload)).toEqual([{ label: "events processed", value: "999,999" }]);
expect(toStatItems({})).toEqual([]);
});
});
describe("fetchLivePlatformStats", () => {
it("revalidates the manifest and takes the immutable file it names from the browser cache", async () => {
const fetchMock = serve(BOTH_FILES);
expect(await fetchLivePlatformStats(MANIFEST_URL)).toEqual(PAYLOAD);
expect(fetchMock.mock.calls).toEqual([
[MANIFEST_URL, { cache: "no-cache" }],
[PAYLOAD_URL, { cache: "default" }],
]);
});
it("resolves the versioned file against the manifest as served, after redirects", async () => {
const redirected = json(MANIFEST);
Object.defineProperty(redirected, "url", { value: "https://stats.example.test/v2/platform-stats.json" });
const fetchMock = serve({
[MANIFEST_URL]: () => redirected,
"https://stats.example.test/v2/platform-stats.2026-10-02T140005Z.json": () => json(PAYLOAD),
});
expect(await fetchLivePlatformStats(MANIFEST_URL)).toEqual(PAYLOAD);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
const failures: [string, Record<string, () => Response>][] = [
["the manifest is missing", { [PAYLOAD_URL]: () => json(PAYLOAD) }],
["the manifest names no file", { [MANIFEST_URL]: () => json({ generatedAtUtc: MANIFEST.generatedAtUtc }) }],
["the manifest is not JSON", { [MANIFEST_URL]: () => new Response("<html></html>") }],
["the manifest names a file on another origin", { [MANIFEST_URL]: () => json({ latest: "https://elsewhere.example.test/stats.json" }) }],
["the versioned file is missing", { [MANIFEST_URL]: () => json(MANIFEST) }],
["the versioned file holds no object", { [MANIFEST_URL]: () => json(MANIFEST), [PAYLOAD_URL]: () => json(42) }],
];
for (const [name, routes] of failures) {
it(`returns null when ${name}`, async () => {
serve(routes);
expect(await fetchLivePlatformStats(MANIFEST_URL)).toBeNull();
});
}
it("returns null when the network fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
expect(await fetchLivePlatformStats(MANIFEST_URL)).toBeNull();
});
});
describe("loadPlatformStatsSnapshot", () => {
let log: MockInstance<typeof console.log>;
let warn: MockInstance<typeof console.warn>;
beforeEach(() => {
log = vi.spyOn(console, "log").mockImplementation(() => undefined);
warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
});
it("bakes nothing and sends no request without an absolute URL (local and CI builds)", async () => {
const fetchMock = serve(BOTH_FILES);
expect(await loadPlatformStatsSnapshot("/platform-stats.json")).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});
it("returns the band's items and the stats timestamp, read past Next's fetch cache within a deadline", async () => {
const fetchMock = serve(BOTH_FILES);
expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toEqual({ items: BAND, asOf: PAYLOAD.lastUpdated });
const inits = fetchMock.mock.calls.map(([, init]) => init);
expect(inits).toHaveLength(2);
for (const init of inits) {
expect(init?.cache).toBe("no-store");
expect(init?.signal).toBeInstanceOf(AbortSignal);
}
expect(inits[0]?.signal).toBe(inits[1]?.signal);
expect(log).toHaveBeenCalledWith(expect.stringContaining(`from ${MANIFEST.latest} (as of ${PAYLOAD.lastUpdated})`));
});
it("fails soft with the reason in the build log when the blob does not answer", async () => {
serve({ [MANIFEST_URL]: () => new Response("unavailable", { status: 503 }) });
expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("HTTP 503"));
});
it("names the network cause behind Node's generic fetch failure", async () => {
const failure = new TypeError("fetch failed", { cause: new Error("getaddrinfo ENOTFOUND stats.example.test") });
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(failure));
expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("fetch failed (getaddrinfo ENOTFOUND stats.example.test)"));
});
it("bakes nothing when the file holds no positive figure", async () => {
serve({ [MANIFEST_URL]: () => json(MANIFEST), [PAYLOAD_URL]: () => json({ ...PAYLOAD, totalEnrollments: 0, issuesDetected: 0, totalSignedUpTenants: 0, uniqueDeviceModels: 0, totalEventsProcessed: 0 }) });
expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("no positive figure"));
});
});
@@ -1201,3 +1201,81 @@ describe("isOnOffsetGrid", () => {
expect(isOnOffsetGrid(0)).toBe(false);
});
});
describe("reduceScriptEvents — correction from IME's saved result (D-316)", () => {
const POLICY = "d94468af-6ebe-4e94-9901-2a62d33ea6c1";
const OWN = "=== Autopilot Monitor Bootstrap Started =====\nBootstrap script version: v2.5\nInstaller finished (exit code 0).";
const FOREIGN = "VERBOSE: === Sample Suite Detection Started ===\nWARNING: === DETECTION FAILED ===";
const correction = (data: Record<string, unknown>, at = 60): ScriptInputEvent => ({
timestamp: ts(at),
eventType: "script_output_reconciliation",
data: { policyId: POLICY, scriptType: "platform", ...data },
});
it("a foreign end block is replaced: the script's own output, no exit code, Success", () => {
const items = reduceScriptEvents([
finalEvent({ eventType: "script_failed", data: { policyId: POLICY, scriptType: "platform", runId: "r1", result: "Success", exitCode: "1", stdout: FOREIGN } }),
correction({ runId: "r1", outcome: "foreign", result: "Success", stdout: OWN, stderr: "", previousExitCode: "1" }),
]);
expect(items).toHaveLength(1);
expect(items[0].state).toBe("Success");
expect(items[0].exitCode).toBeUndefined();
expect(items[0].stdout).toBe(OWN);
expect(items[0].stdout).not.toContain("Detection");
expect(items[0].outputCorrection).toBe("foreign");
expect(items[0].bootstrapVersion).toBe("2.5");
});
it("an incomplete output is completed and the exit code stands", () => {
const items = reduceScriptEvents([
finalEvent({ data: { policyId: POLICY, scriptType: "platform", runId: "r2", result: "Success", exitCode: "0" } }),
correction({ runId: "r2", outcome: "repaired", result: "Success", stdout: OWN, stderr: "" }),
]);
expect(items[0].exitCode).toBe(0);
expect(items[0].stdout).toBe(OWN);
expect(items[0].outputCorrection).toBe("repaired");
});
it("a stdout split into stderr is mended: the empty stderr clears the failure", () => {
const items = reduceScriptEvents([
finalEvent({ eventType: "script_failed", data: { policyId: POLICY, scriptType: "platform", runId: "r3", result: "Success", exitCode: "0", stdout: "Copying files", stderr: "0 errors\nDone, error =" } }),
correction({ runId: "r3", outcome: "repaired", result: "Success", stdout: "Copying files, error = 0 errors\nDone", stderr: "" }),
]);
expect(items[0].state).toBe("Success");
expect(items[0].stderr).toBeUndefined();
expect(items[0].stdout).toBe("Copying files, error = 0 errors\nDone");
});
it("a correction only applies to the run it names", () => {
const items = reduceScriptEvents([
finalEvent({ eventType: "script_failed", data: { policyId: POLICY, scriptType: "platform", runId: "r4", result: "Success", exitCode: "1", stdout: FOREIGN } }),
correction({ runId: "another-run", outcome: "foreign", result: "Success", stdout: OWN, stderr: "" }),
]);
expect(items[0].state).toBe("Failed");
expect(items[0].exitCode).toBe(1);
expect(items[0].outputCorrection).toBeUndefined();
});
it("a correction without a stdout key keeps the emitted stdout", () => {
const items = reduceScriptEvents([
finalEvent({ eventType: "script_failed", data: { policyId: POLICY, scriptType: "platform", runId: "r5", result: "Failed", exitCode: "1", stdout: "partial" } }),
correction({ runId: "r5", outcome: "repaired", result: "Failed", stderr: "Access denied" }),
]);
expect(items[0].stdout).toBe("partial");
expect(items[0].stderr).toBe("Access denied");
expect(items[0].state).toBe("Failed");
});
it("two runs of the same script never surface the corrected run's foreign output", () => {
const items = reduceScriptEvents([
finalEvent({ ts: 0, data: { policyId: POLICY, scriptType: "platform", runId: "old", result: "Success", exitCode: "0", runContext: "System", stdout: OWN } }),
finalEvent({ ts: 30, eventType: "script_failed", data: { policyId: POLICY, scriptType: "platform", runId: "r6", result: "Success", exitCode: "1", runContext: "System", stdout: FOREIGN } }),
correction({ runId: "r6", outcome: "foreign", result: "Success", stdout: OWN, stderr: "" }),
]);
expect(items).toHaveLength(1);
expect(items[0].stdout).toBe(OWN);
expect(items[0].stdout).not.toContain("Detection");
expect(items[0].state).toBe("Success");
expect(items[0].exitCode === 1).toBe(false);
});
});
@@ -95,6 +95,17 @@ describe("enrollmentWindowOf", () => {
expect(w).toEqual({ startMs: Date.parse("2026-09-09T06:32:46Z"), endMs: Date.parse("2026-09-09T06:40:00Z") });
});
it("ignores update and servicing rows backfilled from before the agent started", () => {
// D-315: the update watchers deliver rows with their event-log time up to 60 min before the agent.
const w = enrollmentWindowOf([
activity(1, "2026-10-03T08:51:58Z", "windows_update_started", "WindowsUpdateWatcher"),
activity(2, "2026-10-03T08:52:37Z", "windows_update_servicing", "ServicingWatcher"),
activity(3, "2026-10-03T08:56:46Z", "ime_agent_version", "ImeLogTracker"),
activity(4, "2026-10-03T10:08:09Z", "enrollment_complete", "DecisionEngine"),
]);
expect(w).toEqual({ startMs: Date.parse("2026-10-03T08:56:46Z"), endMs: Date.parse("2026-10-03T10:08:09Z") });
});
it("is null without activity events", () => {
expect(enrollmentWindowOf([activity(1, "2026-09-09T06:00:00Z", "system_clock_changed", "SystemTimelineWatcher")])).toBeNull();
expect(enrollmentWindowOf([])).toBeNull();
@@ -0,0 +1,180 @@
import type { PlatformStats } from "@/utils/wire-types.generated";
/**
* The landing page's platform stats. A public blob container holds a manifest
* (`platform-stats.json`, rewritten by the backend maintenance run every 2 hours) that names the
* versioned file with the numbers (`latest`, relative to the manifest).
*
* Read twice: once by `next build`, which bakes the numbers of that moment into the static HTML
* (crawlers and LLM fetchers run no JS and would otherwise see only a skeleton), and again by
* every browser, which swaps in the live numbers (components/landing/StatsBand.tsx). Both reads
* share the URL, fetch and formatting rules here.
*/
interface PlatformStatsManifest {
latest: string;
generatedAtUtc: string;
}
/**
* The versioned file. The backend writes it field by field under the names of the wire type
* PlatformStats; every field stays optional because the body is parsed, not trusted.
*/
export type PlatformStatsPayload = Partial<PlatformStats>;
export interface StatItem {
label: string;
value: string;
}
/** The numbers baked into the static HTML at build time. */
export interface PlatformStatsSnapshot {
items: StatItem[];
/** When the backend computed them (the payload's lastUpdated): page marker and build log. */
asOf: string | null;
}
export function resolvePlatformStatsManifestUrl(rawUrl?: string): string {
const trimmed = rawUrl?.trim();
if (!trimmed) {
return "/platform-stats.json";
}
const hashIndex = trimmed.indexOf("#");
const withoutHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
const hash = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
const queryIndex = withoutHash.indexOf("?");
const basePath = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash;
const query = queryIndex >= 0 ? withoutHash.slice(queryIndex) : "";
if (/\.json$/i.test(basePath)) {
return trimmed;
}
const normalizedBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
const manifestPath = `${normalizedBasePath}/platform-stats.json`;
return `${manifestPath}${query}${hash}`;
}
export const PLATFORM_STATS_MANIFEST_URL =
resolvePlatformStatsManifestUrl(process.env.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL);
/** Budget for both build-time requests together: a slow blob must not stall the deploy. */
const BUILD_FETCH_TIMEOUT_MS = 10_000;
/** 8,341,206 → "8.3M"; smaller values keep their grouped form. */
function compact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
return n.toLocaleString("en-US");
}
function grouped(n: number): string {
return n.toLocaleString("en-US");
}
/** The band's figures in display order. */
const STAT_FIELDS: { field: keyof PlatformStats; label: string; format: (n: number) => string }[] = [
{ field: "totalEnrollments", label: "enrollments monitored", format: grouped },
{ field: "issuesDetected", label: "issues detected", format: grouped },
{ field: "totalSignedUpTenants", label: "organisations", format: grouped },
{ field: "uniqueDeviceModels", label: "device models", format: grouped },
{ field: "totalEventsProcessed", label: "events processed", format: compact },
];
/**
* The band's items. Only finite positive numbers count: the values end up in the static HTML,
* and a zero means "not computed yet", not a figure to publish.
*/
export function toStatItems(payload: PlatformStatsPayload): StatItem[] {
return STAT_FIELDS.flatMap(({ field, label, format }) => {
const value = payload[field];
return typeof value === "number" && Number.isFinite(value) && value > 0
? [{ label, value: format(value) }]
: [];
});
}
/** One read: the numbers and the versioned file they came from (named in the build log). */
interface PlatformStatsRead {
payload: PlatformStatsPayload;
file: string;
}
/**
* Manifest, then the file it names. Throws with the reason; the callers decide what to report.
* Each hop takes its own request options: the manifest changes with every publish, a versioned
* file never does.
*/
async function readPlatformStats(
manifestUrl: string,
manifestInit: RequestInit,
payloadInit: RequestInit = manifestInit,
): Promise<PlatformStatsRead> {
const manifestResponse = await fetch(manifestUrl, manifestInit);
if (!manifestResponse.ok) throw new Error(`${manifestUrl} answered HTTP ${manifestResponse.status}`);
const manifest = (await manifestResponse.json()) as Partial<PlatformStatsManifest> | null;
if (typeof manifest?.latest !== "string" || !manifest.latest) {
throw new Error(`${manifestUrl} names no versioned file`);
}
// Relative to the manifest as served (after redirects); a hand-built Response has no url.
const base = manifestResponse.url || manifestUrl;
const payloadUrl = new URL(manifest.latest, base);
if (payloadUrl.origin !== new URL(base).origin) {
throw new Error(`${manifestUrl} names a file on another origin`);
}
const payloadResponse = await fetch(payloadUrl.toString(), payloadInit);
if (!payloadResponse.ok) throw new Error(`${payloadUrl} answered HTTP ${payloadResponse.status}`);
const payload: unknown = await payloadResponse.json();
if (typeof payload !== "object" || payload === null) throw new Error(`${payloadUrl} holds no stats object`);
return { payload: payload as PlatformStatsPayload, file: manifest.latest };
}
/**
* The browser's read. The manifest is revalidated on every load; the versioned file it names
* comes from the browser cache once fetched: the backend writes a new name per publish and serves
* it immutable (MaintenanceService.PlatformStatsVersionedFileName).
*/
export async function fetchLivePlatformStats(
manifestUrl: string = PLATFORM_STATS_MANIFEST_URL,
): Promise<PlatformStatsPayload | null> {
try {
return (await readPlatformStats(manifestUrl, { cache: "no-cache" }, { cache: "default" })).payload;
} catch {
// The band is decorative: a failed read only means no live numbers.
return null;
}
}
/**
* The build's read (app/page.tsx), once per `next build`. Only for an absolute URL: local and CI
* builds have none (the relative fallback exists only in a browser) and bake nothing.
* `no-store` keeps the read out of Next's fetch cache, which would otherwise hand the next build
* on the same machine the previous numbers; the page is `force-static`, so `no-store` stays
* legal in the static export. Fail-soft: without a snapshot the page ships the skeleton and
* browsers load the numbers live, as before; deploy-web.yml reports it as a warning.
*/
export async function loadPlatformStatsSnapshot(
manifestUrl: string = PLATFORM_STATS_MANIFEST_URL,
): Promise<PlatformStatsSnapshot | null> {
if (!/^https?:\/\//i.test(manifestUrl)) return null;
try {
const { payload, file } = await readPlatformStats(manifestUrl, {
cache: "no-store",
signal: AbortSignal.timeout(BUILD_FETCH_TIMEOUT_MS),
});
const items = toStatItems(payload);
if (items.length === 0) throw new Error(`${file} holds no positive figure`);
const asOf = typeof payload.lastUpdated === "string" ? payload.lastUpdated : null;
console.log(`Platform stats baked into the landing page from ${file} (as of ${asOf ?? "unknown"}).`);
return { items, asOf };
} catch (err) {
// Node's fetch reports every network failure as "fetch failed" and keeps the reason in `cause`.
const cause = err instanceof Error && err.cause instanceof Error ? ` (${err.cause.message})` : "";
const reason = err instanceof Error ? `${err.message}${cause}` : String(err);
console.warn(`Platform stats not baked into the landing page; browsers still load them live. ${reason}`);
return null;
}
}
@@ -36,8 +36,19 @@ interface ScriptEventData {
sourceOffsetOrigin?: string;
/** "true" when the line's own timestamp was rejected and the event was stamped from the clock. */
derivedTimestamp?: string | boolean;
/** Identity of one platform-script run; a script_output_reconciliation names the run it corrects (agents from 2026-10 on). */
runId?: string;
/** On script_output_reconciliation: "foreign" (the run had another script's end block) or "repaired". */
outcome?: string;
}
/**
* A platform run corrected after the fact from IME's saved result (D-316). Its `runId` names the
* script_completed/script_failed it corrects; a present `stdout`/`stderr` key replaces the emitted
* value (empty clears it), `outcome` "foreign" makes the emitted exit code unverified.
*/
export const SCRIPT_OUTPUT_RECONCILIATION = "script_output_reconciliation";
export interface ScriptItem {
policyId: string;
scriptType: string; // "platform" | "remediation"
@@ -80,6 +91,12 @@ export interface ScriptItem {
state: "Running" | "Success" | "Failed";
timestamp: string;
bootstrapVersion?: string | null;
/**
* Set when IME's saved result corrected the run (script_output_reconciliation): "foreign" — the
* log had given the run another script's end block, so the output shown is the script's own from
* IME and the exit code is unknown; "repaired" — the log had delivered the output incomplete.
*/
outputCorrection?: "foreign" | "repaired";
/**
* Set by applyObservationEnd, never by the fold: the script was still running when the
* session's observation ended. Terminal, neither a failure nor a success — the outcome was
@@ -198,6 +215,9 @@ export function partitionHistoricScriptEvents(events: ScriptInputEvent[]): Histo
function dataCompleteness(item: ScriptItem): number {
let score = 0;
if (item.exitCode != null) score += 4; // exit code is the most important signal
// A run corrected from IME's saved result lost a foreign exit code, not data: it must not lose
// the merge against a sibling run for that.
if (item.outputCorrection === "foreign") score += 4;
if (item.result) score += 2;
if (item.complianceResult) score += 2;
if (item.remediationStatus != null) score += 1;
@@ -224,6 +244,18 @@ export function toNumber(v: unknown): number | undefined {
return undefined;
}
/**
* The value a correction publishes for `key`, or the emitted one: a present key replaces it (an
* empty string clears it), an absent key keeps it.
*/
function correctedText(correction: ScriptEventData | undefined, key: "stdout" | "stderr", emitted: unknown): string | undefined {
if (correction && typeof correction[key] === "string") {
const value = correction[key] as string;
return value.length > 0 ? value : undefined;
}
return typeof emitted === "string" ? emitted : undefined;
}
/** Map RemediationStatus enum to the human-readable label shown in the detail panel. */
export function mapRemediationStatus(status?: number): string | null {
switch (status) {
@@ -378,7 +410,14 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
// script_started events per (policyId, scriptType) in timestamp order — the start line a
// final is paired with when its run duration is judged (see judgeDurationPair).
const startsByPolicy = new Map<string, ScriptInputEvent[]>();
// IME's saved result corrects a platform run after the fact; the correction names the run.
const correctionsByRunId = new Map<string, ScriptEventData>();
for (const evt of sorted) {
if (evt.eventType === SCRIPT_OUTPUT_RECONCILIATION) {
const c = evt.data as ScriptEventData | undefined;
if (c && typeof c.runId === "string" && c.runId.length > 0) correctionsByRunId.set(c.runId, c);
continue;
}
if (evt.eventType !== "script_started") continue;
const d = evt.data as ScriptEventData | undefined;
const startPolicyId = d?.policyId ?? d?.policy_id;
@@ -402,7 +441,10 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
const key = `${dedupeId}-${scriptType}-${scriptPart ?? ""}`;
if (policyId) policyIdsWithFinal.add(`${policyId}-${scriptType}`);
const exitCode = toNumber(d.exitCode ?? d.exit_code);
const correction = typeof d.runId === "string" ? correctionsByRunId.get(d.runId) : undefined;
const outputCorrection = correction ? (correction.outcome === "foreign" ? "foreign" : "repaired") : undefined;
// A foreign end block's exit code belongs to another script: unknown, not the emitted value.
const exitCode = outputCorrection === "foreign" ? undefined : toNumber(d.exitCode ?? d.exit_code);
// Duration normalization — the wire field durationSeconds carries two different
// semantics, named by durationBasis (see the ScriptItem field docs):
@@ -435,8 +477,9 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
const remediationStatus = toNumber(d.remediationStatus ?? d.remediation_status);
const targetType = toNumber(d.targetType ?? d.target_type);
const errorCode = toNumber(d.errorCode ?? d.error_code);
const stdout = typeof d.stdout === "string" ? d.stdout : undefined;
const stderr = typeof d.stderr === "string" ? d.stderr : undefined;
const stdout = correctedText(correction, "stdout", d.stdout);
const stderr = correctedText(correction, "stderr", d.stderr);
const result = (typeof correction?.result === "string" ? correction.result : undefined) ?? d.result;
const hasStderr = !!stderr && stderr.trim().length > 0;
// State derivation — rules in priority order (mirrors the agent's EmitScriptCompleted):
@@ -450,8 +493,10 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
// 3. Defensive: explicit script_failed eventType OR result === "Failed" → Failed.
const isHealthComplianceReport = scriptType === "remediation"
&& (scriptPart === "detection" || scriptPart === "post-detection");
const isFailureSignal = evt.eventType === "script_failed"
|| d.result === "Failed"
// A corrected run is judged on its corrected data: the agent chose script_failed from the
// emitted values the correction replaced.
const isFailureSignal = (!correction && evt.eventType === "script_failed")
|| result === "Failed"
|| (!isHealthComplianceReport && (hasStderr || (exitCode != null && exitCode !== 0)));
const candidate: ScriptItem = {
@@ -460,7 +505,7 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
scriptPart,
runContext: d.runContext ?? d.run_context,
exitCode,
result: d.result,
result,
complianceResult: d.complianceResult ?? d.compliance_result,
remediationStatus,
targetType,
@@ -476,6 +521,7 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
timestamp: evt.timestamp,
bootstrapVersion: scriptType === "platform" ? extractBootstrapVersion(stdout) : null,
isIncomplete: false,
outputCorrection,
};
// Keep the most-complete entry, but merge the duration fields across both: the
+10 -5
View File
@@ -1,4 +1,8 @@
import type { EnrollmentEvent } from "@/types";
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
/** Sources whose rows never set a session's start (backend `Constants.EventSources.SessionAnchorIneligible`). */
const ANCHOR_INELIGIBLE_SOURCES: ReadonlySet<string> = new Set(SHARED_MANIFEST.sessionAnchorIneligibleSources);
export interface StandbyWindow {
startMs: number;
@@ -7,17 +11,18 @@ export interface StandbyWindow {
/**
* The enrollment window as the session page measures its duration: first activity event →
* `enrollment_complete`, or the last activity event while no verdict exists. System-timeline
* rows (clock steps, sleep episodes) are excluded — they are backfilled from the Windows event
* log with timestamps up to 24 h before the agent started and would drag the start into
* pre-enrollment idle time. Null when the session has no activity events.
* `enrollment_complete`, or the last activity event while no verdict exists. Backfilled
* environment rows (clock steps, sleep, update and servicing activity) are excluded, as in the
* backend's session anchor — they carry Windows event-log timestamps from before the agent
* started and would drag the start into pre-enrollment time. Null when the session has no
* activity events.
*/
export function enrollmentWindowOf(events: EnrollmentEvent[]): StandbyWindow | null {
let startMs = Number.POSITIVE_INFINITY;
let lastMs = Number.NEGATIVE_INFINITY;
let completeMs: number | null = null;
for (const e of events) {
if (e.source === "SystemTimelineWatcher") continue;
if (ANCHOR_INELIGIBLE_SOURCES.has(e.source)) continue;
const t = new Date(e.timestamp).getTime();
if (!Number.isFinite(t)) continue;
if (t < startMs) startMs = t;
@@ -18,7 +18,7 @@ const OUTPUT_FILE = path.join(WEB_ROOT, "utils/page-lastmod.generated.ts");
// fails the build for any URL missing here. Documentation and the changelog
// live on docs.autopilotmonitor.com (GitBook) and are not part of this site.
const PAGE_MAP = {
"/": ["app/page.tsx"],
"/": ["app/page.tsx", "components/landing/LandingPage.tsx"],
"/about": ["app/about/page.tsx", "app/about/faq.ts"],
"/ai": ["app/ai/page.tsx", "components/ai/questions.ts"],
"/get-started": ["app/get-started/page.tsx"],
@@ -503,6 +503,11 @@ export const SHARED_MANIFEST = {
"ime",
"realmjoin"
],
"sessionAnchorIneligibleSources": [
"SystemTimelineWatcher",
"WindowsUpdateWatcher",
"ServicingWatcher"
],
"ruleSubmissionKinds": [
"gather",
"analyze"
@@ -895,6 +900,7 @@ export const SHARED_MANIFEST = {
"remote_config_fetch_failed",
"script_completed",
"script_failed",
"script_output_reconciliation",
"script_recurrence_summary",
"script_started",
"script_timeout_suspected",
@@ -499,6 +499,11 @@
"ime",
"realmjoin"
],
"sessionAnchorIneligibleSources": [
"SystemTimelineWatcher",
"WindowsUpdateWatcher",
"ServicingWatcher"
],
"ruleSubmissionKinds": [
"gather",
"analyze"
@@ -891,6 +896,7 @@
"remote_config_fetch_failed",
"script_completed",
"script_failed",
"script_output_reconciliation",
"script_recurrence_summary",
"script_started",
"script_timeout_suspected",