Compare commits

...

43 Commits

Author SHA1 Message Date
Oliver Kieselbach d1063f17cb bump pointer: internal (D-320, concept docs, plan oobe-update-outcome-and-late-start)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 11:27:58 +02:00
Oliver Kieselbach ed517b5b40 Agent: the first run reads the OOBE update page back an hour; live and backfilled names have their own budgets (D-320)
The first run has no Shell-Core replay (the restart replay re-raises the Hello-wizard start), so an
agent installed late during the ESP missed the update page's start and first half (6a7d241d).

- BackfillUpdatePageTelemetry: on the first run the last 60 min of Shell-Core become oobe_update_page
  rows and nothing else — no Hello replay, no ESP exit or failure, no agent_trace, no registry read.
- Live and backfilled names have budgets of 150 each; the first name over a budget sends a
  names_capped marker, so a missing name is never read as "did not happen".
- A backfill batch keeps its own last page and moves the live watcher's only with a newer RecordId:
  a restart replay's older page start no longer takes over the next live stop, and a first run's
  live stop finds the page that started before the agent.

Agent suite 3,436 green; the agent stays on net48.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 11:27:08 +02:00
Oliver Kieselbach 46058ffb90 OOBE quality update: how it ended, a begin without the page start, three rules for the update that did not happen (D-320)
Field measurement 3: a first-run agent that started after the update page (6a7d241d) left the whole
update in Apps (ESP), failed and skipped updates were reported by nothing, and the lane listed a
failed update's staged servicing stack as an installed KB.

- Time attribution v5: update names before the first observed OobeNDUP start begin the update at the
  earlier of the first name and the update's first servicing step (after the window's ESP exit when
  one was seen before the name, else the first step of a package the update also serviced later).
  The fallback's ESP exit is per window, so a WhiteGlove part-1 exit no longer begins an update in
  part 2. OsUpdateSpan carries Outcome (installed/failed/skipped/unknown, the latest evidence wins)
  and NotInstalledKbs; Kbs lists only packages that reached Installed. The 2-h sweep recomputes the
  last 30 days.
- ANALYZE-DEV-013 (update failed), DEV-014 (not offered after an earlier failure), DEV-015 (update
  check ended without a result): warning, never KO; a failure only from explicit names, every
  absence condition gated on the agent's names_capped marker; never two at once.
- ShellCoreTracker rows never set the session start (D-315 list), ahead of the agent's first-run
  update page backfill.
- The web lane names the outcome and the KBs not installed; MCP get_time_attribution mentions both.

Real-event replay: 6a7d241d os_update 2358 s installed (was 55 min in esp_apps), e7551b82 failed with
KB5124007 not installed. Rule dry runs and a sweep over all 97 update-page sessions since 2026-10-02
hit exactly the five field sessions. Functions 6,918, web 1,701, MCP 997 green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 11:26:57 +02:00
Oliver Kieselbach 81d0fe6ed3 Row accents survive the list divider on every row, not just the first
Tailwind 3's divide-<color> rule (`> :not([hidden]) ~ :not([hidden])`,
specificity 0,3,0, plus the dark-mode !important overrides in globals.css)
recolours every child but the first on all sides, so a row's own border
accent only ever showed on the first row.

- Notification dropdown: the purple GA and blue tenant bars are inset
  shadows now, so every such row keeps its bar.
- Geographic performance: the red outlier bar moves from the row to its
  first cell; it was grey on every outlier but the first table row.
- Tenant config report: the heavier separator between notification
  channels and between self-hosted AI clients moves to the cell
  (GROUP_SEPARATOR); the divider had shrunk it to a plain 1px line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 09:33:47 +02:00
Oliver Kieselbach a26cd5edf7 bump pointer: internal (D-319 released, agent 2.0.1477; B-bmk narrowed to the field verify)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 09:23:51 +02:00
Oliver Kieselbach 48e9702fd2 bump pointer: internal (D-319, concept docs, plan rewound-line-freshness, B-bmk)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 09:07:10 +02:00
Oliver Kieselbach b560cb13b6 A re-processed IME log entry anchors itself when the bytes it replaced were seen moments ago (D-319)
A concurrent IME writer overwrites lines behind the bookmark, and the overwrite check recovers them a
pass later. Recovered lines were never fresh, so they resolved in the agent's own cached zone: in a lab
the zone was stale (+60, IME wrote +120), recovered result lines landed an hour ahead of the clock and
the platform-script durations were omitted ("n/a" on Install Autopilot Monitor, session d29e27e3, also
on 2.0.1474/2.0.1467). Bytes that read differently at a position were written after that position was
last seen, and IME stamps a line when it writes it, so the last sighting bounds the new line's age.

- Ledger entries carry SeenUtc: set when read or seeded, renewed whenever a verification finds them
  unchanged.
- A changed entry below the old bookmark is fresh when the pass is, the ledger held the entry at the
  rewind point, and the replaced bytes were seen 0-30 s ago; otherwise the fallback as before.
  Re-processed entries are never calibration anchors.

Field replay and longer-running script red before; six mutation proofs; agent suite 3,426 green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 09:07:09 +02:00
Oliver Kieselbach b0f866dc7d bump pointer: internal (OOBE update field measurement 3, D-315 verified)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 08:57:03 +02:00
Oliver Kieselbach f3460ea828 bump pointer: internal (D-318 released, agent 2.0.1476; B-gc8 narrowed to the field verify)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 20:12:25 +02:00
Oliver Kieselbach d88d4a143b bump pointer: internal (D-318, concept docs, plan platform-script-retry-pairing, B-gc8)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 18:29:50 +02:00
Oliver Kieselbach 913dfa13fe One platform run per executor start; IME results pair with the run that started last before them, the fallback judges by IME's rule (D-318)
IME re-runs a failed platform script on later check-ins, and the agent's first pass reads all of
AgentExecutor.log before the IME log, so several runs of one policy shared one slot: the first IME
result took the LAST run's exit code and output (session eff1b413: the bootstrap script "Failed
(exit 0)" with the stdout of its successful retry), every later result went out without an end
block. The exit-code fallback said Success for exit 0 with stderr and Failed for exit 1 without,
which IME never does.

- A later timestamped executor start parks the previous run of its policy (persisted, max 64); an
  IME result pairs with the run of its policy and user that started last before the result line;
  earlier parked runs of the same user resolve then, so emits keep run order.
- Invocation markers carry the run id; the emitted marker only rises; no dedup path deletes a live
  run; a parked emit never takes the later run's D-316 register entry.
- Fallback: Success exactly when the stderr is empty, the exit code only when no stderr line was
  read; "write output done … error =" replaces the "error from script" snapshot, empty stdout too.

Backfill replays fail on the old code; nine mutation proofs; agent suite 3,416 green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 18:29:33 +02:00
Oliver Kieselbach 51345718c6 bump pointer: internal (D-316/D-317 released, agent 2.0.1475; B-qrz narrowed to the field verify)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:46:47 +02:00
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
Oliver Kieselbach e7dd962133 bump pointer: internal (time attribution v4 D-314, OOBE update page agent fixes, backlog B-q3z/B-6dx/B-dbf; member list lesson)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 22:57:10 +02:00
Oliver Kieselbach 14a67ef5af Time attribution shows the OOBE quality update as Windows Update and the wait after its restart as Waiting for sign-in (D-314)
TimeAttributionCalculator v4 cuts two segments out of the phase partition:
os_update from the OobeNDUP page start whose visit holds update evidence
(a servicing step or a name only an update in progress writes) to the last
update evidence before the user is back; awaiting_sign_in from there to the
first evidence of the user, only after a restart. Both replace the phase
span they overlap; without evidence the partition is v3's. OsUpdates names
the KBs and restarts. Reboot gaps no longer start or end on the clock-sync
row logged at boot. The sweep recomputes the last 30 days.

Aggregates carry eight segments; the session lane and Fleet Health show
"Windows Update" (cyan) and "Waiting for sign-in" (sky) with the KBs and
restarts in legend and tooltip; MCP get_time_attribution description;
manifest and wire types regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 22:55:45 +02:00
Oliver Kieselbach 2371855b77 OOBE update page: fields from event properties in every display language, page start and stop never capped, no duplicates after a restart
ShellCoreTracker reads CXID, result, name and value from the event properties
(62404..62407 %1/%2), live and in the backfill. The formatted message is
localized: German devices lost result and value, French devices reported no
update page at all. The text patterns stay as the fallback and accept
guillemets. The Hello-wizard start reads the CXID property too.

OobeUpdateTelemetry: page start and stop sit outside the run budget (at most
3 per page), names share a budget of 150 (was 50; the cloud page logs ~45
init names in its first 40 s). ReportPage returns whether it reported.

A RecordId watermark (shellcore-page-watermark.json) claims every update-page
record and persists reported ones, so the restart backfill does not send the
minutes before the restart again. EspAndHelloTracker passes the state
directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 22:55:39 +02:00
Oliver Kieselbach 3a50de83ef Add-member input keeps a minimum width and wraps instead of shrinking (D-313)
The form no longer lays out by viewport breakpoint: a wide window can host a
narrow column next to the settings sidebar, where the input shrank to a few
characters. The input now grows from an 18rem basis and wraps below the type
switch when the row has no room; role and Add wrap together.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 22:51:28 +02:00
Oliver Kieselbach 24dfd247e9 bump pointer: internal (viewer app role + member list live, D-313, lesson)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 21:18:14 +02:00
Oliver Kieselbach a640039518 Member role changes need an explicit Save; the GA add form gives the input its own line (D-312)
- An open member row keeps role and bootstrap permission as a draft:
  Save writes it, Discard or closing the row drops it, so a stray select
  change no longer alters anyone's access.
- AddMemberForm takes `stacked` for narrow hosts; the GA tenant editor
  modal uses it, so the email field spans the full width there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 21:13:11 +02:00
Oliver Kieselbach c3e9011905 bump pointer: internal (D-312, plan viewer-app-role-and-member-list, B-8gf/B-7w2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 20:36:37 +02:00
Oliver Kieselbach 8f6926a945 Viewer can come from an Entra app role; Access Management lists one line per member (D-312)
- EntraAppRoleResolver maps the Viewer app role next to Admin and Operator
  (the highest role wins); a claim Viewer gets no bootstrap-token rights.
- The tenant settings page and the GA tenant editor share components/members:
  one line per member, a click opens who added it and the actions, Remove
  asks in the row, role chips with counts, a search, 25 rows per page. A
  vanished chip or a shrunken list moves the selection, so the list never
  shows an empty page and never snaps back.
- The add form sits above the list, About Roles is collapsed, and a tenant
  with Entra app roles enabled sees that those users are not listed.
- The EntraAppRolesEnabled doc names Viewer; manifest and generated wire
  types regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 20:32:54 +02:00
Oliver Kieselbach 01323fdf6d ProgressAccessHint test matches the docs link as a substring, not a RegExp
CodeQL js/incomplete-hostname-regexp (alert #96) traced DOCS_URL into the
RegExp built in the "Learn more" assertion. The dots were escaped with
[.], which CodeQL does not recognize as a sanitizer. A substring match
on the exact markup needs no escaping and keeps the same checks: a space
after the sentence, the exact href with target/rel, and "Learn more" as
the link text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 20:17:46 +02:00
Oliver Kieselbach c566ed818b bump pointer: internal (access hint released, B-u5a verify only)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 17:01:38 +02:00
Oliver Kieselbach 3a40361bb6 bump pointer: internal (access-hint follow-up, docs-corpus URL invariant)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:54:52 +02:00
Oliver Kieselbach 1b988d2943 Docs links use the URL GitBook publishes: access hint via the docs path registry, MCP citations from SUMMARY.md groups
GitBook publishes a page under the slug of its SUMMARY.md group, not its
folder: troubleshooting/ is /troubleshooting-and-support/, trust/ is
/trust-and-security/. A folder URL works only for pages that predate the
group rename (307); for a newer page it is a 404.

- Web: the Progress Portal access hint linked the new troubleshooting
  article by folder path (404 live). It now uses DOCS_PATHS.progressPortalOnly.
- MCP: docUrl built folder URLs for every search_docs citation. Live census
  over all 58 SUMMARY pages: 47 direct, 10 via redirect, 1 dead.
  summaryFolderSlugs now derives the segments from SUMMARY.md; all 60 page
  URLs of the real corpus answer 200.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:54:40 +02:00
Oliver Kieselbach e88a0e455c Progress Portal access hint links to its troubleshooting article
Both variants end with a muted "Learn more" to
troubleshooting/progress-portal-only in the customer docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:35:37 +02:00
Oliver Kieselbach 0695f7943c bump pointers: internal (D-311, plan progress-portal-access-hint, B-zr9/B-u5a), commands (platform-health marker)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:20:50 +02:00
Oliver Kieselbach 6f8e7d48b2 Progress Portal tells members without a role whom to ask for access; activation lands every role where sign-in would
A member without a portal role only ever sees the Progress Portal and never
learned that their organization already runs the full product. Below the
search they now see one muted line: ask your Autopilot Monitor admin for a
role. When the organization holds no session and signed up more than 14 days
ago, the line names the signup date and the support mailbox instead.

- GET progress/tenant-status (AuthenticatedUser, JWT tenant only, no session
  data): unused flag plus signup date. Both reads are strict, so a storage
  failure is a 500 and never reads as "unused".
- ISessionRepository.HasAnySessionStrictAsync: key-only probe on
  SessionsIndex with the primary-table fallback, no catch.
- ProgressTenantStatusResponse + wire parity facts; regenerated manifests and
  web/MCP wire types.
- lib/landingTarget.ts: one landing decision for the landing gate and the
  activation page. Leaving activation no longer sends Operators, Viewers,
  Global Readers and delegated admins to the Progress Portal, and it keeps
  the deep link the landing gate saved during activation.
- lib/supportContact.ts: one source for the support address.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:20:38 +02:00
Oliver Kieselbach 0248d14738 bump pointer: internal (IME diff notes 1.106.106.0 + 1.105.154.0, B-4v2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:01:05 +02:00
Oliver Kieselbach dc59fea6e5 bump pointer: internal (OOBE update phase: DEV-004 live, agent v2.0.1473 in Shadow)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:28:34 +02:00
134 changed files with 9535 additions and 1104 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
+118
View File
@@ -0,0 +1,118 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-DEV-013",
"title": "OOBE Quality Update Failed",
"description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "update_failure",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "in",
"value": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
"required": true
},
{
"signal": "no_install_success",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
"required": true
},
{
"signal": "os_build_unchanged",
"source": "event_type",
"eventType": "os_build_changed",
"operator": "not_exists",
"value": "",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
},
{
"signal": "update_page_failed",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "result",
"operator": "equals",
"value": "fail",
"filterField": "page",
"filterOperator": "equals",
"filterValue": "OobeNDUP",
"required": false
},
{
"signal": "update_skipped",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "regex",
"value": "SkipDownloadInstallButtonClicked$",
"required": false
}
],
"confidenceFactors": [
{
"signal": "update_page_failed",
"condition": "exists",
"weight": 10
}
],
"explanation": "The Windows quality update page at the end of OOBE reported `{{update_failure}}`, and no successful install was recorded afterwards. When the download or install of the update fails, the page gives up and setup continues, so the enrollment itself can still succeed — but the device most likely finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. Selecting Skip on the page after the failure leads to the same result.",
"remediation": [
{
"title": "Find out why the update failed",
"steps": [
"Open the oobe_update_page events around the failure in the session timeline: the download progress before it shows how far the download got, and a Skip after it shows that someone chose to continue.",
"Check the network path the device uses during OOBE for Windows Update and Delivery Optimization: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
"Compare with other devices: the same failure on many devices of one site points at the network path, on one model at the device, and across the fleet at the update itself."
]
},
{
"title": "Make sure the device gets the update",
"steps": [
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule.",
"Where the OOBE quality update keeps failing for one network or device group, decide per Enrollment Status Page profile whether \"Install Windows quality updates\" should stay on."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
}
+119
View File
@@ -0,0 +1,119 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-DEV-014",
"title": "OOBE Quality Update Not Offered After an Earlier Failure",
"description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "previous_failures",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount",
"required": true
},
{
"signal": "nothing_offered",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "equals",
"value": "false",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI",
"required": true
},
{
"signal": "days_out_of_date",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
"required": true
},
{
"signal": "no_update_failure",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "in",
"filterValue": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
"required": true
},
{
"signal": "no_install_success",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
}
],
"explanation": "The Windows quality update page at the end of OOBE checked for updates and found none to install, although Windows reports the device as {{days_out_of_date}} days out of date. The page's failure counter for this device stands at {{previous_failures}}: an earlier download or install of an OOBE quality update on this device failed. The device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment.",
"remediation": [
{
"title": "Check the earlier attempt",
"steps": [
"Look at the device's earlier enrollment sessions — the failed attempt and its ANALYZE-DEV-013 finding are usually there.",
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
]
},
{
"title": "Reduce how far behind devices start",
"steps": [
"Devices that start OOBE many months behind need a long update during setup; a current OS image or factory build shortens it and gives a failed attempt less to repeat."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
}
+117
View File
@@ -0,0 +1,117 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-DEV-015",
"title": "OOBE Update Check Ended Without a Result",
"description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "update_allowed",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "equals",
"value": "true",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded",
"required": true
},
{
"signal": "scan_started",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "equals",
"value": "ExpeditedUpdate_startWUScanStartedSuccedded",
"required": true
},
{
"signal": "no_scan_result",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "^ExpeditedUpdate_getUpdateResults",
"required": true
},
{
"signal": "update_page_ended",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "equals",
"value": "page_stopped",
"filterField": "page",
"filterOperator": "equals",
"filterValue": "OobeNDUP",
"required": true
},
{
"signal": "days_out_of_date",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
}
],
"explanation": "The Windows quality update page at the end of OOBE started a Windows Update scan, but no scan result arrived before the page ended. The device is {{days_out_of_date}} days out of date, so an update was due — the device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. The page waits only a short time for the scan (two minutes in the observed sessions): a scan that takes longer, or cannot reach Windows Update, ends the page without an update.",
"remediation": [
{
"title": "Check the path to Windows Update during OOBE",
"steps": [
"Verify that devices in OOBE reach the Windows Update service: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
"Check whether an update policy applied during the Enrollment Status Page points the scan at another source, such as WSUS."
]
},
{
"title": "Shorten the scan",
"steps": [
"Devices far behind scan longer; a current OS image or factory build keeps the OOBE scan short.",
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
}
+351
View File
@@ -1930,6 +1930,357 @@
"environment"
]
},
{
"ruleId": "ANALYZE-DEV-013",
"title": "OOBE Quality Update Failed",
"description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "update_failure",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "in",
"value": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
"required": true
},
{
"signal": "no_install_success",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
"required": true
},
{
"signal": "os_build_unchanged",
"source": "event_type",
"eventType": "os_build_changed",
"operator": "not_exists",
"value": "",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
},
{
"signal": "update_page_failed",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "result",
"operator": "equals",
"value": "fail",
"filterField": "page",
"filterOperator": "equals",
"filterValue": "OobeNDUP",
"required": false
},
{
"signal": "update_skipped",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "regex",
"value": "SkipDownloadInstallButtonClicked$",
"required": false
}
],
"confidenceFactors": [
{
"signal": "update_page_failed",
"condition": "exists",
"weight": 10
}
],
"explanation": "The Windows quality update page at the end of OOBE reported `{{update_failure}}`, and no successful install was recorded afterwards. When the download or install of the update fails, the page gives up and setup continues, so the enrollment itself can still succeed — but the device most likely finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. Selecting Skip on the page after the failure leads to the same result.",
"remediation": [
{
"title": "Find out why the update failed",
"steps": [
"Open the oobe_update_page events around the failure in the session timeline: the download progress before it shows how far the download got, and a Skip after it shows that someone chose to continue.",
"Check the network path the device uses during OOBE for Windows Update and Delivery Optimization: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
"Compare with other devices: the same failure on many devices of one site points at the network path, on one model at the device, and across the fleet at the update itself."
]
},
{
"title": "Make sure the device gets the update",
"steps": [
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule.",
"Where the OOBE quality update keeps failing for one network or device group, decide per Enrollment Status Page profile whether \"Install Windows quality updates\" should stay on."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
},
{
"ruleId": "ANALYZE-DEV-014",
"title": "OOBE Quality Update Not Offered After an Earlier Failure",
"description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "previous_failures",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount",
"required": true
},
{
"signal": "nothing_offered",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "equals",
"value": "false",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI",
"required": true
},
{
"signal": "days_out_of_date",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
"required": true
},
{
"signal": "no_update_failure",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "in",
"filterValue": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
"required": true
},
{
"signal": "no_install_success",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
}
],
"explanation": "The Windows quality update page at the end of OOBE checked for updates and found none to install, although Windows reports the device as {{days_out_of_date}} days out of date. The page's failure counter for this device stands at {{previous_failures}}: an earlier download or install of an OOBE quality update on this device failed. The device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment.",
"remediation": [
{
"title": "Check the earlier attempt",
"steps": [
"Look at the device's earlier enrollment sessions — the failed attempt and its ANALYZE-DEV-013 finding are usually there.",
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
]
},
{
"title": "Reduce how far behind devices start",
"steps": [
"Devices that start OOBE many months behind need a long update during setup; a current OS image or factory build shortens it and gives a failed attempt less to repeat."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
},
{
"ruleId": "ANALYZE-DEV-015",
"title": "OOBE Update Check Ended Without a Result",
"description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "update_allowed",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "equals",
"value": "true",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded",
"required": true
},
{
"signal": "scan_started",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "equals",
"value": "ExpeditedUpdate_startWUScanStartedSuccedded",
"required": true
},
{
"signal": "no_scan_result",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "^ExpeditedUpdate_getUpdateResults",
"required": true
},
{
"signal": "update_page_ended",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "equals",
"value": "page_stopped",
"filterField": "page",
"filterOperator": "equals",
"filterValue": "OobeNDUP",
"required": true
},
{
"signal": "days_out_of_date",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
}
],
"explanation": "The Windows quality update page at the end of OOBE started a Windows Update scan, but no scan result arrived before the page ended. The device is {{days_out_of_date}} days out of date, so an update was due — the device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. The page waits only a short time for the scan (two minutes in the observed sessions): a scan that takes longer, or cannot reach Windows Update, ends the page without an update.",
"remediation": [
{
"title": "Check the path to Windows Update during OOBE",
"steps": [
"Verify that devices in OOBE reach the Windows Update service: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
"Check whether an update policy applied during the Enrollment Status Page points the scan at another source, such as WSUS."
]
},
{
"title": "Shorten the scan",
"steps": [
"Devices far behind scan longer; a current OS image or factory build keeps the OOBE scan short.",
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
},
{
"ruleId": "ANALYZE-ENRL-001",
"title": "Enrollment Failed",
@@ -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,336 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// B-96m: IME re-runs a failed platform script on later check-ins, so AgentExecutor.log can hold several runs of one
/// policy before the tracker has read IME's result of the first — always on the agent's first pass (the agent starts
/// mid-enrollment and reads all of AgentExecutor.log before IntuneManagementExtension.log), after a restart, and
/// whenever a result line surfaces late. With one slot per policy the second executor start reused the first run's
/// slot and its end block overwrote the first one: the first IME result went out with the LAST run's exit code and
/// output (session eff1b413: the bootstrap script "Failed (exit 0)" with the stdout of its successful retry), every
/// later result without an end block. These tests pin one run per executor start, each IME result paired with the
/// run of its policy that started last before it, each run carrying its own end block.
/// </summary>
public sealed partial class ImeLogTrackerOverwriteRewindTests
{
private const string RetryPolicyId = "4c2d8e1a-7b3f-4a9e-8d21-6f0b5c3e9a74";
private const string RetryUser = "00000000-0000-0000-0000-000000000000";
private static string ImeEntryAt(string time, string message)
=> $"<![LOG[{message}]LOG]!><time=\"{time}\" date=\"9-10-2026\" component=\"IntuneManagementExtension\" context=\"\" type=\"1\" thread=\"12\" file=\"\">\r\n";
/// <summary>One executor run's start block: banner, the platform argument line, paths, launch.</summary>
private static string RunStart(string time, string pid, string policyId = RetryPolicyId, string userId = RetryUser)
{
var path = $@"{ImeDir}\Policies\Scripts\{userId}_{policyId}.ps1";
var results = $@"{ImeDir}\Policies\Results\{userId}_{policyId}";
return ExecutorBanner(time)
+ EntryAt(time, $"Adding argument powershell with value {path} to the named argument list.")
+ EntryAt(time, "Powershell option gets invoked")
+ EntryAt(time, path)
+ EntryAt(time, results + ".output")
+ EntryAt(time, results + ".error")
+ EntryAt(time, results + ".timeout")
+ ExecutorLaunch(time, path, pid);
}
/// <summary>
/// One executor run's end block in AgentExecutor's order. "error from script =" logs the stderr buffer before the
/// output readers finished (<paramref name="stderrSnapshot"/>); "write output done" logs the final texts, the
/// error part being exactly the error file IME judges.
/// </summary>
private static string RunEnd(string time, int exitCode, string stdout, string stderr, string? stderrSnapshot = null)
=> EntryAt(time, $"Powershell exit code is {exitCode}")
+ EntryAt(time, $"length of out={stdout.Length}")
+ EntryAt(time, $"length of error={stderr.Length}")
+ EntryAt(time, "error from script =" + (stderrSnapshot ?? stderr))
+ EntryAt(time, exitCode == 0 ? "Powershell script is successfully executed." : "Powershell script is failed to execute")
+ EntryAt(time, $"write output done. output = {stdout}, error = {stderr}")
+ EntryAt(time, "Revert Wow64FsRedirection")
+ EntryAt(time, "Agent executor completed.");
private static string ImeRunStart(string time, string policyId = RetryPolicyId, string userId = RetryUser)
=> ImeEntryAt(time, $@"Script file {ImeDir}\Policies\Scripts\{userId}_{policyId}.ps1 is generated.");
private static string ImeRunResult(string time, string result, string policyId = RetryPolicyId, string userId = RetryUser)
=> ImeEntryAt(time, $"[PowerShell] User Id = {userId}, Policy id = {policyId}, policy result = {result}");
private static void AppendImeLog(Harness h, string text)
=> File.AppendAllText(Path.Combine(Path.GetDirectoryName(h.LogPath)!, "IntuneManagementExtension.log"), text, new UTF8Encoding(false));
/// <summary>Result | exit | stdout | stderr — readable in an assertion failure; "-" no exit code, "~" never read.</summary>
private static string Shape(ScriptExecutionState s)
=> $"{s.Result}|{(s.ExitCode.HasValue ? s.ExitCode.Value.ToString() : "-")}|{s.Stdout ?? "~"}|{s.Stderr ?? "~"}";
/// <summary>
/// Two poll passes as the agent runs them (read the files, then the flushes), the second past the result hold:
/// a result missing its end block goes out by then.
/// </summary>
private static async Task PassAndSettle(Harness h)
{
await h.Tracker.RunPollPassAsync(default);
h.Now = h.Now.AddSeconds(6);
await h.Tracker.RunPollPassAsync(default);
}
[Fact]
public async Task First_pass_pairs_each_ime_result_with_the_end_block_of_its_own_run()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
// IME's first run and two retries on later check-ins, all written before the agent started.
h.Append(RunStart("12:00:10.0000000", "1001") + RunEnd("12:00:12.0000000", 1, "", "Run A failed")
+ RunStart("12:10:10.0000000", "1002") + RunEnd("12:10:12.0000000", 1, "Run B output", "")
+ RunStart("12:20:10.0000000", "1003") + RunEnd("12:20:12.0000000", 0, "Run C output", ""));
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed")
+ ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:12.1000000", "Success")
+ ImeRunStart("12:20:08.0000000") + ImeRunResult("12:20:12.1000000", "Success"));
await PassAndSettle(h);
Assert.Equal(new[] { "Failed|1||Run A failed", "Success|1|Run B output|", "Success|0|Run C output|" },
h.Completed.Select(Shape).ToArray());
Assert.All(h.Completed, s => Assert.Equal("ime_policy_result", s.ResultSource));
Assert.True(h.Completed[0].StartedAtUtc < h.Completed[1].StartedAtUtc && h.Completed[1].StartedAtUtc < h.Completed[2].StartedAtUtc);
Assert.Equal(3, h.Completed.Select(s => s.RunId).Distinct().Count());
}
[Fact]
public async Task Retry_of_the_bootstrap_script_never_lends_its_success_output_to_the_failed_first_attempt()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(RunStart("12:00:10.0000000", "2001") + RunEnd("12:00:20.0000000", 1, "attempt 1: download failed", "Invoke-WebRequest : The remote name could not be resolved")
+ RunStart("12:10:10.0000000", "2002") + RunEnd("12:10:20.0000000", 0, "===== Bootstrap Completed Successfully =====", ""));
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:20.1000000", "Failed")
+ ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:20.1000000", "Success"));
await PassAndSettle(h);
Assert.Equal(new[]
{
"Failed|1|attempt 1: download failed|Invoke-WebRequest : The remote name could not be resolved",
"Success|0|===== Bootstrap Completed Successfully =====|",
}, h.Completed.Select(Shape).ToArray());
}
[Fact]
public async Task Run_killed_at_the_timeout_reports_failed_without_the_next_runs_end_block()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
// IME killed the first executor after 1800 s: its start block is all AgentExecutor.log holds of it.
h.Append(RunStart("12:00:10.0000000", "3001")
+ RunStart("12:40:10.0000000", "3002") + RunEnd("12:40:12.0000000", 0, "Run B output", ""));
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:30:10.5000000", "Failed")
+ ImeRunStart("12:40:08.0000000") + ImeRunResult("12:40:12.1000000", "Success"));
await PassAndSettle(h);
Assert.Equal(new[] { "Failed|-|~|~", "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
}
[Fact]
public async Task Run_whose_result_never_surfaced_falls_back_on_imes_rule_before_the_next_run()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(RunStart("12:00:10.0000000", "4001") + RunEnd("12:00:12.0000000", 0, "Run A output", "Run A wrote an error")
+ RunStart("12:10:10.0000000", "4002") + RunEnd("12:10:12.0000000", 0, "Run B output", ""));
// Run A's result line was lost; run B's is there.
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:12.1000000", "Success"));
await PassAndSettle(h);
Assert.Equal(new[] { "Failed|0|Run A output|Run A wrote an error", "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
Assert.Equal(new[] { "agentexecutor_fallback", "ime_policy_result" }, h.Completed.Select(s => s.ResultSource).ToArray());
}
[Fact]
public async Task Run_in_flight_survives_the_emit_of_the_run_before_it()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(RunStart("12:00:10.0000000", "5001") + RunEnd("12:00:12.0000000", 0, "Run A output", "")
+ RunStart("12:10:10.0000000", "5002"));
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Success") + ImeRunStart("12:10:08.0000000"));
await PassAndSettle(h);
h.Now = h.Now.AddSeconds(30);
await h.Pass();
// Run B ends minutes later.
h.Append(RunEnd("12:10:40.0000000", 0, "Run B output", ""));
AppendImeLog(h, ImeRunResult("12:10:40.1000000", "Success"));
await PassAndSettle(h);
Assert.Equal(new[] { "Success|0|Run A output|", "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
}
[Fact]
public async Task Shutdown_between_the_two_logs_flushes_each_run_with_its_own_end_block()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(RunStart("12:00:10.0000000", "6001") + RunEnd("12:00:12.0000000", 1, "", "Run A failed")
+ RunStart("12:10:10.0000000", "6002") + RunEnd("12:10:12.0000000", 0, "Run B output", ""));
// The pass is cancelled after AgentExecutor.log; the shutdown flush follows.
await h.Pass();
h.Tracker.FlushPendingPlatformScriptResults(h.Now, force: true);
h.Restart();
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed")
+ ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:12.1000000", "Success"));
await PassAndSettle(h);
// Each run went out with its own block and IME's verdict on it; the results read after the restart are
// the same runs and change nothing.
Assert.Equal(new[] { "Failed|1||Run A failed", "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
Assert.All(h.Completed, s => Assert.Equal("agentexecutor_fallback", s.ResultSource));
}
[Fact]
public async Task End_block_line_recovered_after_the_next_run_started_stays_with_its_own_run()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(RunStart("12:00:10.0000000", "7001"));
var exitLineAt = h.Length;
h.Append(RunEnd("12:00:12.0000000", 1, "", "Run A failed")
+ RunStart("12:10:10.0000000", "7002") + RunEnd("12:10:12.0000000", 0, "Run B output", ""));
await h.Pass();
// A stale writer rewrites run A's exit line in place: the ledger check re-reads it behind run B's start.
h.Overwrite(exitLineAt, EntryAt("12:00:12.0000000", "Powershell exit code is 7"));
h.Tracker.RequestOverwriteCheckForTest();
h.Now = h.Now.AddSeconds(2);
await h.Pass();
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed")
+ ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:12.1000000", "Success"));
await PassAndSettle(h);
Assert.Equal(new[] { "Failed|7||Run A failed", "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
}
[Fact]
public async Task Run_without_result_or_end_block_is_dropped_once_a_later_run_has_its_result()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(RunStart("12:00:10.0000000", "9001")
+ RunStart("12:10:10.0000000", "9002") + RunEnd("12:10:12.0000000", 0, "Run B output", ""));
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunStart("12:10:08.0000000") + ImeRunResult("12:10:12.1000000", "Success"));
await PassAndSettle(h);
Assert.Equal(new[] { "Success|0|Run B output|" }, h.Completed.Select(Shape).ToArray());
Assert.Equal(0, h.Tracker.ParkedPlatformScriptCountForTest);
}
[Fact]
public void Reading_the_same_executor_start_line_again_never_parks_its_run()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
var line = $@"Adding argument powershell with value {ImeDir}\Policies\Scripts\{RetryUser}_{RetryPolicyId}.ps1 to the named argument list.";
var at = T0.AddMinutes(-30);
h.Tracker.ProcessLogMessageForTest(line, at, "AgentExecutor.log");
h.Tracker.ProcessLogMessageForTest(line, at, "AgentExecutor.log");
Assert.Equal(0, h.Tracker.ParkedPlatformScriptCountForTest);
h.Tracker.ProcessLogMessageForTest(line, at.AddMinutes(10), "AgentExecutor.log");
Assert.Equal(1, h.Tracker.ParkedPlatformScriptCountForTest);
}
[Fact]
public async Task Run_without_end_block_keeps_waiting_for_its_result_across_a_shutdown()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(RunStart("12:00:10.0000000", "9101") + RunStart("12:40:10.0000000", "9102"));
await h.Pass();
h.Tracker.FlushPendingPlatformScriptResults(h.Now, force: true);
Assert.Empty(h.Completed);
h.Restart();
Assert.Equal(1, h.Tracker.ParkedPlatformScriptCountForTest);
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:30:10.5000000", "Failed"));
await PassAndSettle(h);
Assert.Equal(new[] { "Failed|-|~|~" }, h.Completed.Select(Shape).ToArray());
}
[Fact]
public async Task Earlier_run_emitted_while_a_later_one_runs_never_takes_the_later_runs_register_entry()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(RunStart("12:00:10.0000000", "9201") + RunEnd("12:00:12.0000000", 1, "", "Run A failed")
+ RunStart("12:10:10.0000000", "9202"));
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed") + ImeRunStart("12:10:08.0000000"));
await PassAndSettle(h);
Assert.Single(h.Completed);
// A save of this key is the latest run's, and that one is still running.
h.Tracker.ScriptRuns.NotePassCompleted(h.Now);
Assert.Equal(PlatformRunClaim.Wait, h.Tracker.ScriptRuns.TryClaim(RetryUser, RetryPolicyId, h.Now.AddSeconds(-10), out _));
h.Append(RunEnd("12:10:40.0000000", 0, "Run B output", ""));
AppendImeLog(h, ImeRunResult("12:10:40.1000000", "Success"));
await PassAndSettle(h);
h.Tracker.ScriptRuns.NotePassCompleted(h.Now);
Assert.Equal(PlatformRunClaim.Claimed, h.Tracker.ScriptRuns.TryClaim(RetryUser, RetryPolicyId, h.Now.AddSeconds(-10), out var run));
Assert.Equal(h.Completed[1].RunId, run!.RunId);
Assert.Equal("Run B output", run.Stdout);
}
[Fact]
public async Task Run_of_another_user_keeps_its_own_register_entry_when_it_waited_for_its_result()
{
const string otherUser = "2b7e5c91-3d4a-4f80-b6e2-9a1c7d3f5e08";
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(RunStart("12:00:10.0000000", "9301") + RunEnd("12:00:12.0000000", 1, "", "Run A failed")
+ RunStart("12:10:10.0000000", "9302", userId: otherUser));
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed")
+ ImeRunStart("12:10:08.0000000", userId: otherUser));
await PassAndSettle(h);
h.Tracker.ScriptRuns.NotePassCompleted(h.Now);
Assert.Equal(PlatformRunClaim.Claimed, h.Tracker.ScriptRuns.TryClaim(RetryUser, RetryPolicyId, h.Now.AddSeconds(-10), out var run));
Assert.Equal(h.Completed[0].RunId, run!.RunId);
Assert.Equal(PlatformRunClaim.Wait, h.Tracker.ScriptRuns.TryClaim(otherUser, RetryPolicyId, h.Now.AddSeconds(-10), out _));
}
[Fact]
public async Task Start_line_read_again_stays_a_late_line_after_an_earlier_run_of_another_user_went_out()
{
const string otherUser = "2b7e5c91-3d4a-4f80-b6e2-9a1c7d3f5e08";
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(RunStart("12:00:10.0000000", "9401") + RunEnd("12:00:12.0000000", 0, "Run A output", "")
+ RunStart("12:10:10.0000000", "9402", userId: otherUser) + RunEnd("12:10:12.0000000", 0, "Run B output", ""));
// Run A's result surfaces after run B's (its line was hidden by a concurrent writer).
AppendImeLog(h, ImeRunResult("12:10:12.1000000", "Success", userId: otherUser) + ImeRunResult("12:00:12.1000000", "Success"));
await PassAndSettle(h);
Assert.Equal(new[] { "Success|0|Run B output|", "Success|0|Run A output|" }, h.Completed.Select(Shape).ToArray());
// Run B's executor start line read again (a rewind): a late line of the emitted run B, never a new run.
var started = 0;
h.Tracker.OnScriptStarted = _ => started++;
h.Tracker.ProcessLogMessageForTest(
$@"Adding argument powershell with value {ImeDir}\Policies\Scripts\{otherUser}_{RetryPolicyId}.ps1 to the named argument list.",
h.Completed[0].StartedAtUtc, "AgentExecutor.log");
Assert.Equal(0, started);
}
[Fact]
public async Task Final_stderr_of_the_end_block_replaces_the_early_snapshot_even_without_stdout()
{
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
// The snapshot was taken before the error reader finished; IME judged the full error file.
h.Append(RunStart("12:00:10.0000000", "8001") + RunEnd("12:00:12.0000000", 0, "", "Remove-AppxPackage : Cannot process argument", stderrSnapshot: ""));
AppendImeLog(h, ImeRunStart("12:00:08.0000000") + ImeRunResult("12:00:12.1000000", "Failed"));
await PassAndSettle(h);
Assert.Equal(new[] { "Failed|0||Remove-AppxPackage : Cannot process argument" }, h.Completed.Select(Shape).ToArray());
}
}
}
@@ -0,0 +1,139 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// Sessions d29e27e3 and 4377911b: a concurrent IME writer overwrote the result line of a platform script, and the
/// overwrite check recovered it one pass later. Recovered lines were never fresh, so they resolved in the agent's own
/// cached zone (+60) instead of anchoring themselves (+120): the end stamp landed an hour ahead of the clock and the
/// run duration was omitted. Bytes that read differently at a position than at the last look were written after that
/// look, so when the replaced bytes were confirmed seconds ago the new line's age is bounded like an appended line's —
/// these tests pin that it anchors itself then, and only then.
/// </summary>
public sealed partial class ImeLogTrackerOverwriteRewindTests
{
private static void AppendExecutorLog(Harness h, string text)
=> File.AppendAllText(Path.Combine(Path.GetDirectoryName(h.LogPath)!, "AgentExecutor.log"), text, new UTF8Encoding(false));
/// <summary>An IME log entry with the byte length of <paramref name="sameLengthAs"/>: what a stale writer's line replaces exactly.</summary>
private static string FillerLike(string time, string sameLengthAs)
{
var bare = ImeEntryAt(time, "[Win32App] checking ");
Assert.True(sameLengthAs.Length > bare.Length);
return ImeEntryAt(time, "[Win32App] checking " + new string('x', sameLengthAs.Length - bare.Length));
}
/// <summary>
/// A platform run whose IME writer lives in +120 (local = UTC + 2 h; the harness clock is 19:30Z): both logs are
/// observed empty first, so what follows is read fresh. Returns the IME log position where the filler line sits.
/// </summary>
private static async Task<long> StartRunAndReadFiller(Harness h, string fillerTime, string resultEntry)
{
h.Append(string.Empty);
AppendExecutorLog(h, string.Empty);
await h.Tracker.RunPollPassAsync(default);
AppendExecutorLog(h, RunStart("21:29:50.0000000", "1001"));
h.Append(ImeRunStart("21:29:48.0000000"));
await h.Tracker.RunPollPassAsync(default);
// Another IME writer appends a line; the tracker reads it.
var at = h.Length;
h.Append(FillerLike(fillerTime, resultEntry));
h.Now = h.Now.AddSeconds(1);
await h.Tracker.RunPollPassAsync(default);
return at;
}
[Fact]
public async Task Result_line_recovered_seconds_after_its_bytes_were_read_anchors_itself_like_a_fresh_line()
{
using var h = new Harness("IntuneManagementExtension.log", ScriptPatterns());
var result = ImeRunResult("21:30:01.1000000", "Success");
var at = await StartRunAndReadFiller(h, "21:30:00.5000000", result);
// The script ends: its end block, then the stale IME service writes the result over the filler.
AppendExecutorLog(h, RunEnd("21:30:01.0000000", 0, "done", ""));
h.Overwrite(at, result);
h.Now = h.Now.AddSeconds(1);
await PassAndSettle(h);
Assert.Equal(1, h.Health.OverwriteRewinds);
var script = Assert.Single(h.Completed);
Assert.Equal(CmTraceOffsetOrigin.LineAnchored, script.ResultProvenance!.Origin);
Assert.Equal(120, script.ResultProvenance.OffsetMinutes);
Assert.Equal(ImeLogTrackerAdapter.DurationPairVerdict.Verified, ImeLogTrackerAdapter.JudgeDurationPair(
script.StartedAtProvenance, script.ResultProvenance, false, script.ResultObservedAtUtc!.Value - script.StartedAtUtc!.Value, out _));
}
[Fact]
public async Task Verification_passes_keep_the_replaced_bytes_current_for_a_script_that_runs_longer()
{
using var h = new Harness("IntuneManagementExtension.log", ScriptPatterns());
var result = ImeRunResult("21:30:41.1000000", "Success");
var at = await StartRunAndReadFiller(h, "21:30:00.5000000", result);
// The script runs 40 s; the cadence check verifies the ledger every 10 s while the run is in flight.
for (var i = 0; i < 4; i++)
{
h.Now = h.Now.AddSeconds(10);
await h.Tracker.RunPollPassAsync(default);
}
AppendExecutorLog(h, RunEnd("21:30:41.0000000", 0, "done", ""));
h.Overwrite(at, result);
h.Now = h.Now.AddSeconds(1);
await PassAndSettle(h);
var script = Assert.Single(h.Completed);
Assert.Equal(CmTraceOffsetOrigin.LineAnchored, script.ResultProvenance!.Origin);
Assert.Equal(120, script.ResultProvenance.OffsetMinutes);
}
[Fact]
public async Task Overwrite_does_not_move_the_files_measured_writer_offset()
{
using var h = new Harness("IntuneManagementExtension.log", ScriptPatterns());
var result = ImeRunResult("21:30:01.1000000", "Success");
var at = await StartRunAndReadFiller(h, "21:30:00.5000000", result);
Assert.True(h.Tracker.OffsetCalibrator.TryGetOffset("IntuneManagementExtension.log", out var before));
AppendExecutorLog(h, RunEnd("21:30:01.0000000", 0, "done", ""));
h.Overwrite(at, result);
h.Now = h.Now.AddSeconds(1);
await PassAndSettle(h);
Assert.True(h.Tracker.OffsetCalibrator.TryGetOffset("IntuneManagementExtension.log", out var after));
Assert.Equal(before, after);
}
[Theory]
// Replaced bytes confirmed 1 s / 30 s ago: written since, age bounded like an appended line's.
[InlineData(true, true, 1, true)]
[InlineData(true, true, 30, true)]
// Confirmed longer ago: the new bytes could be minutes old — their age may round onto the 15-minute grid.
[InlineData(true, true, 31, false)]
// The clock stepped back since the confirmation: the age is unknown.
[InlineData(true, true, -1, false)]
// The divergence lies in bytes the ledger never held (dropped by a cap): nothing dates them.
[InlineData(true, false, 1, false)]
// The pass itself is not fresh — the first pass after a restart never anchors.
[InlineData(false, true, 1, false)]
// Nothing was replaced.
[InlineData(true, true, null, false)]
public void Rewound_entry_is_fresh_only_when_the_bytes_it_replaced_were_confirmed_within_the_fresh_window(
bool passLinesAreFresh, bool entryAtRewindPointReplaced, int? secondsSinceConfirmed, bool expected)
{
var now = new DateTime(2026, 10, 4, 19, 2, 0, DateTimeKind.Utc);
DateTime? confirmed = secondsSinceConfirmed.HasValue ? now.AddSeconds(-secondsSinceConfirmed.Value) : (DateTime?)null;
Assert.Equal(expected, ImeLogTracker.RewoundEntryIsFresh(passLinesAreFresh, entryAtRewindPointReplaced, confirmed, now));
}
}
}
@@ -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>
@@ -502,6 +502,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
public async Task Ime_result_line_hidden_by_an_executor_start_line_is_recovered_from_the_ime_log()
{
using var h = new Harness("IntuneManagementExtension.log", ScriptPatterns());
// Seen empty first: the start lines are fresh and anchor themselves like the recovered result line will,
// so start and result sit on one timeline whatever the host's own zone is.
h.Append(string.Empty);
await h.Pass();
h.Append(Entry(GeneratedLine, "17") + Entry("Launch powershell executor in user session", "17") + Entry("process id = 10608", "17"));
await h.Pass();
var servicePosition = h.Length;
@@ -83,8 +83,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
Assert.Single(emitted);
}
[Theory]
// IME's verdict is Success exactly when the error file is empty, whatever the exit code. Judged by the exit
// code, session 67ae751c showed exit 0 with the script's own stderr as Success, and 46a41a0a an exit 1 without
// stderr as Failed, a run IME reported as Success one cycle earlier.
[InlineData(0, "Invoke-WebRequest : The remote name could not be resolved", "Failed")]
[InlineData(1, "", "Success")]
[InlineData(1, " \n", "Success")]
[InlineData(0, "", "Success")]
[InlineData(1, null, "Failed")]
[InlineData(0, null, "Success")]
public void Fallback_judges_like_ime_by_the_stderr_it_read(int exitCode, string? stderr, string expected)
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp, out var emitted);
var observedAt = new DateTime(2026, 6, 19, 12, 59, 4, DateTimeKind.Utc);
tracker.SeedPendingPlatformScriptForTesting("0d2294bf", exitCode: exitCode, exitObservedAtUtc: observedAt, stderr: stderr);
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
var script = Assert.Single(emitted);
Assert.Equal(expected, script.Result);
Assert.Equal(exitCode, script.ExitCode);
Assert.Equal("agentexecutor_fallback", script.ResultSource);
}
[Fact]
public void Nonzero_exit_code_emits_failed_result()
public void Nonzero_exit_code_without_a_stderr_line_emits_failed_result()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp, out var emitted);
@@ -108,6 +108,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
var tracker = BuildTracker(tmp, out var emitted);
tracker.LoadStateForTest(); // must not throw
Assert.Equal(0, tracker.ParkedPlatformScriptCountForTest);
// Degrades to pre-fix behavior: no markers restored, normal emission works (the
// result waits for its end block until the shutdown flush).
@@ -116,6 +117,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
Assert.Single(emitted);
}
[Fact]
public void Pending_slot_from_a_state_file_without_executor_start_loads_and_completes()
{
using var tmp = new TempDirectory();
var started = new DateTime(2026, 10, 4, 9, 0, 0, DateTimeKind.Utc);
// A pending slot written by an agent before runs carried their executor start and run id.
var persistence = new ImeTrackerStatePersistence(tmp.Path, new AgentLogger(tmp.Path, AgentLogLevel.Info));
persistence.Save(new ImeTrackerStateData
{
PendingPlatformScripts = new List<ScriptExecutionState>
{
new ScriptExecutionState { PolicyId = "policyF", ScriptType = "platform", StartedAtUtc = started, ExitCode = 0, ExitObservedAtUtc = started.AddSeconds(5), Stdout = "done", Stderr = "" },
},
});
var tracker = BuildTracker(tmp, out var emitted);
tracker.LoadStateForTest();
tracker.CompletePlatformScriptFromImeResultForTesting("policyF", "Success", started.AddSeconds(6));
var script = Assert.Single(emitted);
Assert.Equal("Success", script.Result);
Assert.Equal(0, script.ExitCode);
Assert.False(string.IsNullOrEmpty(script.RunId));
}
[Fact]
public void Stale_result_older_than_current_run_start_is_dropped()
{
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
@@ -10,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
@@ -36,18 +38,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
private const string CspGateTrue =
"CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_isNDUPAllowedByCSPSucceeded', Value: 'true'.";
// A French Windows labels the same fields "Résultat", "Nom" and "valeur" and quotes them
// in guillemets; the event properties carry the values unchanged in every language.
private const string FrenchNdupStarted = "Activité d’application web CloudExperienceHost démarrée. CXID : « OobeNDUP ».";
private const string FrenchPageStopped = "Activité d’application web CloudExperienceHost arrêtée. Résultat : « success ».";
private const string FrenchCspGate =
"Événement d’application web CloudExperienceHost 2. Nom : « ExpeditedUpdate_isNDUPAllowedByCSPSucceeded », valeur : « true ».";
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
private IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState> _registry = Array.Empty<OobeUpdateRegistrySnapshot.KeyState>();
public void Dispose() => _tmp.Dispose();
private ShellCoreTracker Build() =>
private ShellCoreTracker Build(string? stateDirectory = null) =>
new ShellCoreTracker("S1", "T1",
new InformationalEventPost(_sink, new VirtualClock(At)),
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
helloTracker: null,
oobeUpdateRegistryReader: () => _registry);
oobeUpdateRegistryReader: () => _registry,
stateDirectory: stateDirectory);
private string StateDirectory => Path.Combine(_tmp.Path, "state");
private static void Live(ShellCoreTracker tracker, int eventId, string description, DateTime at, long recordId) =>
tracker.ProcessEvent(eventId, description, at, "Microsoft-Windows-Shell-Core", isBackfill: false, recordId: recordId);
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
_sink.Posted.Where(p =>
@@ -274,6 +289,132 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
Assert.Equal(3, _sink.Posted.Count);
}
// ------------------------------------------------------------ localization ----
[Fact]
public void LocalizedRecords_AreReadFromTheirProperties()
{
using var tracker = Build();
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStarted, FrenchNdupStarted, At, "Microsoft-Windows-Shell-Core",
isBackfill: false, properties: new[] { "OobeNDUP" });
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEvent, FrenchCspGate, At.AddSeconds(5), "Microsoft-Windows-Shell-Core",
isBackfill: false, properties: new[] { "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true" });
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStopped, FrenchPageStopped, At.AddMinutes(1), "Microsoft-Windows-Shell-Core",
isBackfill: false, properties: new[] { "success" });
var pages = ByType(Constants.EventTypes.OobeUpdatePage).Select(Data).ToList();
Assert.Equal(new object[] { "page_started", "event_name", "page_stopped" }, pages.Select(d => d["cxhEvent"]).ToArray());
Assert.Equal("OobeNDUP", pages[0]["page"]);
Assert.Equal("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", pages[1]["name"]);
Assert.Equal("true", pages[1]["value"]);
Assert.Equal("OobeNDUP", pages[2]["page"]);
Assert.Equal("success", pages[2]["result"]);
}
[Fact]
public void LocalizedMessage_WithoutProperties_DoesNotYieldItsLabelledFields()
{
// Why the properties are read: the French labels are not the manifest's.
Assert.Null(ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppStopped, FrenchPageStopped, properties: null).Primary);
Assert.Null(ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppEvent, FrenchCspGate, properties: null).Primary);
Assert.Equal("success",
ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppStopped, FrenchPageStopped, new[] { "success" }).Primary);
}
[Theory]
[InlineData("CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_x', Value: 'true'.")]
[InlineData("CloudExperienceHost Web App Event 2. Name : « ExpeditedUpdate_x », Value : « true ».")]
[InlineData("CloudExperienceHost Web App Event 2. Name\u00A0: «\u202FExpeditedUpdate_x\u202F», Value\u00A0: «\u00A0true\u00A0».")]
public void MessageFallback_AcceptsQuotesAndGuillemets(string description)
{
var fields = ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppEvent, description, properties: null);
Assert.Equal("ExpeditedUpdate_x", fields.Primary);
Assert.Equal("true", fields.Value);
}
[Fact]
public void PropertyValue_LeavesTheTrackerOnlyInASafeForm()
{
var fields = ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppEvent, "",
new[] { "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded", "{\"url\":\"https://login.microsoftonline.com/x\"}" });
var record = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, fields, lastPage: null);
Assert.Equal("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded", record!.Name);
Assert.Null(record.Value);
Assert.DoesNotContain("microsoftonline",
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEvent, fields, lastPage: null));
}
// ------------------------------------------------------- restart watermark ----
[Fact]
public void RestartBackfill_SkipsUpdatePageRecords_AnEarlierRunReported()
{
using (var first = Build(StateDirectory))
{
Live(first, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 500);
Live(first, ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(1), recordId: 501);
}
// The restart reads the minutes before it again — plus the stop the first run missed.
using var second = Build(StateDirectory);
second.LoadPageWatermark();
second.ReplayBackfillRecords(new[]
{
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 500),
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(1), recordId: 501),
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(2), recordId: 502),
});
var pages = ByType(Constants.EventTypes.OobeUpdatePage).Select(Data).ToList();
Assert.Equal(new object[] { "page_started", "event_name", "page_stopped" }, pages.Select(d => d["cxhEvent"]).ToArray());
Assert.Equal(new object[] { false, false, true }, pages.Select(d => d["backfill"]).ToArray());
// The skipped start still names the page the stop belongs to.
Assert.Equal("OobeNDUP", pages[2]["page"]);
}
[Fact]
public void SameRecord_FromTheLiveWatcherAndTheBackfill_IsReportedOnce()
{
// The live watcher is armed before the backfill runs, so both can deliver a record.
using var tracker = Build(StateDirectory);
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 700);
tracker.ReplayBackfillRecords(new[]
{
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 700),
});
Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage));
}
[Fact]
public void Watermark_FollowsReportedRecordsOnly()
{
using var tracker = Build(StateDirectory);
for (var i = 0; i <= OobeUpdateTelemetry.MaxPageEventsPerKey; i++)
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddSeconds(i), recordId: 800 + i);
// The repeat over the per-name cap is claimed for this run, not written down.
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey, ByType(Constants.EventTypes.OobeUpdatePage).Count);
var state = JsonConvert.DeserializeObject<EventRecordWatermark.WatermarkState>(
File.ReadAllText(Path.Combine(StateDirectory, ShellCoreTracker.PageWatermarkStateFileName)));
Assert.Equal(800 + OobeUpdateTelemetry.MaxPageEventsPerKey - 1, state!.LastRecordId);
}
[Fact]
public void RecordsWithoutARecordId_AreNotDeduplicated()
{
using var tracker = Build(StateDirectory);
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: -1);
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(5), recordId: -1);
Assert.Equal(2, ByType(Constants.EventTypes.OobeUpdatePage).Count);
Assert.False(File.Exists(Path.Combine(StateDirectory, ShellCoreTracker.PageWatermarkStateFileName)));
}
[Fact]
public void AgentStartAndStop_ReportTheRegistryState_StopOnce()
{
@@ -84,6 +84,55 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
Assert.False(raised);
}
[Fact]
public void ProcessEvent_62404_NGC_in_a_localized_message_is_read_from_the_cxid_property()
{
// A French Windows quotes the CXID in guillemets — the "'NGC'" substring never matched.
using var tmp = new TempDirectory();
using var tracker = MakeTracker(tmp, new VirtualClock(ClockNow));
var raised = 0;
tracker.HelloWizardStarted += (_, _) => raised++;
tracker.ProcessEvent(
eventId: ShellCoreTracker.EventId_ShellCore_WebAppStarted,
description: "Activité d’application web CloudExperienceHost démarrée. CXID : « NGC ».",
timestamp: ClockNow,
providerName: "Microsoft-Windows-Shell-Core",
isBackfill: false,
properties: new[] { "NGC" });
Assert.Equal(1, raised);
}
[Theory]
[InlineData("AADHello", true)]
[InlineData("NGC", true)]
[InlineData("NGCPin", false)]
[InlineData("OobeNDUP", false)]
public void HelloWizardStart_is_decided_by_the_cxid(string cxid, bool expected)
{
Assert.Equal(expected, ShellCoreTracker.IsHelloWizardStart(cxid, description: "CXID: 'AADHello'"));
}
[Fact]
public void Backfill_62404_reads_the_cxid_property()
{
using var tmp = new TempDirectory();
using var tracker = MakeTracker(tmp, new VirtualClock(ClockNow));
var captured = new List<HelloWizardStartedEventArgs>();
tracker.HelloWizardStarted += (_, args) => captured.Add(args);
var historical = ClockNow.AddMinutes(-3);
tracker.ReplayBackfillRecords(new[]
{
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted,
"Activité d’application web CloudExperienceHost démarrée. CXID : « NGC ».", historical,
recordId: 42, properties: new[] { "NGC" }),
});
Assert.Equal(historical, Assert.Single(captured).OccurredAtUtc);
}
[Fact]
public void Backfill_62404_raises_once_with_historical_timestamp()
{
@@ -0,0 +1,225 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
{
/// <summary>
/// The first run's update page backfill (<see cref="ShellCoreTracker.BackfillUpdatePageTelemetry"/>)
/// and the page a stop belongs to across backfill and live records. The first run has no
/// replay — its backfill is telemetry and nothing else, so a Hello-wizard start, an ESP exit
/// or an ESP failure in the window must not leave the tracker on any rail.
/// </summary>
public sealed class ShellCoreTrackerUpdatePageBackfillTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 10, 5, 13, 10, 0, DateTimeKind.Utc);
private const string NdupStarted = "CloudExperienceHost Web App Activity started. CXID: 'OobeNDUP'.";
private const string RebootNdupStarted = "CloudExperienceHost Web App Activity started. CXID: 'RebootNDUP'.";
private const string AadHelloStarted = "CloudExperienceHost Web App Activity started. CXID: 'AADHello'.";
private const string PageStopped = "CloudExperienceHost Web App Activity stopped. Result: 'success'.";
private const string DownloadSucceeded =
"CloudExperienceHost Web App Event 1. Name: 'SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded'.";
private const string EspExit = "CommercialOOBE_ESPProgress_Page_Exiting";
private const string EspFailure = "CommercialOOBE_ESPProgress_Failure";
private const string WhiteGloveSuccess = "CommercialOOBE_ESPProgress_WhiteGlove_Success";
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
private int _registryReads;
public void Dispose() => _tmp.Dispose();
private ShellCoreTracker Build() =>
new ShellCoreTracker("S1", "T1",
new InformationalEventPost(_sink, new VirtualClock(At)),
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
helloTracker: null,
oobeUpdateRegistryReader: () => { _registryReads++; return Array.Empty<OobeUpdateRegistrySnapshot.KeyState>(); },
stateDirectory: Path.Combine(_tmp.Path, "state"));
private static void Live(ShellCoreTracker tracker, int eventId, string description, DateTime at, long recordId) =>
tracker.ProcessEvent(eventId, description, at, "Microsoft-Windows-Shell-Core", isBackfill: false, recordId: recordId);
private static ShellCoreRecord Record(int eventId, string description, DateTime at, long recordId) =>
new ShellCoreRecord(eventId, description, at, recordId);
private IReadOnlyList<IReadOnlyDictionary<string, object>> Pages() =>
_sink.Posted
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == Constants.EventTypes.OobeUpdatePage)
.Select(p => (IReadOnlyDictionary<string, object>)p.TypedPayload!)
.ToList();
// ------------------------------------------------------------ first run ----
[Fact]
public void FirstRunBackfill_ReportsTheUpdatePage_AndNothingElse()
{
using var tracker = Build();
var raised = new List<string>();
tracker.HelloWizardStarted += (_, _) => raised.Add("hello");
tracker.FinalizingSetupPhaseTriggered += (_, reason) => raised.Add($"finalizing:{reason}");
tracker.EspExited += (_, _) => raised.Add("exit");
tracker.EspFailureDetected += (_, _) => raised.Add("failure");
tracker.WhiteGloveCompleted += (_, _) => raised.Add("whiteglove");
tracker.ReplayUpdatePageTelemetry(new[]
{
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspExit, At.AddMinutes(-41), 95),
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspFailure, At.AddSeconds(-2430), 96),
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, WhiteGloveSuccess, At.AddSeconds(-2420), 97),
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-40), 100),
Record(ShellCoreTracker.EventId_ShellCore_WebAppEventName, DownloadSucceeded, At.AddMinutes(-30), 101),
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, AadHelloStarted, At.AddMinutes(-2), 110),
});
Assert.Empty(raised);
Assert.False(tracker.IsEspExitedForTest);
Assert.False(tracker.IsWhiteGloveDetectedForTest);
var pages = Pages();
Assert.Equal(new object[] { "page_started", "event_name" }, pages.Select(d => d["cxhEvent"]).ToArray());
Assert.All(pages, d => Assert.Equal(true, d["backfill"]));
// No agent_trace, no ESP or Hello event — only the two page rows.
Assert.Equal(2, _sink.Posted.Count);
// The registry shows the state now, not at the records' time.
Assert.Equal(0, _registryReads);
}
[Fact]
public void FirstRunBackfill_KeepsTheEventTimes()
{
using var tracker = Build();
tracker.ReplayUpdatePageTelemetry(new[]
{
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-40), 100),
});
var posted = Assert.Single(_sink.Posted);
Assert.Equal(At.AddMinutes(-40), posted.OccurredAtUtc);
}
[Fact]
public void LiveStop_FindsThePageThatStartedBeforeTheAgent()
{
using var tracker = Build();
tracker.ReplayUpdatePageTelemetry(new[]
{
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-40), 100),
});
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(5), 120);
var stop = Pages().Last();
Assert.Equal("page_stopped", stop["cxhEvent"]);
Assert.Equal("OobeNDUP", stop["page"]);
Assert.Equal(false, stop["backfill"]);
}
[Fact]
public void SameRecord_FromTheLiveWatcherAndTheFirstRunBackfill_IsReportedOnce()
{
// The live watcher is armed before the backfill reads, so both can deliver a record.
using var tracker = Build();
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, 700);
tracker.ReplayUpdatePageTelemetry(new[]
{
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, 700),
});
Assert.Single(Pages(), d => (string)d["cxhEvent"] == "page_started");
}
// ---------------------------------------------------- the page of a stop ----
[Fact]
public void ReplayedOlderStart_DoesNotTakeOverTheNextLiveStop()
{
// A restart replay reads records older than the live watcher's; before, its page start
// overwrote the live page and the next live stop was attributed to the old page.
using var tracker = Build();
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, RebootNdupStarted, At, 900);
tracker.ReplayBackfillRecords(new[]
{
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-20), 850),
Record(ShellCoreTracker.EventId_ShellCore_WebAppEventName, DownloadSucceeded, At.AddMinutes(-19), 851),
});
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(1), 901);
var stop = Pages().Last();
Assert.Equal("page_stopped", stop["cxhEvent"]);
Assert.Equal("RebootNDUP", stop["page"]);
}
[Fact]
public void BackfilledStop_BelongsToTheStartOfItsOwnBatch()
{
using var tracker = Build();
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, RebootNdupStarted, At, 900);
tracker.ReplayUpdatePageTelemetry(new[]
{
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-20), 850),
Record(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(-18), 852),
});
var stop = Pages().Last();
Assert.Equal("page_stopped", stop["cxhEvent"]);
Assert.Equal("OobeNDUP", stop["page"]);
Assert.Equal(true, stop["backfill"]);
}
[Fact]
public void BackfilledStopWithoutItsStart_IsNotGivenTheLivePage()
{
using var tracker = Build();
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, 900);
tracker.ReplayUpdatePageTelemetry(new[]
{
Record(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(-18), 852),
});
Assert.DoesNotContain(Pages(), d => (string)d["cxhEvent"] == "page_stopped");
}
[Fact]
public void NewerBackfilledStart_MovesTheLivePageForward()
{
// Records written between arming the watcher and the backfill's read are newer than
// the last live start the watcher delivered before them.
using var tracker = Build();
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, 900);
tracker.ReplayUpdatePageTelemetry(new[]
{
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, RebootNdupStarted, At.AddSeconds(30), 905),
});
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(1), 906);
Assert.Equal("RebootNDUP", Pages().Last()["page"]);
}
[Fact]
public void UpdatePageLookback_IsInsideTheReplayClamp()
{
Assert.Equal(ShellCoreTracker.UpdatePageBackfillLookbackMinutes,
ShellCoreTracker.ClampLookbackMinutes(ShellCoreTracker.UpdatePageBackfillLookbackMinutes));
}
}
}
@@ -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);
}
}
}
@@ -101,15 +101,73 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
}
[Fact]
public void PageEvents_AreCappedPerRun()
public void PageEvents_AreCappedPerRun_AndTheCapIsMarkedOnce()
{
var telemetry = Build();
for (var i = 0; i < OobeUpdateTelemetry.MaxPageEventsPerRun + 20; i++)
{
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false);
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At.AddSeconds(i), isBackfill: false);
}
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun, ByType(Constants.EventTypes.OobeUpdatePage).Count);
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun + 1, pages.Count);
// A name missing after the marker proves nothing — the rules check for it.
var marker = Data(pages.Last());
Assert.Equal(OobeUpdatePageRecord.NamesCapped, marker["cxhEvent"]);
Assert.Equal(false, marker["backfill"]);
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun, marker["limit"]);
Assert.Equal(At.AddSeconds(OobeUpdateTelemetry.MaxPageEventsPerRun), pages.Last().OccurredAtUtc); // the first name over the budget
}
[Fact]
public void BackfilledNames_HaveTheirOwnBudget()
{
// The records from before the agent started cannot spend the live page's budget.
var telemetry = Build();
for (var i = 0; i < OobeUpdateTelemetry.MaxBackfillPageEventsPerRun + 5; i++)
{
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_old{i}"), At.AddMinutes(-30), isBackfill: true);
}
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_live"), At, isBackfill: false));
var markers = ByType(Constants.EventTypes.OobeUpdatePage)
.Where(p => (string)Data(p)["cxhEvent"] == OobeUpdatePageRecord.NamesCapped).ToList();
var marker = Assert.Single(markers);
Assert.Equal(true, Data(marker)["backfill"]);
Assert.Equal(OobeUpdateTelemetry.MaxBackfillPageEventsPerRun, Data(marker)["limit"]);
}
[Fact]
public void PageStartAndStop_AreReported_AfterTheNameBudgetIsSpent()
{
// The time attribution reads the update's span from the page start and stop.
var telemetry = Build();
for (var i = 0; i < OobeUpdateTelemetry.MaxPageEventsPerRun; i++)
{
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false));
}
Assert.False(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_late"), At, isBackfill: false));
Assert.False(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_later"), At, isBackfill: false));
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, 62404, page: "RebootNDUP"), At.AddMinutes(1), isBackfill: false));
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "RebootNDUP", result: "success"), At.AddMinutes(2), isBackfill: false));
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun + 3, pages.Count);
Assert.Equal(new object[] { OobeUpdatePageRecord.NamesCapped, "page_started", "page_stopped" },
pages.Skip(OobeUpdateTelemetry.MaxPageEventsPerRun).Select(p => Data(p)["cxhEvent"]).ToArray());
}
[Fact]
public void SamePageStart_IsReportedAtMostThreeTimes()
{
// Outside the run budget, but a page that restarts in a loop still cannot flood.
var telemetry = Build();
var reported = Enumerable.Range(0, 10)
.Count(i => telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, 62404, page: "OobeNDUP"), At.AddMinutes(i), isBackfill: false));
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey, reported);
}
// ------------------------------------------------------------ oobe_update_state ----
@@ -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,17 +271,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
else
{
HandlePlatformScriptStarted(id, source);
HandlePlatformScriptStarted(id, source, ImeUserIdFrom(match.Value));
}
}
/// <summary>
/// The IME user id on a platform-script line — <c>User Id = {id}</c> on the result line,
/// <c>…\Policies\Scripts\{id}_{policy}.ps1</c> on the start lines — or null. Read from the
/// matched text, not from a capture group, so it works with every pattern pack the agent may
/// be served.
/// </summary>
internal static string ImeUserIdFrom(string text)
{
if (string.IsNullOrEmpty(text)) return null;
const string resultTag = "User Id = ";
const string pathTag = @"\Policies\Scripts\";
var i = text.IndexOf(resultTag, StringComparison.OrdinalIgnoreCase);
if (i >= 0) return GuidAt(text, i + resultTag.Length);
i = text.IndexOf(pathTag, StringComparison.OrdinalIgnoreCase);
return i >= 0 ? GuidAt(text, i + pathTag.Length) : null;
}
private static string GuidAt(string text, int start)
{
if (start + 36 > text.Length) return null;
var candidate = text.Substring(start, 36);
return Guid.TryParse(candidate, out _) ? candidate.ToLowerInvariant() : null;
}
// Platform script: AgentExecutor.log entries create/enrich, IME log entries also create.
// Internal so the stale-slot hardening is directly testable (the public path arrives via
// a regex Match).
internal void HandlePlatformScriptStarted(string id, string source = null)
internal void HandlePlatformScriptStarted(string id, string source = null, string userId = null)
{
var currentStartTs = LastMatchedLogTimestamp ?? UtcNowProvider();
var currentStartProvenance = CaptureLastMatchedProvenance();
var fromExecutor = string.Equals(source, "agentexecutor", StringComparison.OrdinalIgnoreCase);
// A start line older than the result this policy already emitted is the late start
// line of THAT run: its AgentExecutor block surfaced after the IME result — the two
@@ -294,7 +319,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// never tie; a low-resolution clock in a test may).
if (_platformScriptResultEmitted.TryGetValue(id, out var emittedResultTs) && currentStartTs < emittedResultTs)
{
RecordInvocationMarker(id, isClose: false);
RecordInvocationMarker(id, isClose: false, EmittedRunId);
NoteInvocationActivity();
_logger.Debug($"ImeLogTracker: platform script start line for {id} (source: {source ?? "ime"}) predates its emitted result — late line of that run, not a new one");
return;
@@ -302,7 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
if (_pendingPlatformScripts.TryGetValue(id, out var pendingSlot))
{
if (pendingSlot.StartedAtUtc.HasValue && currentStartTs - pendingSlot.StartedAtUtc.Value > TimeSpan.FromHours(24))
if (pendingSlot.StartedAtUtc.HasValue && currentStartTs - pendingSlot.StartedAtUtc.Value > StalePlatformRunAge)
{
// Stale-slot hardening (session eaf3d8c4): a replayed start line from a previous
// enrollment whose matching result never appeared (script was mid-run when that
@@ -319,6 +344,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// Emit what the held run has; its end block did not surface in time.
EmitPlatformScriptResult(pendingSlot, "the next run of this policy started");
}
else if (fromExecutor && LastMatchedLogTimestamp.HasValue && pendingSlot.Result == null
&& pendingSlot.ExecutorStartedAtUtc.HasValue && pendingSlot.ExecutorStartedAtUtc.Value < currentStartTs)
{
// Every executor process writes its start line once: a later one is the next run
// (IME re-runs a failed script on a later check-in), whose end block must not
// overwrite this one's. This run waits for its IME result — on the first pass
// AgentExecutor.log is read in full before the IME log (session eff1b413).
ParkPlatformRun(pendingSlot);
}
else if (!pendingSlot.StartedAtUtc.HasValue)
{
// The slot was opened by its result while the start block was still hidden;
@@ -328,7 +362,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_stateDirty = true;
}
}
if (!_pendingPlatformScripts.ContainsKey(id))
if (_pendingPlatformScripts.TryGetValue(id, out var slot))
{
if (slot.UserId == null && userId != null) slot.UserId = userId;
if (fromExecutor && !slot.ExecutorStartedAtUtc.HasValue)
{
slot.ExecutorStartedAtUtc = currentStartTs;
_stateDirty = true;
}
}
else
{
// A fresh start for this policy (no pending entry) begins a NEW execution.
// Clear any emitted-marker from a prior run so IME re-evaluations / retries of
@@ -336,24 +379,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// deduped away. A start line of the emitted run itself never reaches this point
// (the guard above), so this never clears the current run's own marker.
_platformScriptResultEmitted.Remove(id);
_pendingPlatformScripts[id] = new ScriptExecutionState
slot = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "platform",
UserId = userId,
RunId = NewRunId(),
// Prefer the source CMTrace timestamp so a script that started before the
// agent launched (replayed log content) is dated to its real start, not now.
// Set only at slot creation: the start line fires twice (agentexecutor + ime
// source) but the earliest wins.
StartedAtUtc = currentStartTs,
StartedAtProvenance = currentStartProvenance,
ExecutorStartedAtUtc = fromExecutor ? currentStartTs : (DateTime?)null,
};
_pendingPlatformScripts[id] = slot;
ScriptRuns.NoteStarted(userId, id, UtcNowProvider());
// Live "running" indicator — same signal health scripts emit. Gated on slot
// creation so the duplicate start line (agentexecutor + ime source) doesn't
// double-emit for the same execution.
try { OnScriptStarted?.Invoke(new ScriptStartedInfo { PolicyId = id, ScriptType = "platform" }); }
catch (Exception ex) { _logger.Warning($"ImeLogTracker: OnScriptStarted handler threw: {ex.Message}"); }
}
RecordInvocationMarker(id, isClose: false);
RecordInvocationMarker(id, isClose: false, LastMatchedLogTimestamp.HasValue ? RunIdOfStartLine(slot, fromExecutor, currentStartTs) : slot.RunId);
NoteInvocationActivity();
// Started lines fire twice per script (agentexecutor + ime source) and carry no
// outcome — the matching `platform script completed` line below carries result+exit
@@ -361,6 +409,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_logger.Debug($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})");
}
/// <summary>How long after IME's "Script file … is generated" line its executor writes its start line (about 2 s).</summary>
private static readonly TimeSpan ImeLaunchWindow = TimeSpan.FromMinutes(1);
/// <summary>
/// The run a timestamped start line belongs to, for its invocation marker. An executor start line belongs to the
/// slot unless it is older than the slot's executor start — re-read behind a later run — then to the parked run
/// it started. IME's "Script file … is generated" precedes its executor: it belongs to the live run whose
/// executor started within <see cref="ImeLaunchWindow"/> after it, else to a slot whose executor has not
/// started yet; otherwise its run is not known (yet) and the lines behind it go nowhere.
/// </summary>
private string RunIdOfStartLine(ScriptExecutionState slot, bool fromExecutor, DateTime lineTs)
{
if (fromExecutor)
{
if (!slot.ExecutorStartedAtUtc.HasValue || slot.ExecutorStartedAtUtc.Value <= lineTs) return slot.RunId;
var parked = _parkedPlatformScripts.FirstOrDefault(r =>
string.Equals(r.PolicyId, slot.PolicyId, StringComparison.OrdinalIgnoreCase) && r.ExecutorStartedAtUtc == lineTs);
return parked?.RunId ?? EmittedRunId;
}
var launched = LivePlatformRuns(slot.PolicyId)
.Where(r => r.ExecutorStartedAtUtc.HasValue && r.ExecutorStartedAtUtc.Value >= lineTs && r.ExecutorStartedAtUtc.Value - lineTs <= ImeLaunchWindow)
.OrderBy(r => r.ExecutorStartedAtUtc.Value)
.FirstOrDefault();
if (launched != null) return launched.RunId;
return slot.ExecutorStartedAtUtc.HasValue ? EmittedRunId : slot.RunId;
}
// Routes line-by-line script-data handlers (context / exitCode / output) to the right
// accumulator based on the pattern's scriptType parameter. Health-script lines feed the
// single-slot _pendingHealthScript (the HS-NEW-RESULT JSON delivers the full cycle in one
@@ -439,6 +515,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
ApplyExitCode(script, exitCode, LastMatchedLogTimestamp ?? UtcNowProvider(), CaptureLastMatchedProvenance());
else if (ownerPolicyId != null)
_logger.Debug($"ImeLogTracker: exit code {exitCode} belongs to platform script {ownerPolicyId}, whose completion was already emitted — dropped");
else
LogLineBehindWriterBoundary($"exit code {exitCode}");
// An exit code means the executor just wrote its end block and IME is about to
// write the result — both land at a stale stream position when another process
// appended meanwhile. Check the ledgers on the next pass.
@@ -465,17 +543,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null;
var script = GetCurrentScriptForLineUpdate(parameters);
if (script == null) return;
if (script == null)
{
LogLineBehindWriterBoundary($"script output ({outputType ?? "combined"})");
return;
}
// PS-AGENT-OUTPUT captures both stdout and stderr in one pattern
var output = match.Groups["output"]?.Value;
var error = match.Groups["error"]?.Value;
var error = match.Groups["error"];
if (!string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error))
if (error != null && error.Success)
{
// Combined pattern (write output done. output = ..., error = ...)
script.Stdout = TruncateOutput(output.Trim());
script.Stderr = TruncateOutput(error.Trim());
// Combined pattern (write output done. output = ..., error = ...): both texts as
// AgentExecutor wrote them to the result files — the error part is the error file
// IME judges. The "error from script" line before it is a snapshot taken before the
// output readers finished, so this one replaces it, an empty stdout included.
script.Stdout = TruncateOutput(output?.Trim());
script.Stderr = TruncateOutput(error.Value.Trim());
}
else if (string.Equals(outputType, "stderr", StringComparison.OrdinalIgnoreCase))
{
@@ -507,7 +592,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
if (string.IsNullOrEmpty(id)) return;
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId, CaptureLastMatchedProvenance());
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId, CaptureLastMatchedProvenance(), ImeUserIdFrom(match.Value));
}
/// <summary>
@@ -521,48 +606,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// line reopened the slot, produced a fallback duplicate (session e7f3c910).
/// <see cref="FlushPendingPlatformScriptResults"/> emits the held completion.
/// </summary>
internal void CompletePlatformScriptFromImeResult(string policyId, string result, DateTime? resultLineTimestampUtc, string patternId, CmTraceLineProvenance resultProvenance)
internal void CompletePlatformScriptFromImeResult(string policyId, string result, DateTime? resultLineTimestampUtc, string patternId, CmTraceLineProvenance resultProvenance, string userId = null)
{
// Dedup: the deadline-based fallback (FlushPendingPlatformScriptResults) may already have
// emitted this script from its AgentExecutor exit code because IME's authoritative
// PS-SCRIPT-RESULT line arrived late. The fallback carries the same exit code + stdout,
// so re-emitting here would duplicate the timeline entry and inflate counts. Drop the
// now-redundant pending entry and skip.
if (_platformScriptResultEmitted.ContainsKey(policyId))
// The result belongs to the run of this policy that started last before its line —
// the run in flight, or an earlier one that waits for its result while a later run
// already started (ParkPlatformRun).
var script = FindRunForResult(policyId, userId, resultLineTimestampUtc);
if (script == null)
{
_logger.Debug($"ImeLogTracker: platform script {policyId} PS-SCRIPT-RESULT arrived after fallback emit — skipping duplicate");
_pendingPlatformScripts.Remove(policyId);
return;
}
// Dedup: the deadline-based fallback (FlushPendingPlatformScriptResults) may already
// have emitted this run from its AgentExecutor end block because IME's authoritative
// PS-SCRIPT-RESULT line arrived late, or the same result line is read again.
// Re-emitting would duplicate the timeline entry and inflate counts.
if (_platformScriptResultEmitted.ContainsKey(policyId))
{
_logger.Debug($"ImeLogTracker: platform script {policyId} PS-SCRIPT-RESULT arrived after its run was emitted — skipping duplicate");
return;
}
// Merge with pending AgentExecutor data if available
if (_pendingPlatformScripts.TryGetValue(policyId, out var script))
{
// Stale-result guard: on an intra-lifetime re-run of the same policy, run 1's
// late PS-SCRIPT-RESULT can arrive after run 2's start line already created a
// fresh slot (the start clears the emitted-marker). Merging would pair run 2's
// StartedAtUtc/state with run 1's result and then drop run 2's genuine result at
// the marker check above. A result line always postdates its own run's start
// line in the source log, so a result older than the pending slot's start
// belongs to a previous run — drop it and keep the live slot intact.
if (script.StartedAtUtc.HasValue
&& resultLineTimestampUtc.HasValue
&& resultLineTimestampUtc.Value < script.StartedAtUtc.Value)
// Stale-result guard: every run of this policy in flight started after the result
// line — a result line always postdates its own run's start line in the source log,
// so it belongs to a previous run. Keep the live runs intact.
if (HasLivePlatformRun(policyId))
{
_logger.Debug($"ImeLogTracker: platform script {policyId} result predates the current run's start — stale result from a previous run, skipping");
return;
}
}
else
{
script = new ScriptExecutionState
{
PolicyId = policyId,
ScriptType = "platform"
ScriptType = "platform",
UserId = userId,
RunId = NewRunId(),
};
_pendingPlatformScripts[policyId] = script;
ScriptRuns.NoteStarted(userId, policyId, UtcNowProvider());
}
if (script.UserId == null && userId != null) script.UserId = userId;
ResolveParkedRunsBefore(script);
script.Result = result;
script.ResultSource = "ime_policy_result";
script.ResultObservedAtUtc = resultLineTimestampUtc ?? UtcNowProvider();
@@ -591,19 +674,150 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private void EmitPlatformScriptResult(ScriptExecutionState script, string note = null)
{
_logger.Info($"ImeLogTracker: platform script completed: {script.PolicyId}, result={script.Result}, exit={script.ExitCode}{(note != null ? $" ({note})" : string.Empty)}");
if (string.IsNullOrEmpty(script.RunId)) script.RunId = NewRunId();
EmitScriptEvent(script);
// The register keeps the latest run per (user, policy) for the registry reconciliation:
// a run that waited for its result while a later one started must not take that later
// run's entry.
if (!HasLaterLivePlatformRun(script)) ScriptRuns.NoteEmitted(script, UtcNowProvider());
// The run's source stamp, capped at the clock: a result line cannot be read before it
// was written, so a stamp ahead of the clock is a wrong zone assumption on that line
// (a rewound result resolved in the reader's zone, session 4377911b: +1 h). Left
// uncapped, the marker made the genuine NEXT run's start line read as a late line of
// this run and its result a duplicate.
// this run and its result a duplicate. Only ever raised: a run emitted after a later
// one must not move the reference back.
var runStamp = script.ResultObservedAtUtc ?? script.ExitObservedAtUtc ?? UtcNowProvider();
var nowUtc = UtcNowProvider();
_platformScriptResultEmitted[script.PolicyId] = runStamp > nowUtc ? nowUtc : runStamp;
_pendingPlatformScripts.Remove(script.PolicyId);
var stamp = runStamp > nowUtc ? nowUtc : runStamp;
if (!_platformScriptResultEmitted.TryGetValue(script.PolicyId, out var previousStamp) || stamp > previousStamp)
_platformScriptResultEmitted[script.PolicyId] = stamp;
if (_pendingPlatformScripts.TryGetValue(script.PolicyId, out var current) && ReferenceEquals(current, script))
_pendingPlatformScripts.Remove(script.PolicyId);
else
_parkedPlatformScripts.Remove(script);
_stateDirty = true;
}
private static string NewRunId() => Guid.NewGuid().ToString("D");
/// <summary>A run older than this by source time is from a previous enrollment, not a run in flight.</summary>
private static readonly TimeSpan StalePlatformRunAge = TimeSpan.FromHours(24);
private static bool SameUserOrUnknown(string a, string b)
=> a == null || b == null || string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
private static DateTime StartOf(ScriptExecutionState run) => run.StartedAtUtc ?? DateTime.MinValue;
/// <summary>The runs of a policy that are not emitted yet: the earlier ones waiting for their IME result, then the one in flight.</summary>
private IEnumerable<ScriptExecutionState> LivePlatformRuns(string policyId)
{
foreach (var run in _parkedPlatformScripts)
{
if (string.Equals(run.PolicyId, policyId, StringComparison.OrdinalIgnoreCase)) yield return run;
}
if (_pendingPlatformScripts.TryGetValue(policyId, out var current)) yield return current;
}
private bool HasLivePlatformRun(string policyId) => LivePlatformRuns(policyId).Any();
/// <summary>The live run a start marker names, or null once that run was emitted.</summary>
private ScriptExecutionState FindLivePlatformRun(string policyId, string runId)
=> LivePlatformRuns(policyId).FirstOrDefault(r => string.Equals(r.RunId, runId, StringComparison.Ordinal));
private bool HasLaterLivePlatformRun(ScriptExecutionState script)
=> LivePlatformRuns(script.PolicyId).Any(r => !ReferenceEquals(r, script)
&& SameUserOrUnknown(r.UserId, script.UserId) && StartOf(r) > StartOf(script));
/// <summary>
/// The live run an IME result line belongs to: of the runs of its policy that started at or before the line,
/// the latest — one of the same IME user when there is one.
/// </summary>
private ScriptExecutionState FindRunForResult(string policyId, string userId, DateTime? resultLineTimestampUtc)
{
ScriptExecutionState sameUser = null, anyUser = null;
foreach (var run in LivePlatformRuns(policyId))
{
if (resultLineTimestampUtc.HasValue && run.StartedAtUtc.HasValue && run.StartedAtUtc.Value > resultLineTimestampUtc.Value) continue;
if (anyUser == null || StartOf(run) > StartOf(anyUser)) anyUser = run;
if (SameUserOrUnknown(run.UserId, userId) && (sameUser == null || StartOf(run) > StartOf(sameUser))) sameUser = run;
}
return sameUser ?? anyUser;
}
/// <summary>
/// Moves the run in flight aside when the next executor run of its policy starts: it keeps its own end block and
/// waits for its IME result (<see cref="FindRunForResult"/>), the exit-code fallback, or a later run's result.
/// </summary>
private void ParkPlatformRun(ScriptExecutionState run)
{
_pendingPlatformScripts.Remove(run.PolicyId);
_parkedPlatformScripts.Add(run);
_stateDirty = true;
if (_parkedPlatformScripts.Count > MaxParkedPlatformScripts)
{
var oldest = _parkedPlatformScripts.OrderBy(StartOf).First();
_logger.Warning($"ImeLogTracker: more than {MaxParkedPlatformScripts} platform runs wait for their IME result — resolving the oldest ({oldest.PolicyId}, started {oldest.StartedAtUtc:o}) now");
ResolveParkedRun(oldest, "too many runs waiting for their IME result");
}
_logger.Debug($"ImeLogTracker: platform script {run.PolicyId}: the next executor run started before IME's result of run {run.RunId} was read — that run keeps its end block and waits for its result");
}
/// <summary>
/// Results arrive in run order: a parked run of the same IME user that started before the run a result belongs to
/// gets none any more (its result line was lost) — it goes out now, ahead of the later run.
/// </summary>
private void ResolveParkedRunsBefore(ScriptExecutionState owner)
{
if (_parkedPlatformScripts.Count == 0 || !owner.StartedAtUtc.HasValue) return;
var earlier = _parkedPlatformScripts
.Where(r => !ReferenceEquals(r, owner)
&& string.Equals(r.PolicyId, owner.PolicyId, StringComparison.OrdinalIgnoreCase)
&& SameUserOrUnknown(r.UserId, owner.UserId)
&& r.StartedAtUtc.HasValue && r.StartedAtUtc.Value < owner.StartedAtUtc.Value)
.OrderBy(StartOf)
.ToList();
foreach (var run in earlier) ResolveParkedRun(run, "a later run of this policy got its IME result first");
}
/// <summary>A parked run that will not get its IME result: out with what it has, or dropped when it has nothing.</summary>
private void ResolveParkedRun(ScriptExecutionState run, string why)
{
if (run.Result != null)
{
EmitPlatformScriptResult(run, why);
}
else if (run.ExitCode.HasValue)
{
EmitFromExecutorEndBlock(run, why);
}
else
{
_parkedPlatformScripts.Remove(run);
_stateDirty = true;
_logger.Debug($"ImeLogTracker: platform script {run.PolicyId} run {run.RunId} has neither IME result nor executor end block — dropped ({why})");
}
}
/// <summary>
/// IME's own verdict from a run's end block: Success exactly when the error file was empty
/// (<c>IsNullOrEmpty(errorFile.Trim())</c> in IME), whatever the exit code — PowerShell returns 0 after a
/// non-terminating error and any code a script exits with. Only when the stderr line was never read does the
/// exit code stand in.
/// </summary>
internal static string ImeResultFromEndBlock(ScriptExecutionState script)
{
if (script.Stderr != null) return string.IsNullOrWhiteSpace(script.Stderr) ? "Success" : "Failed";
return script.ExitCode == 0 ? "Success" : "Failed";
}
private void EmitFromExecutorEndBlock(ScriptExecutionState script, string why)
{
script.Result = ImeResultFromEndBlock(script);
script.ResultSource = "agentexecutor_fallback";
var basis = script.Stderr == null ? $"exit code {script.ExitCode}, stderr not read" : script.Stderr.Trim().Length == 0 ? "empty stderr" : "stderr present";
EmitPlatformScriptResult(script, $"{why} — {script.Result} from the executor end block ({basis}) (fallback)");
}
/// <summary>
/// Grace period after a platform script's AgentExecutor exit code is observed before
/// <see cref="FlushPendingPlatformScriptResults"/> emits a completion from that exit code.
@@ -641,29 +855,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// out once its executor end block has been read — the block arrives within one pass, so
/// stdout/stderr are in by now — or after <see cref="PlatformScriptEndBlockGrace"/>.</item>
/// <item>A script with a known AgentExecutor exit code but no IME <c>PS-SCRIPT-RESULT</c>
/// line within <see cref="PlatformScriptResultGrace"/> is emitted from the exit code
/// (0 → Success, else Failed), tagged <c>resultSource=agentexecutor_fallback</c> so the
/// data is honest about its provenance. Without this, scripts that completed shortly
/// before the agent terminates were silently dropped.</item>
/// line within <see cref="PlatformScriptResultGrace"/> is emitted from its end block by
/// IME's own rule (<see cref="ImeResultFromEndBlock"/>), tagged
/// <c>resultSource=agentexecutor_fallback</c> so the data is honest about its provenance.
/// Without this, scripts that completed shortly before the agent terminates were silently
/// dropped.</item>
/// </list>
/// Parked runs go first, oldest first, so a policy's runs leave in run order; one without
/// result and end block keeps waiting — across a shutdown too — until a later run's result
/// resolves it or it is a day old.
/// Runs on the single-threaded polling loop (no locking needed, same as the handlers).
/// <paramref name="force"/> bypasses both grace checks for the final pass on shutdown.
/// </summary>
internal void FlushPendingPlatformScriptResults(DateTime nowUtc, bool force = false)
{
if (_pendingPlatformScripts.Count == 0) return;
if (_pendingPlatformScripts.Count == 0 && _parkedPlatformScripts.Count == 0) return;
var fallbackEmitted = false;
foreach (var script in _pendingPlatformScripts.Values.ToList())
foreach (var run in _parkedPlatformScripts.OrderBy(StartOf).ToList())
{
// Already emitted by the authoritative path — just drop the stale buffer entry.
if (_platformScriptResultEmitted.ContainsKey(script.PolicyId))
if (run.Result != null)
{
_pendingPlatformScripts.Remove(script.PolicyId);
_stateDirty = true;
if (run.ExitCode.HasValue || force || !run.ResultHeldSinceUtc.HasValue || nowUtc - run.ResultHeldSinceUtc.Value >= PlatformScriptEndBlockGrace)
EmitPlatformScriptResult(run, run.ExitCode.HasValue ? "executor end block read after the result" : $"no executor end block within {PlatformScriptEndBlockGrace.TotalSeconds:F0}s");
continue;
}
if (run.ExitCode.HasValue)
{
if (!force && (!run.ExitObservedAtUtc.HasValue || nowUtc - run.ExitObservedAtUtc.Value < PlatformScriptResultGrace)) continue;
EmitFromExecutorEndBlock(run, $"result not seen within {PlatformScriptResultGrace.TotalSeconds:F0}s{(force ? ", shutdown flush" : string.Empty)}");
fallbackEmitted = true;
continue;
}
if (run.StartedAtUtc.HasValue && nowUtc - run.StartedAtUtc.Value > StalePlatformRunAge)
{
_parkedPlatformScripts.Remove(run);
_stateDirty = true;
_logger.Debug($"ImeLogTracker: platform script {run.PolicyId} run {run.RunId} waited a day without IME result or executor end block — dropped");
}
}
foreach (var script in _pendingPlatformScripts.Values.ToList())
{
if (script.Result != null)
{
if (script.ExitCode.HasValue)
@@ -682,10 +915,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
if (nowUtc - script.ExitObservedAtUtc.Value < PlatformScriptResultGrace) continue;
}
script.Result = script.ExitCode.Value == 0 ? "Success" : "Failed";
script.ResultSource = "agentexecutor_fallback";
EmitPlatformScriptResult(script,
$"result not seen within {PlatformScriptResultGrace.TotalSeconds:F0}s{(force ? ", shutdown flush" : string.Empty)} — emitted from AgentExecutor exit code {script.ExitCode.Value} (fallback)");
EmitFromExecutorEndBlock(script, $"result not seen within {PlatformScriptResultGrace.TotalSeconds:F0}s{(force ? ", shutdown flush" : string.Empty)}");
fallbackEmitted = true;
}
@@ -697,7 +927,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// <summary>Test seam: inject a pending platform script as if AgentExecutor.log had reported
/// its start + exit code, without an IME PS-SCRIPT-RESULT line.</summary>
internal void SeedPendingPlatformScriptForTesting(string policyId, int? exitCode, DateTime? exitObservedAtUtc, string stdout = null, DateTime? startedAtUtc = null,
CmTraceLineProvenance startedAtProvenance = null, CmTraceLineProvenance exitProvenance = null)
CmTraceLineProvenance startedAtProvenance = null, CmTraceLineProvenance exitProvenance = null, string stderr = null)
{
// Mirrors a fresh execution start: a new run clears any prior emitted-marker (see
// HandleScriptStarted) so re-runs of the same policy within one lifetime emit again.
@@ -706,9 +936,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
PolicyId = policyId,
ScriptType = "platform",
RunId = NewRunId(),
ExitCode = exitCode,
ExitObservedAtUtc = exitObservedAtUtc,
Stdout = stdout,
Stderr = stderr,
StartedAtUtc = startedAtUtc,
StartedAtProvenance = startedAtProvenance,
ExitProvenance = exitProvenance,
@@ -717,6 +949,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
RecordInvocationMarker(policyId, isClose: false);
}
/// <summary>Test seam: earlier runs waiting for their IME result while a later run of their policy is in flight.</summary>
internal int ParkedPlatformScriptCountForTest => _parkedPlatformScripts.Count;
/// <summary>Test seam: the authoritative IME PS-SCRIPT-RESULT path for a platform script
/// (the production handler minus regex extraction). <paramref name="resultLineTimestampUtc"/>
/// stands in for the CMTrace timestamp of the PS-SCRIPT-RESULT line (production reads it
@@ -1163,11 +1398,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
internal static readonly TimeSpan HeldScriptStartGrace = TimeSpan.FromMinutes(30);
internal static readonly TimeSpan HeldScriptStartShutdownGrace = TimeSpan.FromMinutes(5);
private static string TruncateOutput(string output)
internal static string TruncateOutput(string output)
{
if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength)
return output;
return output.Substring(0, MaxScriptOutputLength) + "...[truncated]";
return output.Substring(0, MaxScriptOutputLength) + OutputTruncationMarker;
}
// -----------------------------------------------------------------------
@@ -123,16 +123,19 @@ 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); a changed entry below the old
// bookmark is fresh only when the bytes it replaced were seen moments ago, and never 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,21 +167,21 @@ 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)
{
var divergence = await FindOverwriteAsync(stream, ledger, guardFrom, startPos, token);
var divergence = await FindOverwriteAsync(stream, ledger, guardFrom, startPos, passNowUtc, token);
_verifiedBytes += startPos - guardFrom;
if (divergence >= 0)
{
// The guard saw only the tail of the ledger; the block may start earlier.
var earliest = await FindOverwriteAsync(stream, ledger, ledger.StartOffset, startPos, token);
var earliest = await FindOverwriteAsync(stream, ledger, ledger.StartOffset, startPos, passNowUtc, token);
_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 +262,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;
@@ -269,11 +272,27 @@ 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
if (ledger != null) ledger.Record(entry, passNowUtc);
var unchanged = rewind != null && rewind.IsUnchanged(entry);
if (unchanged) RestoreInvocationMarkers(rewind, entry.Offset);
_currentPassLinesAreFresh = passLinesAreFresh && entry.Offset >= oldBookmark;
// 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
// Behind the old bookmark only changed entries arrive here: fresh when the bytes they replaced
// were seen moments ago (RewoundEntryIsFresh), never a calibration anchor.
_currentPassLinesAreFresh = entry.Offset >= oldBookmark
? passLinesAreFresh
: rewind != null && RewoundEntryIsFresh(passLinesAreFresh, rewind.EntryAtRewindPointReplaced, rewind.OldestReplacedSeenUtc, passNowUtc);
// --- Normal processing (single-line or completed multiline) ---
CmTraceLogEntry parsed;
@@ -62,6 +62,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public long Offset;
public int Length;
public ulong Hash;
/// <summary>
/// When these bytes were last seen as they are (read, seeded or verified unchanged): bytes found different
/// at this position later were written after it. Not part of the identity.
/// </summary>
public DateTime SeenUtc;
// Identity is (offset, hash): a tail first read unterminated and later completed
// keeps its hash but grows by its terminator.
@@ -100,11 +105,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
return idx < Entries.Count ? Entries[idx].Offset : -1;
}
public void Record(AssembledEntry e)
public void Record(AssembledEntry e, DateTime seenUtc)
{
var idx = IndexOfFirstAtOrAfter(e.Offset);
if (idx < Entries.Count) Entries.RemoveRange(idx, Entries.Count - idx);
Entries.Add(new LedgerEntry { Offset = e.Offset, Length = e.Length, Hash = e.Hash });
Entries.Add(new LedgerEntry { Offset = e.Offset, Length = e.Length, Hash = e.Hash, SeenUtc = seenUtc });
}
/// <summary>Drops everything from <paramref name="offset"/> on and returns it — the entries a rewind must not process twice.</summary>
@@ -197,7 +202,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
ledger.Seeded = true;
if (ledger.Entries.Count == 0 && bookmark > 0)
await SeedLedgerAsync(filePath, ledger, bookmark, token).ConfigureAwait(false);
await SeedLedgerAsync(filePath, ledger, bookmark, nowUtc, token).ConfigureAwait(false);
}
var due = ledger.VerifyRequested || (InContention(nowUtc) && nowUtc >= ledger.NextCadenceVerifyUtc);
@@ -213,7 +218,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
long divergence;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
divergence = await FindOverwriteAsync(stream, ledger, from, bookmark, token).ConfigureAwait(false);
divergence = await FindOverwriteAsync(stream, ledger, from, bookmark, nowUtc, token).ConfigureAwait(false);
if (token.IsCancellationRequested) return -1;
ledger.VerifyRequested = false;
@@ -228,8 +233,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// Re-reads [fromOffset, toOffset) with the read loop's assembler and compares against the
/// ledger. Returns the offset of the first entry whose bytes changed — a ledger entry not
/// reproduced, or an entry the ledger never had — or -1 when everything reads as before.
/// Every entry found unchanged is stamped seen at <paramref name="nowUtc"/>.
/// </summary>
private async Task<long> FindOverwriteAsync(FileStream stream, FileLedger ledger, long fromOffset, long toOffset, CancellationToken token)
private async Task<long> FindOverwriteAsync(FileStream stream, FileLedger ledger, long fromOffset, long toOffset, DateTime nowUtc, CancellationToken token)
{
var entries = ledger.Entries;
var idx = ledger.IndexOfFirstAtOrAfter(fromOffset);
@@ -255,6 +261,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
if (idx < entries.Count && entries[idx].Offset == e.Offset)
{
if (entries[idx].Hash != e.Hash) return e.Offset;
var confirmed = entries[idx];
confirmed.SeenUtc = nowUtc;
entries[idx] = confirmed;
idx++;
continue;
}
@@ -265,7 +274,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
/// <summary>After a restart the ledger is empty: fingerprint the entries just behind the persisted bookmark without processing them.</summary>
private async Task SeedLedgerAsync(string filePath, FileLedger ledger, long bookmark, CancellationToken token)
private async Task SeedLedgerAsync(string filePath, FileLedger ledger, long bookmark, DateTime nowUtc, CancellationToken token)
{
var from = Math.Max(0, bookmark - RestartSeedBytes);
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
@@ -288,18 +297,69 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
if (assembler.Feed(line, reader.LastLineStart, reader.Position, reader.LastLineTruncated, reader.LastLineHash, out e)
== CmTraceEntryAssembler.Outcome.Entry
&& e.Offset + e.Length <= bookmark)
ledger.Record(e);
ledger.Record(e, nowUtc);
}
}
}
/// <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, long rewindTo)
{
FileName = fileName;
Unchanged = unchanged;
Markers = markers;
LastPlatformMarker = lastPlatformMarker;
foreach (var replaced in unchanged)
{
if (replaced.Offset == rewindTo) EntryAtRewindPointReplaced = true;
if (!OldestReplacedSeenUtc.HasValue || replaced.SeenUtc < OldestReplacedSeenUtc.Value) OldestReplacedSeenUtc = replaced.SeenUtc;
}
}
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; }
/// <summary>The ledger held the entry at the rewind point: the changed bytes lie where something was seen before.</summary>
public bool EntryAtRewindPointReplaced { get; }
/// <summary>The earliest time any of the replaced bytes was last seen as it was: every changed byte was written after it.</summary>
public DateTime? OldestReplacedSeenUtc { get; }
public bool IsUnchanged(AssembledEntry entry) => Unchanged.Contains(new LedgerEntry { Offset = entry.Offset, Hash = entry.Hash });
}
/// <summary>
/// Whether an entry a rewind re-processes (it reads differently than before) is fresh. Its bytes were written after
/// the bytes it replaced were last seen as they were — IME stamps a line when it writes it — so when that was at
/// most <see cref="FreshLineMaxAge"/> ago the entry's age is bounded like an appended line's and it may anchor
/// itself (session d29e27e3: recovered result lines fell back to the agent's stale zone and lost their duration).
/// Not fresh when the pass is not (the first pass after a restart never anchors), when the divergence lies in bytes
/// the ledger never held (nothing dates them), or when the clock stepped back since.
/// </summary>
internal static bool RewoundEntryIsFresh(bool passLinesAreFresh, bool entryAtRewindPointReplaced, DateTime? oldestReplacedSeenUtc, DateTime passNowUtc)
{
if (!passLinesAreFresh || !entryAtRewindPointReplaced || !oldestReplacedSeenUtc.HasValue) return false;
var sinceSeen = passNowUtc - oldestReplacedSeenUtc.Value;
return sinceSeen >= TimeSpan.Zero && sinceSeen <= FreshLineMaxAge;
}
/// <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 +374,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
_logger.Debug(message);
}
return removed;
return new RewindState(fileName, removed, markers, tookLastPlatformMarker ? lastPlatformMarker : null, rewindTo);
}
private long RetentionFloor(string fileName, long bookmark)
@@ -331,20 +391,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// -----------------------------------------------------------------------
//
// AgentExecutor.log carries "Powershell exit code is N" / "write output done" without a
// policy id. Their owner is decided by FILE POSITION, not by the order lines were read:
// an executor writes its end block at its own stream position, i.e. directly after its
// own start block in the final bytes, so the nearest preceding invocation marker names
// it — and stays right after a rewind re-reads that region. A marker is a platform start
// (policy id), some other invocation (detection, remediation, requirement: null) or a
// close ("gets invoked" banner, "Agent executor completed").
// policy id. Their owner is decided by FILE POSITION: the nearest preceding marker — a
// platform start (policy id), some other invocation (detection, remediation, requirement:
// null), a close ("gets invoked" banner, "Agent executor completed") or a writer boundary.
//
// Every executor writes its lines contiguously from its own stream position, so its end
// block follows its own start block — but that start block may be gone. Two executors that
// open the file at the same end write their start blocks over each other, and the end block
// of the one that lost then sits behind the OTHER executor's start block (session 756970cf:
// a detection script's exit code and output on a platform script). Where one writer's bytes
// meet another's, the cut leaves an entry that is not exactly one CMTrace record — a
// fragment, a merged line or an empty line — unless the line boundaries of both writers
// coincide byte-exactly. That writer boundary closes the platform invocation in front of it.
// AgentExecutor.log only: in the IME log the service writes the markers and the lines they
// own itself, and its fragments are executor telemetry lines.
//
// A marker lives exactly as long as the bytes of its entry: a rewind sets the markers from
// the rewind point on aside, and an entry that reads unchanged gets its markers back.
private sealed class InvocationMarker
{
public long Offset;
public string PolicyId;
/// <summary>
/// The platform run the start line belongs to: the lines behind it go to that run while it is pending or
/// waits for its IME result, and nowhere once it was emitted — never to a later run of the same policy.
/// Null only on seeded test markers, which resolve by policy.
/// </summary>
public string RunId;
public bool IsClose;
/// <summary>Set on a writer boundary: the platform invocation it closed.</summary>
public string ClosedPolicyId;
}
/// <summary>Run id of a start line whose run was emitted already (a late line): owns its lines, matches no live run.</summary>
private const string EmittedRunId = "emitted";
private const string ExecutorLogFileName = "AgentExecutor.log";
private readonly Dictionary<string, List<InvocationMarker>> _invocationMarkers =
new Dictionary<string, List<InvocationMarker>>(StringComparer.OrdinalIgnoreCase);
private InvocationMarker _lastPlatformMarker;
@@ -354,24 +438,62 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private readonly HashSet<string> _platformMarkerFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private long _testEntryOrdinal;
private void RecordInvocationMarker(string policyId, bool isClose)
/// <summary>
/// The executor log, live or rotated (<c>AgentExecutor-yyyyMMdd-HHmmss.log</c>): its invocations are one process
/// each, so a writer boundary ends an invocation — in the final bytes of a rotation as much as in the live file.
/// </summary>
internal static bool IsWriterBoundaryLogFile(string fileName)
=> string.Equals(fileName, ExecutorLogFileName, StringComparison.OrdinalIgnoreCase)
|| (fileName != null
&& fileName.StartsWith("AgentExecutor-", StringComparison.OrdinalIgnoreCase)
&& fileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// True when the entry is exactly one CMTrace record: it opens with <c>&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, string runId = null)
{
if (_currentEntryOffset < 0) return;
var file = _currentSourceFileName ?? TestSourceFileName;
var list = GetOrCreateMarkerList(file);
if (list.Count > 0 && list[list.Count - 1].Offset == _currentEntryOffset)
{
// Two patterns on one line: the platform start outranks the generic argument line.
// A writer boundary is never upgraded.
var last = list[list.Count - 1];
if (!isClose && policyId != null)
if (!isClose && policyId != null && last.ClosedPolicyId == null)
{
last.PolicyId = policyId;
last.RunId = runId;
last.IsClose = false;
_lastPlatformMarker = last;
_platformMarkerFiles.Add(file);
@@ -379,9 +501,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
return;
}
if (list.Count >= MaxInvocationMarkersPerFile) list.RemoveAt(0);
var marker = new InvocationMarker { Offset = _currentEntryOffset, PolicyId = policyId, IsClose = isClose };
list.Add(marker);
var marker = new InvocationMarker { Offset = _currentEntryOffset, PolicyId = policyId, RunId = runId, IsClose = isClose };
AppendInvocationMarker(list, marker);
if (!isClose && policyId != null)
{
_lastPlatformMarker = marker;
@@ -390,21 +511,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
/// <summary>
/// The pending platform script that owns the entry being processed, or null.
/// <paramref name="ownerPolicyId"/> names the owning platform script even when its slot is
/// gone (completion already emitted); null when no platform invocation owns the entry.
/// An entry of <paramref name="fileName"/> at <paramref name="offset"/> that is not exactly one CMTrace record:
/// the bytes behind it were written by another process than the bytes in front of it. Closes the platform
/// invocation that owns the position; records nothing when none is open there (a cut multi-line output yields
/// one fragment per line). Idempotent at the same offset.
/// </summary>
private ScriptExecutionState ResolvePlatformScriptForCurrentEntry(out string ownerPolicyId)
private void CloseAtWriterBoundary(string fileName, long offset, string text)
{
var owner = FindOwningMarker(fileName, offset);
if (owner == null || owner.IsClose || owner.PolicyId == null) return;
var list = GetOrCreateMarkerList(fileName);
if (list.Count > 0 && list[list.Count - 1].Offset >= offset) return;
AppendInvocationMarker(list, new InvocationMarker { Offset = offset, IsClose = true, ClosedPolicyId = owner.PolicyId });
var kind = string.IsNullOrEmpty(text) ? "empty line" : text.StartsWith(LogOpenTag) ? "merged line" : "fragment";
_logger.Debug($"ImeLogTracker: writer boundary in {fileName} at offset {offset} ({kind}, {text?.Length ?? 0} chars) closes platform script {owner.PolicyId} (opened at {owner.Offset}) — the bytes behind it belong to another executor");
}
/// <summary>The marker that owns <paramref name="offset"/> in <paramref name="file"/>, or null.</summary>
private InvocationMarker FindOwningMarker(string file, long offset)
{
ownerPolicyId = null;
var file = _currentSourceFileName ?? TestSourceFileName;
InvocationMarker marker = null;
List<InvocationMarker> list = null;
if (_currentEntryOffset >= 0 && _invocationMarkers.TryGetValue(file, out list))
if (offset >= 0 && _invocationMarkers.TryGetValue(file, out list))
{
for (var i = list.Count - 1; i >= 0; i--)
{
if (list[i].Offset <= _currentEntryOffset)
if (list[i].Offset <= offset)
{
marker = list[i];
break;
@@ -420,24 +554,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// (session 24dc69d1: the health-script worker's exit line pre-filled a platform slot's
// exit code, its launch line the slot's context).
if (marker == null && (list == null || list.Count == 0) && _platformMarkerFiles.Contains(file)) marker = _lastPlatformMarker;
return marker;
}
/// <summary>
/// The pending platform script that owns the entry being processed, or null.
/// <paramref name="ownerPolicyId"/> names the owning platform script even when its slot is
/// gone (completion already emitted); null when no platform invocation owns the entry.
/// </summary>
private ScriptExecutionState ResolvePlatformScriptForCurrentEntry(out string ownerPolicyId)
{
ownerPolicyId = null;
var marker = FindOwningMarker(_currentSourceFileName ?? TestSourceFileName, _currentEntryOffset);
if (marker == null || marker.IsClose || marker.PolicyId == null) return null;
ownerPolicyId = marker.PolicyId;
if (marker.RunId != null) return FindLivePlatformRun(marker.PolicyId, marker.RunId);
ScriptExecutionState state;
return _pendingPlatformScripts.TryGetValue(marker.PolicyId, out state) ? state : null;
}
private void InvalidateInvocationMarkersFrom(string fileName, long offset)
/// <summary>Debug trace for an executor line that a writer boundary kept away from a platform script.</summary>
private void LogLineBehindWriterBoundary(string what)
{
var marker = FindOwningMarker(_currentSourceFileName ?? TestSourceFileName, _currentEntryOffset);
if (marker?.ClosedPolicyId == null) return;
_logger.Debug($"ImeLogTracker: {what} at offset {_currentEntryOffset} lies behind the writer boundary at {marker.Offset} that closed platform script {marker.ClosedPolicyId} — another executor's end block, not attributed");
}
/// <summary>Removes the markers from <paramref name="offset"/> on and returns them in offset order.</summary>
private List<InvocationMarker> TakeInvocationMarkersFrom(string fileName, long offset, out bool tookLastPlatformMarker)
{
tookLastPlatformMarker = false;
var taken = new List<InvocationMarker>();
List<InvocationMarker> list;
if (!_invocationMarkers.TryGetValue(fileName, out list) || list.Count == 0) return;
var removedLast = false;
for (var i = list.Count - 1; i >= 0 && list[i].Offset >= offset; i--)
if (!_invocationMarkers.TryGetValue(fileName, out list) || list.Count == 0) return taken;
var from = list.Count;
while (from > 0 && list[from - 1].Offset >= offset) from--;
for (var i = from; i < list.Count; i++)
{
if (ReferenceEquals(list[i], _lastPlatformMarker)) removedLast = true;
list.RemoveAt(i);
if (ReferenceEquals(list[i], _lastPlatformMarker)) tookLastPlatformMarker = true;
taken.Add(list[i]);
}
if (removedLast)
list.RemoveRange(from, list.Count - from);
if (tookLastPlatformMarker)
{
_lastPlatformMarker = null;
for (var i = list.Count - 1; i >= 0; i--)
@@ -449,6 +611,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 +658,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,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private readonly Dictionary<string, ScriptExecutionState> _pendingPlatformScripts =
new Dictionary<string, ScriptExecutionState>(StringComparer.OrdinalIgnoreCase);
// Earlier runs of a policy whose next executor run started before IME's result of them was
// read (IME re-runs a failed script on later check-ins; the first pass reads all of
// AgentExecutor.log before the IME log). Each keeps its own end block until its result
// arrives — session eff1b413 lost that and paired the first result with the last run's block.
private readonly List<ScriptExecutionState> _parkedPlatformScripts = new List<ScriptExecutionState>();
private const int MaxParkedPlatformScripts = 64;
/// <summary>
/// The platform runs this tracker started and emitted, for the registry reconciliation that
/// checks them against IME's saved results (D-316). Shared with the registry host's thread.
/// </summary>
internal PlatformScriptRunRegister ScriptRuns { get; } = new PlatformScriptRunRegister();
// Health-script (remediation) line-by-line accumulator — single slot because IME
// executes health scripts SEQUENTIALLY within a session (verified across multiple
// diagnostic captures: ProcessScript → context → exit → stdout → stderr → compliance,
@@ -240,7 +253,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// the 32K-char Table Storage property limit that bounds the event downstream. Growth
// driver is the number of log lines the bootstrap chain writes, so re-measure before
// adding a chatty step rather than raising this again.
private const int MaxScriptOutputLength = 8192;
internal const int MaxScriptOutputLength = 8192;
internal const string OutputTruncationMarker = "...[truncated]";
private const int MaxMultiLineBufferLines = 100;
// Size cap for ONE entry — a single physical line (enforced by BoundedLineReader before
// the line is materialized) and an assembled multiline entry alike. Any process able to
@@ -389,6 +403,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
VerifiedBytes = _verifiedBytes,
PassMaxMs = _passMaxMs,
PassGapMaxMs = _passGapMaxMs,
PlatformOutputVerified = ScriptRuns.Verified,
PlatformOutputCorrected = ScriptRuns.Corrected,
PatternHits = new Dictionary<string, int>(_patternHits, StringComparer.OrdinalIgnoreCase),
};
}
@@ -1034,6 +1050,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
internal async Task RunPollPassAsync(CancellationToken token)
{
var startMs = MonotonicMillisProvider();
var passStartedUtc = UtcNowProvider();
if (_lastPassEndMs >= 0 && startMs - _lastPassEndMs > _passGapMaxMs)
{
_passGapMaxMs = startMs - _lastPassEndMs;
@@ -1045,6 +1062,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
FlushRecurringScripts(UtcNowProvider(), shuttingDown: false);
CheckPendingTokenFailure(UtcNowProvider());
// The lines written before this pass started are read now — a held tail or a fragment
// awaiting its check follows a pass or two later, which TrackerCatchUp covers. The
// registry reconciliation waits for such a pass before it pairs an IME save with a run.
if (!token.IsCancellationRequested) ScriptRuns.NotePassCompleted(passStartedUtc);
if (ScriptRuns.ConsumeCountersChanged()) _stateDirty = true;
var endMs = MonotonicMillisProvider();
if (endMs - startMs > _passMaxMs)
{
@@ -1175,6 +1198,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
}
_parkedPlatformScripts.Clear();
if (state.ParkedPlatformScripts != null)
{
foreach (var parked in state.ParkedPlatformScripts)
{
if (!string.IsNullOrEmpty(parked?.PolicyId) && _parkedPlatformScripts.Count < MaxParkedPlatformScripts)
_parkedPlatformScripts.Add(parked);
}
}
_scriptTimeoutSuspectedPosted.Clear();
if (state.ScriptTimeoutSuspectedPosted != null)
{
@@ -1207,6 +1240,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_verifiedBytes = state.Health.VerifiedBytes;
_passMaxMs = state.Health.PassMaxMs;
_passGapMaxMs = state.Health.PassGapMaxMs;
ScriptRuns.RestoreCounters(state.Health.PlatformOutputVerified, state.Health.PlatformOutputCorrected);
}
if (state.PatternHits != null)
{
@@ -1259,6 +1293,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// must survive restarts — see the LoadState comment for the duplicate scenario.
PlatformScriptResultEmittedAt = new Dictionary<string, DateTime>(_platformScriptResultEmitted, StringComparer.OrdinalIgnoreCase),
PendingPlatformScripts = _pendingPlatformScripts.Values.ToList(),
ParkedPlatformScripts = _parkedPlatformScripts.ToList(),
ScriptTimeoutSuspectedPosted = _scriptTimeoutSuspectedPosted.ToList(),
RecurringScripts = _recurringScripts.ToPersisted(),
};
@@ -1283,6 +1318,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
VerifiedBytes = _verifiedBytes,
PassMaxMs = _passMaxMs,
PassGapMaxMs = _passGapMaxMs,
PlatformOutputVerified = ScriptRuns.Verified,
PlatformOutputCorrected = ScriptRuns.Corrected,
};
state.PatternHits = new Dictionary<string, int>(_patternHits, StringComparer.OrdinalIgnoreCase);
}
@@ -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; }
@@ -151,6 +151,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// that run from the start of the next one.
public Dictionary<string, DateTime> PlatformScriptResultEmittedAt { get; set; }
public List<ScriptExecutionState> PendingPlatformScripts { get; set; }
// Earlier runs that wait for their IME result while a later run of the same policy is in
// flight; a restart between reading AgentExecutor.log and the IME log must keep them. Null on
// older state files → none.
public List<ScriptExecutionState> ParkedPlatformScripts { get; set; }
public List<string> ScriptTimeoutSuspectedPosted { get; set; }
// Recurring health-script runs collapsed per policy. Saved in the same write as
@@ -185,6 +189,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// Poll-loop timing maxima (null-defaulting on older state files).
public long PassMaxMs { get; set; }
public long PassGapMaxMs { get; set; }
// Registry reconciliation of platform-script output (null-defaulting on older state files).
public long PlatformOutputVerified { get; set; }
public long PlatformOutputCorrected { get; set; }
}
public class PackageStateData
@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>What the registry reconciliation may do with a saved IME result right now.</summary>
internal enum PlatformRunClaim
{
/// <summary>The run is handed out for reconciliation; it is never handed out again.</summary>
Claimed,
/// <summary>Not decidable yet: the tracker has not read past the save, or the run is not emitted yet.</summary>
Wait,
/// <summary>No run of this agent owns the save (none seen, or already reconciled).</summary>
None,
}
/// <summary>One platform-script run as the tracker emitted it; the register hands out copies.</summary>
internal sealed class PlatformScriptRun
{
public string PolicyId;
public string UserId;
public string RunId;
public DateTime StartSeenUtc;
public bool Emitted;
public bool Claimed;
public string Result;
public string ResultSource;
public int? ExitCode;
/// <summary>As emitted: null when no output line of the run was read, empty when it said so.</summary>
public string Stdout;
public string Stderr;
public PlatformScriptRun Clone() => (PlatformScriptRun)MemberwiseClone();
}
/// <summary>
/// The platform-script runs the tracker has seen, the latest per (IME user id, policy id), for the
/// registry reconciliation (D-316). IME saves a run's output under
/// <c>Policies\&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 at most once per check-in and
/// saves after reporting that check-in's batch, so the next run of the key comes with a later
/// check-in. (Overlapping check-ins can run a key twice at once; then IME itself shares the key's
/// result files and the judge's foreign verdict is the safety net.) A run that waited for its IME
/// result while a later run of its key already started is emitted without touching that later
/// run's entry. A save is paired only after the tracker completed a pass that started
/// <see cref="TrackerCatchUp"/> after the save was seen — every line written before the save, the
/// run's start among them, has been read by then, a stalled poll loop included.
/// </para>
/// </summary>
internal sealed class PlatformScriptRunRegister
{
/// <summary>
/// How far behind a seen save the tracker must have started a completed pass before the save
/// is paired; covers the ledger's verify cadence (1 s) for a start line hidden by an overwrite.
/// </summary>
internal static readonly TimeSpan TrackerCatchUp = TimeSpan.FromSeconds(2);
internal const int MaxRuns = 256;
private readonly object _sync = new object();
private readonly Dictionary<string, PlatformScriptRun> _latest =
new Dictionary<string, PlatformScriptRun>(StringComparer.OrdinalIgnoreCase);
private DateTime _lastCompletedPassStartUtc = DateTime.MinValue;
private long _verified;
private long _corrected;
private bool _countersChanged;
private static string KeyOf(string userId, string policyId) => (userId ?? string.Empty) + "|" + policyId;
/// <summary>A platform run began: its start line, or its result when no start line was seen.</summary>
public void NoteStarted(string userId, string policyId, DateTime seenUtc)
{
if (string.IsNullOrEmpty(policyId)) return;
lock (_sync)
{
_latest[KeyOf(userId, policyId)] = new PlatformScriptRun { PolicyId = policyId, UserId = userId, StartSeenUtc = seenUtc };
TrimLocked();
}
}
/// <summary>The tracker emitted the run with these end-block values.</summary>
public void NoteEmitted(ScriptExecutionState script, DateTime emittedUtc)
{
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
lock (_sync)
{
var key = KeyOf(script.UserId, script.PolicyId);
if (!_latest.TryGetValue(key, out var run) || run.Emitted)
{
run = new PlatformScriptRun { PolicyId = script.PolicyId, UserId = script.UserId, StartSeenUtc = emittedUtc };
_latest[key] = run;
}
run.Emitted = true;
run.RunId = script.RunId;
run.Result = script.Result;
run.ResultSource = script.ResultSource;
run.ExitCode = script.ExitCode;
run.Stdout = script.Stdout;
run.Stderr = script.Stderr;
TrimLocked();
}
}
/// <summary>The tracker finished a poll pass that started at <paramref name="passStartedUtc"/>.</summary>
public void NotePassCompleted(DateTime passStartedUtc)
{
lock (_sync)
{
if (passStartedUtc > _lastCompletedPassStartUtc) _lastCompletedPassStartUtc = passStartedUtc;
}
}
/// <summary>
/// Hands out the run a save of (<paramref name="userId"/>, <paramref name="policyId"/>) seen at
/// <paramref name="savedSeenUtc"/> belongs to — once. A run whose user id the tracker could not
/// read is keyed without one.
/// </summary>
public PlatformRunClaim TryClaim(string userId, string policyId, DateTime savedSeenUtc, out PlatformScriptRun run)
{
run = null;
lock (_sync)
{
if (_lastCompletedPassStartUtc < savedSeenUtc + TrackerCatchUp) return PlatformRunClaim.Wait;
if (!_latest.TryGetValue(KeyOf(userId, policyId), out var latest)
&& !_latest.TryGetValue(KeyOf(null, policyId), out latest))
return PlatformRunClaim.None;
if (latest.Claimed) return PlatformRunClaim.None;
if (!latest.Emitted) return PlatformRunClaim.Wait;
latest.Claimed = true;
run = latest.Clone();
return PlatformRunClaim.Claimed;
}
}
public void CountVerified()
{
lock (_sync) { _verified++; _countersChanged = true; }
}
public void CountCorrected()
{
lock (_sync) { _corrected++; _countersChanged = true; }
}
/// <summary>Runs whose emitted output IME's saved result confirmed (restart-safe, session-cumulative).</summary>
public long Verified { get { lock (_sync) return _verified; } }
/// <summary>Runs whose emitted output IME's saved result corrected (restart-safe, session-cumulative).</summary>
public long Corrected { get { lock (_sync) return _corrected; } }
public void RestoreCounters(long verified, long corrected)
{
lock (_sync)
{
_verified = verified;
_corrected = corrected;
}
}
/// <summary>True once after a counter moved — the tracker then persists its state.</summary>
public bool ConsumeCountersChanged()
{
lock (_sync)
{
var changed = _countersChanged;
_countersChanged = false;
return changed;
}
}
private void TrimLocked()
{
while (_latest.Count > MaxRuns)
{
string oldestKey = null;
var oldest = DateTime.MaxValue;
foreach (var kv in _latest)
{
if (kv.Value.StartSeenUtc < oldest)
{
oldest = kv.Value.StartSeenUtc;
oldestKey = kv.Key;
}
}
if (oldestKey == null) return;
_latest.Remove(oldestKey);
}
}
}
}
@@ -15,6 +15,28 @@ public class ScriptExecutionState
/// <summary>"platform" or "remediation".</summary>
public string ScriptType { get; set; }
/// <summary>
/// IME user id a platform script ran for (the all-zero GUID in device context), read from its
/// script path or its result line. Keys IME's saved result
/// (<c>Policies\&lt;UserId&gt;\&lt;PolicyId&gt;</c>) that the registry reconciliation compares against.
/// </summary>
public string UserId { get; set; }
/// <summary>
/// Identity of one platform-script run, given when its slot opens and surfaced as <c>runId</c> on
/// the event: the invocation markers bind the run's executor lines to it, and a later
/// <c>script_output_reconciliation</c> names it when it corrects that run.
/// </summary>
public string RunId { get; set; }
/// <summary>
/// Source timestamp of the run's AgentExecutor start line (<c>Adding argument powershell with
/// value …</c>). Every executor process writes that line once, so a later one for the same
/// policy starts the next run, never this one. Null until the line is read, and on state files
/// from before the field.
/// </summary>
public System.DateTime? ExecutorStartedAtUtc { get; set; }
/// <summary>
/// UTC timestamp of the script's first observed start line, taken from the source CMTrace log
/// timestamp (so it dates correctly even when the agent replays historic IME log content that
@@ -366,6 +366,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
public void BackfillRecentHelloWizardStart(int lookbackMinutes)
=> _shellCoreTracker?.BackfillRecentHelloWizardStart(lookbackMinutes);
/// <summary>
/// First run only: the update page's records from before the agent started, as telemetry
/// and nothing else — see <see cref="ShellCoreTracker.BackfillUpdatePageTelemetry"/>.
/// </summary>
public void BackfillUpdatePageTelemetry(int lookbackMinutes)
=> _shellCoreTracker?.BackfillUpdatePageTelemetry(lookbackMinutes);
/// <summary>
/// sits-d Cloud-PC fix (2026-08-19) — re-check the user-apps-settled AccountSetup
/// synthesis after the ESP exit has already been observed. Wired to the IME tracker's
@@ -413,7 +420,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_tenantId,
_post,
_logger,
_helloTracker);
_helloTracker,
stateDirectory: _stateDirectory);
_shellCoreTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
_shellCoreTracker.WhiteGloveCompleted += OnWhiteGloveCompleted;
_shellCoreTracker.EspFailureDetected += OnShellCoreEspFailureDetected;
@@ -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),
};
}
}
@@ -18,6 +18,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
public const string PageStopped = "page_stopped";
public const string EventName = "event_name";
/// <summary>
/// Not a record: the <c>cxhEvent</c> of the marker <see cref="OobeUpdateTelemetry"/> sends
/// once per budget when the event names of a run start staying in agent.log.
/// </summary>
public const string NamesCapped = "names_capped";
public OobeUpdatePageRecord(string cxhEvent, int windowsEventId, string page = null, string result = null, string name = null, string value = null)
{
CxhEvent = cxhEvent;
@@ -53,7 +59,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// </summary>
internal sealed class OobeUpdateTelemetry
{
internal const int MaxPageEventsPerRun = 50;
internal const int MaxPageEventsPerRun = 150;
/// <summary>
/// Event names the backfills (first run, restart) report per run — their own budget, so
/// the records from before the agent started cannot spend the live page's.
/// </summary>
internal const int MaxBackfillPageEventsPerRun = 150;
internal const int MaxPageEventsPerKey = 3;
internal const int MaxStateEventsPerRun = 6;
internal const int MaxStateLineLength = 1000;
@@ -67,7 +80,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
public const string AgentStop = "agent_stop";
}
private const string Source = "ShellCoreTracker";
private const string Source = Constants.EventSources.ShellCoreTracker;
private readonly string _sessionId;
private readonly string _tenantId;
@@ -77,8 +90,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly object _lock = new object();
private readonly Dictionary<string, int> _pageEventsPerKey = new Dictionary<string, int>(StringComparer.Ordinal);
private int _pageEvents;
private bool _pageCapLogged;
private int _livePageEvents;
private int _backfillPageEvents;
private bool _liveCapReported;
private bool _backfillCapReported;
private int _stateEvents;
private string _lastStateContent;
@@ -98,31 +113,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// <summary>
/// One <c>oobe_update_page</c> per update-page record, at most
/// <see cref="MaxPageEventsPerKey"/> per page/name and <see cref="MaxPageEventsPerRun"/>
/// per run — a page that logs the same name in a loop cannot flood the session.
/// <see cref="MaxPageEventsPerKey"/> per page/name — a page that logs the same name in a
/// loop cannot flood the session. Event names also share a budget per run: live ones
/// <see cref="MaxPageEventsPerRun"/>, backfilled ones <see cref="MaxBackfillPageEventsPerRun"/>;
/// the first name over a budget sends the <see cref="OobeUpdatePageRecord.NamesCapped"/>
/// marker. A page start or stop is outside the budgets, because the time attribution reads
/// the update's span from them. True when the record was reported.
/// </summary>
public void ReportPage(OobeUpdatePageRecord record, DateTime occurredAtUtc, bool isBackfill)
public bool ReportPage(OobeUpdatePageRecord record, DateTime occurredAtUtc, bool isBackfill)
{
if (record == null) return;
if (record == null) return false;
var isEventName = record.CxhEvent == OobeUpdatePageRecord.EventName;
var key = $"{record.CxhEvent}:{record.Page ?? record.Name}";
int occurrence;
var budget = isBackfill ? MaxBackfillPageEventsPerRun : MaxPageEventsPerRun;
var occurrence = 0;
bool overBudget;
var firstOverBudget = false;
lock (_lock)
{
if (_pageEvents >= MaxPageEventsPerRun)
overBudget = isEventName && (isBackfill ? _backfillPageEvents : _livePageEvents) >= budget;
if (overBudget)
{
if (!_pageCapLogged)
{
_pageCapLogged = true;
_logger.Info($"OOBE update page telemetry: {MaxPageEventsPerRun} events reached, the rest of this run stays in agent.log");
}
return;
firstOverBudget = isBackfill ? !_backfillCapReported : !_liveCapReported;
if (isBackfill) _backfillCapReported = true;
else _liveCapReported = true;
}
_pageEventsPerKey.TryGetValue(key, out occurrence);
if (occurrence >= MaxPageEventsPerKey) return;
occurrence++;
_pageEventsPerKey[key] = occurrence;
_pageEvents++;
else
{
_pageEventsPerKey.TryGetValue(key, out occurrence);
if (occurrence >= MaxPageEventsPerKey) return false;
occurrence++;
_pageEventsPerKey[key] = occurrence;
if (isEventName)
{
if (isBackfill) _backfillPageEvents++;
else _livePageEvents++;
}
}
}
if (overBudget)
{
if (firstOverBudget) ReportNamesCapped(budget, occurredAtUtc, isBackfill);
return false;
}
var data = new Dictionary<string, object>
@@ -138,6 +172,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (record.Name != null) data["name"] = record.Name;
if (record.Value != null) data["value"] = record.Value;
return EmitPage(occurredAtUtc, DescribePage(record), data);
}
/// <summary>
/// The marker that a budget is spent: from here on this run's live or backfilled event
/// names stay in agent.log, so a name missing from the session proves nothing — the
/// analyze rules that read an absent name check for this marker first.
/// </summary>
private void ReportNamesCapped(int budget, DateTime occurredAtUtc, bool isBackfill)
{
var kind = isBackfill ? "backfilled " : string.Empty;
_logger.Info($"OOBE update page telemetry: {budget} {kind}event names reached, the rest of this run stays in agent.log");
EmitPage(occurredAtUtc,
$"OOBE update page: {budget} {kind}event names reached, later ones of this agent run are not sent",
new Dictionary<string, object>
{
{ "cxhEvent", OobeUpdatePageRecord.NamesCapped },
{ "eventTime", occurredAtUtc.ToString("o") },
{ "backfill", isBackfill },
{ "limit", budget },
});
}
private bool EmitPage(DateTime occurredAtUtc, string message, Dictionary<string, object> data)
{
try
{
_post.Emit(new EnrollmentEvent
@@ -149,14 +209,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
Severity = EventSeverity.Debug,
Source = Source,
Phase = EnrollmentPhase.Unknown,
Message = DescribePage(record),
Message = message,
Data = data,
ImmediateUpload = false,
});
return true;
}
catch (Exception ex)
{
_logger.Debug($"OOBE update page telemetry failed: {ex.Message}");
return false;
}
}
@@ -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,
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
@@ -50,6 +51,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// </summary>
internal const int BackfillLookbackMaxMinutes = 360;
/// <summary>
/// Lookback of the first run's update page backfill (<see cref="BackfillUpdatePageTelemetry"/>)
/// — the hour the servicing watcher reads back, so the page's records and the update's
/// servicing steps cover the same time.
/// </summary>
internal const int UpdatePageBackfillLookbackMinutes = 60;
private static readonly HashSet<int> TrackedShellCoreEventIds = new HashSet<int>
{
EventId_ShellCore_WebAppStarted,
@@ -62,17 +70,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
@"OOBE_ESP.*Exiting", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Manifest templates: 62404 "... started. CXID: '%1'.", 62405 "... stopped. Result: '%1'.",
// 62406 "... Event 1. Name: '%1'.", 62407 "... Event 2. Name: '%1', Value: '%2'.". A 62407
// value can carry URLs with tenant ids — a value leaves the tracker only in a form that
// cannot (SafeValuePattern: a boolean, an integer, an HRESULT).
// 62406 "... Event 1. Name: '%1'.", 62407 "... Event 2. Name: '%1', Value: '%2'.". The
// fields are read from the event PROPERTIES (%1/%2): the formatted message is localized
// ("Ergebnis: '…'", "Nom : « … »"), so these patterns are only the fallback for a record
// without properties and accept « » and a space before the colon. A 62407 value can carry
// URLs with tenant ids — a value leaves the tracker only in a form that cannot
// (SafeValuePattern: a boolean, an integer, an HRESULT).
private static readonly Regex CxidPattern = new Regex(
@"CXID:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
@"CXID\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
private static readonly Regex ResultPattern = new Regex(
@"Result:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
@"Result\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
private static readonly Regex NamePattern = new Regex(
@"Name:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
@"Name\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
private static readonly Regex ValuePattern = new Regex(
@"Value:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
@"Value\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
private static readonly Regex SafeValuePattern = new Regex(
@"^(?:true|false|-?[0-9]{1,10}|0x[0-9a-f]{1,8})$",
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
@@ -84,6 +95,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly InformationalEventPost _post;
private readonly HelloTracker _helloTracker;
private readonly OobeUpdateTelemetry _oobeUpdate;
// Shell-Core RecordIds of the update-page records already reported: the restart backfill
// must not report a record an earlier run already sent (D-310 field data: the same 20
// records came twice after a restart).
private readonly EventRecordWatermark _pageWatermark;
internal const string PageWatermarkStateFileName = "shellcore-page-watermark.json";
private EventLogWatcher _watcher;
private int _started;
@@ -91,7 +107,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private bool _espExitDetected;
private bool _whiteGloveDetected;
private bool _helloWizardStartDetected;
private string _lastCxhPage; // 62405 names no page, it ends the last one started
// 62405 names no page, it ends the last one started. The live watcher's page; a backfill
// moves it only with a newer record (see ObserveCxhRecord).
private string _lastCxhPage;
private long _lastCxhPageRecordId = -1;
private readonly object _stateLock = new object();
/// <summary>
@@ -131,7 +150,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
InformationalEventPost post,
AgentLogger logger,
HelloTracker helloTracker,
Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> oobeUpdateRegistryReader = null)
Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> oobeUpdateRegistryReader = null,
string stateDirectory = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
@@ -141,17 +161,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// Test seam: a fake registry reader; production reads the real registry.
_oobeUpdate = new OobeUpdateTelemetry(sessionId, tenantId, post, logger,
oobeUpdateRegistryReader ?? OobeUpdateRegistrySnapshot.Read);
_pageWatermark = new EventRecordWatermark(stateDirectory, PageWatermarkStateFileName, logger, "Shell-Core update page");
}
internal bool IsEspExitedForTest { get { lock (_stateLock) { return _espExitDetected; } } }
internal bool IsWhiteGloveDetectedForTest { get { lock (_stateLock) { return _whiteGloveDetected; } } }
/// <summary>Loads the update-page watermark as <see cref="Start"/> does, without arming the watcher.</summary>
internal void LoadPageWatermark() => _pageWatermark.Load();
public void Start()
{
// The OOBE update registry state at every agent start — so also right after the
// update's restart (D-310).
if (Interlocked.Exchange(ref _started, 1) == 0)
{
_oobeUpdate.ReportState(OobeUpdateTelemetry.Moments.AgentStart);
_pageWatermark.Load();
}
try
{
@@ -177,7 +204,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// WhiteGlove success — indistinguishable on the backend from a real no-signal
// enrollment. Surface it as one-shot telemetry.
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
collectorName: "ShellCoreTracker", reason: "watcher_arm_failed", ex: ex);
collectorName: Constants.EventSources.ShellCoreTracker, reason: "watcher_arm_failed", ex: ex);
}
}
@@ -185,7 +212,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
// Stop runs again from Dispose — one snapshot, and only for a tracker that started.
if (Volatile.Read(ref _started) == 1 && Interlocked.Exchange(ref _stopReported, 1) == 0)
{
_oobeUpdate.ReportState(OobeUpdateTelemetry.Moments.AgentStop);
}
if (_watcher == null) return;
try
@@ -216,7 +245,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
var description = record.FormatDescription() ?? $"Event ID {record.Id}";
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
ProcessEvent(record.Id, description, timestamp, record.ProviderName ?? "", isBackfill: false);
ProcessEvent(record.Id, description, timestamp, record.ProviderName ?? "", isBackfill: false,
recordId: record.RecordId ?? -1, properties: ReadProperties(record));
}
catch (Exception ex)
{
@@ -228,9 +258,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// Core event-processing logic. Exposed as internal so tests can drive it without
/// needing to synthesize an <see cref="EventRecord"/> (abstract + Windows-only).
/// </summary>
internal void ProcessEvent(int eventId, string description, DateTime timestamp, string providerName, bool isBackfill)
internal void ProcessEvent(
int eventId,
string description,
DateTime timestamp,
string providerName,
bool isBackfill,
long recordId = -1,
IReadOnlyList<string> properties = null)
{
ObserveCxhRecord(eventId, description, timestamp, isBackfill);
var fields = ReadCxhFields(eventId, description, properties);
ObserveCxhRecord(eventId, fields, timestamp, isBackfill, recordId, backfillPage: null);
if (eventId == EventId_ShellCore_WebAppStopped || eventId == EventId_ShellCore_WebAppEventName) return;
string eventType;
@@ -244,7 +282,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
switch (eventId)
{
case EventId_ShellCore_WebAppStarted: // 62404
if (description.Contains("AADHello") || description.Contains("'NGC'"))
if (IsHelloWizardStart(fields.Primary, description))
{
eventType = Constants.EventTypes.HelloWizardStarted;
message = "Windows Hello wizard started (CloudExperienceHost)";
@@ -431,30 +469,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
try
{
var lookbackMs = ClampLookbackMinutes(lookbackMinutes) * 60 * 1000;
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
$"*[System[{WatchedEventIdsXPath} and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
var records = new List<(int Id, string Description, DateTime OccurredAtUtc)>();
using (var reader = new EventLogReader(query))
{
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
var description = record.FormatDescription() ?? "";
// Preserve the historical event time across backfill so subscribers
// (EspAndHelloTrackerAdapter) can stamp signals with the source time
// rather than collapsing to wall-clock-now.
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
records.Add((record.Id, description, timestamp));
}
}
}
ReplayBackfillRecords(records);
ReplayBackfillRecords(ReadRecentRecords(lookbackMinutes));
}
catch (Exception ex)
{
@@ -462,6 +477,76 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
/// <summary>
/// The FIRST run's counterpart of the restart replay, for telemetry only: the update
/// page's records of the last <paramref name="lookbackMinutes"/> minutes (clamped like
/// the replay) become <c>oobe_update_page</c> rows. The agent is installed during the ESP
/// and can start after the update page began; without this its time attribution has no
/// page start to begin the update with (field: a first run 7 min after the update's
/// restart saw neither the page start nor the update's first half).
/// <para>
/// Nothing else is looked at: no Hello-wizard replay, no ESP exit or failure count, no
/// <c>agent_trace</c>. The first run keeps its decision behaviour exactly — see
/// <see cref="ReplayBackfillRecords"/> for why no replayed record may reach the engine and
/// <c>DefaultComponentFactory.ResolveEspExitBackfillLookbackMinutes</c> for why the first
/// run has no replay. No boot bound: an agent that first starts after the update's restart
/// is exactly the case to cover; the RecordId watermark keeps it from re-sending.
/// </para>
/// </summary>
public void BackfillUpdatePageTelemetry(int lookbackMinutes)
{
try
{
ReplayUpdatePageTelemetry(ReadRecentRecords(lookbackMinutes));
}
catch (Exception ex)
{
_logger.Warning($"Shell-Core update page backfill failed: {ex.Message}");
}
}
/// <summary>The update page telemetry of a chronological batch of Shell-Core records — nothing else.</summary>
internal void ReplayUpdatePageTelemetry(IReadOnlyList<ShellCoreRecord> records)
{
if (records == null || records.Count == 0) return;
var page = new BackfillPageCursor();
foreach (var record in records)
{
var fields = ReadCxhFields(record.Id, record.Description ?? string.Empty, record.Properties);
ObserveCxhRecord(record.Id, fields, record.OccurredAtUtc, isBackfill: true, recordId: record.RecordId, backfillPage: page);
}
}
/// <summary>The watched Shell-Core records of the last minutes, oldest first, with their original event time.</summary>
private List<ShellCoreRecord> ReadRecentRecords(int lookbackMinutes)
{
var lookbackMs = ClampLookbackMinutes(lookbackMinutes) * 60 * 1000;
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
$"*[System[{WatchedEventIdsXPath} and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
var records = new List<ShellCoreRecord>();
using (var reader = new EventLogReader(query))
{
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
var description = record.FormatDescription() ?? "";
// Preserve the historical event time across backfill so subscribers
// (EspAndHelloTrackerAdapter) can stamp signals with the source time
// rather than collapsing to wall-clock-now.
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
records.Add(new ShellCoreRecord(record.Id, description, timestamp,
record.RecordId ?? -1, ReadProperties(record)));
}
}
}
return records;
}
/// <summary>
/// Replays a chronological batch of Shell-Core records. <b>Only the Hello-wizard start
/// (62404) is replayed.</b> ESP exits and ESP failures (62407) are counted and reported,
@@ -502,6 +587,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// </summary>
internal void ReplayBackfillRecords(
IReadOnlyList<(int Id, string Description, DateTime OccurredAtUtc)> records)
{
if (records == null) return;
var converted = new List<ShellCoreRecord>(records.Count);
foreach (var r in records) converted.Add(new ShellCoreRecord(r.Id, r.Description, r.OccurredAtUtc));
ReplayBackfillRecords(converted);
}
internal void ReplayBackfillRecords(IReadOnlyList<ShellCoreRecord> records)
{
if (records == null || records.Count == 0) return;
@@ -509,15 +602,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
var skippedFailures = 0;
DateTime? oldestSkipped = null;
DateTime? newestSkipped = null;
var page = new BackfillPageCursor();
foreach (var record in records)
{
var description = record.Description ?? string.Empty;
ObserveCxhRecord(record.Id, description, record.OccurredAtUtc, isBackfill: true);
var fields = ReadCxhFields(record.Id, description, record.Properties);
ObserveCxhRecord(record.Id, fields, record.OccurredAtUtc, isBackfill: true, recordId: record.RecordId, backfillPage: page);
if (record.Id == EventId_ShellCore_WebAppStarted)
{
HandleBackfillRecord(record.Id, description, record.OccurredAtUtc);
HandleBackfillRecord(record.Id, description, record.OccurredAtUtc, fields.Primary);
continue;
}
@@ -556,7 +651,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
TenantId = _tenantId,
EventType = Constants.EventTypes.AgentTrace,
Severity = EventSeverity.Info,
Source = "ShellCoreTracker",
Source = Constants.EventSources.ShellCoreTracker,
Phase = EnrollmentPhase.Unknown,
Message =
$"Shell-Core replay skipped {skippedExits} ESP exit(s) and {skippedFailures} ESP failure(s) " +
@@ -586,7 +681,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// (<c>record.TimeCreated</c>); subscribers read it via
/// <see cref="LastEventOccurredAtUtc"/> during their synchronous event handler.
/// </summary>
internal void HandleBackfillRecord(int eventId, string description, DateTime occurredAtUtc)
internal void HandleBackfillRecord(int eventId, string description, DateTime occurredAtUtc, string cxid = null)
{
if (eventId == EventId_ShellCore_WebAppStarted)
{
@@ -594,7 +689,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// are unrelated. Fire-once so a replayed log tail cannot re-raise (downstream is
// idempotent anyway: HelloTracker once-guard, adapter dedup flag, engine
// set-once fact — this guard just keeps the noise down).
if (!description.Contains("AADHello") && !description.Contains("'NGC'")) return;
if (!IsHelloWizardStart(cxid ?? ReadCxhFields(eventId, description, null).Primary, description)) return;
bool shouldRaiseWizard;
lock (_stateLock)
@@ -624,32 +719,61 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// CloudExperienceHost navigation: breadcrumbs and OOBE update page telemetry (D-310)
// =====================================================================
/// <summary>The last page started within one backfill batch.</summary>
private sealed class BackfillPageCursor
{
public string Page;
}
/// <summary>
/// Writes one agent.log line per CloudExperienceHost page start/stop and per update-page
/// event name, so a diagnostics package shows the OOBE navigation. Records of the update
/// page itself (<c>OobeNDUP</c>, <c>RebootNDUP</c> and its <c>ExpeditedUpdate_*</c> names)
/// are also reported as <c>oobe_update_page</c>, and at the page's live start and stop the
/// registry state behind it as <c>oobe_update_state</c>. No decision.
/// are also reported as <c>oobe_update_page</c> — once across agent runs, by RecordId — and
/// at the page's live start and stop the registry state behind it as
/// <c>oobe_update_state</c>. No decision.
/// <para>
/// A stop (62405) belongs to the page started last. A backfill batch
/// (<paramref name="backfillPage"/>) reads records older than the live watcher's, which is
/// armed first, so it keeps its own last page; it moves the live watcher's only with a
/// record of a higher RecordId — a replayed older start must not take over the next live
/// stop, and a first run's live stop still finds the page that started before the agent.
/// </para>
/// </summary>
private void ObserveCxhRecord(int eventId, string description, DateTime occurredAtUtc, bool isBackfill)
private void ObserveCxhRecord(int eventId, CxhFields fields, DateTime occurredAtUtc, bool isBackfill, long recordId,
BackfillPageCursor backfillPage)
{
try
{
string page;
lock (_stateLock)
{
if (eventId == EventId_ShellCore_WebAppStarted)
_lastCxhPage = ExtractToken(CxidPattern, description) ?? _lastCxhPage;
page = _lastCxhPage;
if (eventId == EventId_ShellCore_WebAppStarted && fields.Primary != null)
{
if (backfillPage != null) backfillPage.Page = fields.Primary;
if (backfillPage == null || (recordId >= 0 && recordId > _lastCxhPageRecordId))
{
_lastCxhPage = fields.Primary;
if (recordId >= 0) _lastCxhPageRecordId = recordId;
}
}
page = backfillPage != null ? backfillPage.Page : _lastCxhPage;
}
var crumb = FormatCxhBreadcrumb(eventId, description, page);
var crumb = FormatCxhBreadcrumb(eventId, fields, page);
if (crumb == null) return;
_logger.Info(isBackfill ? $"{crumb} (backfill, at {occurredAtUtc:o})" : crumb);
var record = ParseUpdatePageRecord(eventId, description, page);
var record = ParseUpdatePageRecord(eventId, fields, page);
if (record == null) return;
_oobeUpdate.ReportPage(record, occurredAtUtc, isBackfill);
// A record an earlier run (or this one) already reported — the restart backfill
// re-reads the minutes before the restart.
if (!_pageWatermark.TryClaim(recordId)) return;
// Only a reported record moves the state file — at most the run budget plus the
// page boundaries; one over the budget stays claimed for this run only.
if (_oobeUpdate.ReportPage(record, occurredAtUtc, isBackfill))
_pageWatermark.MarkEmitted(recordId);
// The registry is read now — only a live page boundary is the page's moment.
if (!isBackfill && record.CxhEvent != OobeUpdatePageRecord.EventName)
@@ -667,32 +791,74 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
/// <summary>The own fields of a CloudExperienceHost record: CXID, result or name (%1) and the 62407 value (%2).</summary>
internal readonly struct CxhFields
{
public CxhFields(string primary, string value)
{
Primary = primary;
Value = value;
}
/// <summary>62404: CXID; 62405: result; 62406/62407: event name. Null when not readable.</summary>
public string Primary { get; }
/// <summary>62407: the raw value (never logged or sent unless it is of a safe form).</summary>
public string Value { get; }
}
/// <summary>
/// The record's fields from its event properties, which carry them verbatim in every
/// display language. Without properties (a test, a record that failed to render them) the
/// formatted message is parsed — English punctuation and « » only, so a localized label
/// ("Ergebnis", "Nom") yields null there.
/// </summary>
internal static CxhFields ReadCxhFields(int eventId, string description, IReadOnlyList<string> properties)
{
if (properties != null && properties.Count > 0)
{
return new CxhFields(
Bound(properties[0]),
eventId == EventId_ShellCore_WebAppEvent && properties.Count > 1 ? Bound(properties[1]) : null);
}
switch (eventId)
{
case EventId_ShellCore_WebAppStarted:
return new CxhFields(ExtractToken(CxidPattern, description), null);
case EventId_ShellCore_WebAppStopped:
return new CxhFields(ExtractToken(ResultPattern, description), null);
case EventId_ShellCore_WebAppEventName:
return new CxhFields(ExtractToken(NamePattern, description), null);
case EventId_ShellCore_WebAppEvent:
return new CxhFields(ExtractToken(NamePattern, description), ExtractToken(ValuePattern, description));
default:
return default;
}
}
/// <summary>
/// The breadcrumb for one Shell-Core record, or null when it is not one: a page start
/// (62404, its CXID), a page stop (62405, its result, attributed to the last started
/// page), or an update-page event name (62406/62407 — a 62407 value only in a safe form).
/// </summary>
internal static string FormatCxhBreadcrumb(int eventId, string description, string lastPage)
internal static string FormatCxhBreadcrumb(int eventId, string description, string lastPage) =>
FormatCxhBreadcrumb(eventId, ReadCxhFields(eventId, description, null), lastPage);
internal static string FormatCxhBreadcrumb(int eventId, CxhFields fields, string lastPage)
{
switch (eventId)
{
case EventId_ShellCore_WebAppStarted:
{
var cxid = ExtractToken(CxidPattern, description);
return cxid == null ? null : $"CXH page started: {cxid}";
}
return fields.Primary == null ? null : $"CXH page started: {fields.Primary}";
case EventId_ShellCore_WebAppStopped:
{
var result = ExtractToken(ResultPattern, description) ?? "?";
return $"CXH page stopped: {lastPage ?? "?"} (result={result})";
}
return $"CXH page stopped: {lastPage ?? "?"} (result={fields.Primary ?? "?"})";
case EventId_ShellCore_WebAppEventName:
case EventId_ShellCore_WebAppEvent:
{
var name = ExtractToken(NamePattern, description);
if (!IsUpdatePageToken(name)) return null;
var value = eventId == EventId_ShellCore_WebAppEvent ? ExtractSafeValue(description) : null;
return value == null ? $"CXH event: {name}" : $"CXH event: {name} (value={value})";
if (!IsUpdatePageToken(fields.Primary)) return null;
var value = eventId == EventId_ShellCore_WebAppEvent ? SafeValue(fields.Value) : null;
return value == null ? $"CXH event: {fields.Primary}" : $"CXH event: {fields.Primary} (value={value})";
}
default:
return null;
@@ -704,29 +870,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// the start of a page whose CXID names NDUP, the stop of such a page (62405, attributed
/// to the last started page), or an update-page event name from 62406/62407.
/// </summary>
internal static OobeUpdatePageRecord ParseUpdatePageRecord(int eventId, string description, string lastPage)
internal static OobeUpdatePageRecord ParseUpdatePageRecord(int eventId, string description, string lastPage) =>
ParseUpdatePageRecord(eventId, ReadCxhFields(eventId, description, null), lastPage);
internal static OobeUpdatePageRecord ParseUpdatePageRecord(int eventId, CxhFields fields, string lastPage)
{
switch (eventId)
{
case EventId_ShellCore_WebAppStarted:
{
var cxid = ExtractToken(CxidPattern, description);
return IsUpdatePageToken(cxid)
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, eventId, page: cxid)
return IsUpdatePageToken(fields.Primary)
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, eventId, page: fields.Primary)
: null;
}
case EventId_ShellCore_WebAppStopped:
return IsUpdatePageToken(lastPage)
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, eventId,
page: lastPage, result: ExtractToken(ResultPattern, description) ?? "?")
page: lastPage, result: fields.Primary ?? "?")
: null;
case EventId_ShellCore_WebAppEventName:
case EventId_ShellCore_WebAppEvent:
{
var name = ExtractToken(NamePattern, description);
if (!IsUpdatePageToken(name)) return null;
var value = eventId == EventId_ShellCore_WebAppEvent ? ExtractSafeValue(description) : null;
return new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, eventId, name: name, value: value);
if (!IsUpdatePageToken(fields.Primary)) return null;
var value = eventId == EventId_ShellCore_WebAppEvent ? SafeValue(fields.Value) : null;
return new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, eventId, name: fields.Primary, value: value);
}
default:
return null;
@@ -738,14 +903,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
&& (token.IndexOf("NDUP", StringComparison.OrdinalIgnoreCase) >= 0
|| token.IndexOf("ExpeditedUpdate", StringComparison.OrdinalIgnoreCase) >= 0);
/// <summary>
/// The Hello wizard's CloudExperienceHost start: CXID <c>AADHello</c> or <c>NGC</c>. With
/// no readable CXID the formatted message decides, as before (an English or German
/// message quotes the CXID as <c>'NGC'</c>).
/// </summary>
internal static bool IsHelloWizardStart(string cxid, string description) =>
cxid != null
? cxid.Contains("AADHello") || string.Equals(cxid, "NGC", StringComparison.Ordinal)
: description != null && (description.Contains("AADHello") || description.Contains("'NGC'"));
/// <summary>
/// The 62407 value when it is a boolean, an integer of up to ten digits or an HRESULT —
/// forms that cannot carry a URL or a tenant id (the CSP gate, error codes). Null for
/// anything else.
/// </summary>
internal static string ExtractSafeValue(string description)
internal static string ExtractSafeValue(string description) =>
SafeValue(ReadCxhFields(EventId_ShellCore_WebAppEvent, description, null).Value);
internal static string SafeValue(string value)
{
var value = ExtractToken(ValuePattern, description);
if (string.IsNullOrEmpty(value)) return null;
try
{
@@ -763,9 +940,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
try
{
var match = pattern.Match(description);
if (!match.Success) return null;
var token = match.Groups[1].Value;
return token.Length > MaxBreadcrumbTokenLength ? token.Substring(0, MaxBreadcrumbTokenLength) + "…" : token;
return match.Success ? Bound(match.Groups[1].Value) : null;
}
catch (RegexMatchTimeoutException)
{
@@ -773,6 +948,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
private static string Bound(string token)
{
if (string.IsNullOrEmpty(token)) return null;
return token.Length > MaxBreadcrumbTokenLength ? token.Substring(0, MaxBreadcrumbTokenLength) + "…" : token;
}
/// <summary>The event's property values as strings (the manifest's %1, %2, …); null when unreadable.</summary>
private static IReadOnlyList<string> ReadProperties(EventRecord record)
{
try
{
var properties = record.Properties;
if (properties == null || properties.Count == 0) return null;
var values = new string[properties.Count];
for (var i = 0; i < properties.Count; i++)
values[i] = Convert.ToString(properties[i]?.Value, CultureInfo.InvariantCulture);
return values;
}
catch (Exception)
{
return null;
}
}
// =====================================================================
// Pattern helpers
// =====================================================================
@@ -812,4 +1011,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
return "Unknown_ESP_Failure";
}
}
/// <summary>One Shell-Core record read by the restart backfill.</summary>
internal readonly struct ShellCoreRecord
{
public ShellCoreRecord(int id, string description, DateTime occurredAtUtc, long recordId = -1, IReadOnlyList<string> properties = null)
{
Id = id;
Description = description;
OccurredAtUtc = occurredAtUtc;
RecordId = recordId;
Properties = properties;
}
public int Id { get; }
public string Description { get; }
public DateTime OccurredAtUtc { get; }
/// <summary>The channel's RecordId; -1 when unknown (no cross-run dedup).</summary>
public long RecordId { get; }
/// <summary>The event's property values (%1, %2, …); null when unknown.</summary>
public IReadOnlyList<string> Properties { get; }
}
}
@@ -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:
@@ -142,6 +142,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// replayed 62407 cannot be placed in time; see
/// <c>ShellCoreTracker.ReplayBackfillRecords</c>.
/// </para>
/// <para>
/// The first run reads the last hour for the OOBE update page telemetry instead — no
/// replay, nothing that reaches the decision engine; see
/// <c>ShellCoreTracker.BackfillUpdatePageTelemetry</c>.
/// </para>
/// </summary>
public void Start()
{
@@ -149,6 +154,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
if (_espExitBackfillLookbackMinutes > 0)
_tracker.BackfillRecentHelloWizardStart(_espExitBackfillLookbackMinutes);
else
_tracker.BackfillUpdatePageTelemetry(ShellCoreTracker.UpdatePageBackfillLookbackMinutes);
}
public void Stop() => _tracker.Stop();
@@ -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))
@@ -455,6 +455,25 @@ public class AuthFunctionTests
Assert.Equal(Constants.TenantRoles.Operator, (string)body.role);
}
[Fact]
public void AutoAdmin_NotTriggered_ForClaimDerivedViewer_WhenNoTableMembers()
{
// Same guard for the read-only claim role: an Entra app-role Viewer in a claim-only tenant
// stays a Viewer and is never written into the table as Admin.
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: ViewerRole(), // came from the "roles" claim
mcpCheck: McpDenied(),
hasTenantAdmins: false,
TenantId, Upn, DisplayName, ObjectId);
Assert.True(result.IsSuccess);
Assert.False(result.NeedsAutoAdmin);
var body = ToDynamic(result.Body);
Assert.False((bool)body.isTenantAdmin);
Assert.Equal(Constants.TenantRoles.Viewer, (string)body.role);
}
[Fact]
public void AutoAdmin_NotTriggered_WhenUserAlreadyAdmin()
{
@@ -25,7 +25,37 @@ public class EntraAppRoleResolverTests
[Fact]
public void MapClaimRole_UnknownRole_ReturnsNull()
{
Assert.Null(EntraAppRoleResolver.MapClaimRole(new[] { "Viewer", "SomethingElse" }));
// Neither a foreign value nor an application permission ever becomes a member role.
Assert.Null(EntraAppRoleResolver.MapClaimRole(new[] { "SomethingElse", "access_as_application", "GlobalAdmin" }));
}
[Fact]
public void MapClaimRole_Viewer_IsReadOnlyWithoutBootstrapPermission()
{
var role = EntraAppRoleResolver.MapClaimRole(new[] { "SomethingElse", Constants.TenantRoles.Viewer });
Assert.NotNull(role);
Assert.Equal(Constants.TenantRoles.Viewer, role!.Role);
Assert.False(role.CanManageBootstrapTokens);
}
[Fact]
public void MapClaimRole_OperatorOutranksViewer_WhenBothPresent()
{
var role = EntraAppRoleResolver.MapClaimRole(
new[] { Constants.TenantRoles.Viewer, Constants.TenantRoles.Operator });
Assert.Equal(Constants.TenantRoles.Operator, role!.Role);
}
[Fact]
public void MapClaimRole_AdminOutranksViewer_WhenBothPresent()
{
var role = EntraAppRoleResolver.MapClaimRole(
new[] { Constants.TenantRoles.Viewer, Constants.TenantRoles.Admin });
Assert.Equal(Constants.TenantRoles.Admin, role!.Role);
Assert.True(role.CanManageBootstrapTokens);
}
[Fact]
@@ -63,6 +93,7 @@ public class EntraAppRoleResolverTests
var role = EntraAppRoleResolver.MapClaimRole(new[] { "admin" });
Assert.Equal(Constants.TenantRoles.Admin, role!.Role);
Assert.Equal(Constants.TenantRoles.Viewer, EntraAppRoleResolver.MapClaimRole(new[] { " viewer " })!.Role);
}
// -------------------------------------------------------------------------
@@ -106,14 +137,43 @@ public class EntraAppRoleResolverTests
}
[Fact]
public void Resolve_NoTableRow_IgnoresClaim_WhenTenantFlagDisabled()
public void Resolve_NoTableRow_FallsBackToViewerClaim_WhenEnabled()
{
var result = EntraAppRoleResolver.Resolve(
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: false);
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Viewer }, appRolesEnabled: true);
Assert.Equal(Constants.TenantRoles.Viewer, result!.Role);
}
[Fact]
public void Resolve_DisabledTableRow_IgnoresViewerClaim()
{
var result = EntraAppRoleResolver.Resolve(
TableMemberState.Disabled, null, new[] { Constants.TenantRoles.Viewer }, appRolesEnabled: true);
Assert.Null(result);
}
[Fact]
public void Resolve_EnabledTableViewer_WinsOverHigherClaim()
{
var tableRole = new MemberRoleInfo { Role = Constants.TenantRoles.Viewer };
var result = EntraAppRoleResolver.Resolve(
TableMemberState.Enabled, tableRole, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: true);
Assert.Equal(Constants.TenantRoles.Viewer, result!.Role);
}
[Fact]
public void Resolve_NoTableRow_IgnoresClaim_WhenTenantFlagDisabled()
{
Assert.Null(EntraAppRoleResolver.Resolve(
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: false));
Assert.Null(EntraAppRoleResolver.Resolve(
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Viewer }, appRolesEnabled: false));
}
[Fact]
public void Resolve_NoTableRow_NoClaim_ReturnsNull()
{
@@ -0,0 +1,481 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The OOBE quality update page (oobe_update_page, D-310) — pins the built-in analyze rules for
/// the update that did not happen:
/// - ANALYZE-DEV-013: the page reported a failed download or install and no install success
/// (field shapes e7551b82, cb1992b8). Only the explicit failure names count: an update that
/// installed logs downloadInstallFailureHelper and a start timeout too (e4ecd6f8).
/// - ANALYZE-DEV-014: nothing offered to an out-of-date device whose page counts an earlier
/// failure, without a failure in this session (b2d9f12c).
/// - ANALYZE-DEV-015: the scan started and the page ended without a scan result (c5b20dfa).
/// Every absence condition is vetoed by the agent's names_capped marker. Sequences follow the
/// field sessions of 2026-10-03..05 (synthetic times, no customer data); a session never fires
/// two of the three.
/// </summary>
public class OobeUpdateRuleTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "11111111-2222-3333-4444-555555555555";
private static readonly DateTime T0 = new(2026, 10, 3, 16, 56, 59, DateTimeKind.Utc);
private const string DownloadFailed = "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError";
private const string InstallFailed = "SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError";
private const string CommitFailed = "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure";
private static readonly string[] RuleIds = { "ANALYZE-DEV-013", "ANALYZE-DEV-014", "ANALYZE-DEV-015" };
// ── the rules ───────────────────────────────────────────────────────────
[Theory]
[InlineData("ANALYZE-DEV-013")]
[InlineData("ANALYZE-DEV-014")]
[InlineData("ANALYZE-DEV-015")]
public void Rules_are_enabled_warnings_that_never_fail_the_session(string ruleId)
{
var rule = Rule(ruleId);
Assert.True(rule.Enabled);
Assert.Equal("warning", rule.Severity);
Assert.Equal("device", rule.Category);
Assert.False(rule.MarkSessionAsFailedDefault); // the enrollment continues without the update
}
// ── ANALYZE-DEV-013: failed ─────────────────────────────────────────────
[Fact]
public async Task DEV_013_fires_on_a_failed_download_and_names_the_failure()
{
var outcome = await RunAsync(FailedDownload());
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-DEV-013", result.RuleId);
Assert.Equal(DownloadFailed, Value(result, "update_failure")); // {{update_failure}}
Assert.Equal("fail", Value(result, "update_page_failed"));
Assert.Equal(85, result.ConfidenceScore);
}
[Fact]
public async Task DEV_013_fires_when_the_update_is_skipped_after_the_failure()
{
var outcome = await RunAsync(FailureThenSkip());
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-DEV-013", result.RuleId);
Assert.Equal(DownloadFailed, Value(result, "update_failure"));
Assert.Equal("CloudNDUPSkipDownloadInstallButtonClicked", Value(result, "update_skipped"));
}
[Theory]
[InlineData(InstallFailed)]
[InlineData(CommitFailed)]
public async Task DEV_013_fires_on_every_explicit_failure_name(string failureName)
{
var events = Visit(days: 120, previousFailures: 0);
events.AddRange(new[] { Name("ExpeditedUpdate_getUpdateResultsSucceeded"), Name(failureName), PageStop("OobeNDUP", "fail") });
var result = Assert.Single((await RunAsync(events)).Results);
Assert.Equal(failureName, Value(result, "update_failure"));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task DEV_013_stays_silent_on_an_update_that_installed_despite_the_failure_helper(bool buildChangeObserved)
{
// Field shape e4ecd6f8: the failure helper and a start timeout after "restart required",
// then the update installed — with or without the build change on record.
var events = InstalledDespiteFailureHelper();
if (!buildChangeObserved) events.RemoveAll(e => e.EventType == "os_build_changed");
Assert.Empty((await RunAsync(events)).Results);
}
[Fact]
public async Task DEV_013_stays_silent_when_a_retry_installed_the_update()
{
var events = FailedDownload();
events.AddRange(new[]
{
PageStart("OobeNDUP"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired_lcu"),
});
Assert.Empty((await RunAsync(events)).Results);
}
[Fact]
public async Task DEV_013_is_vetoed_by_an_os_build_change()
{
var events = FailedDownload();
events.Add(OsBuildChanged());
Assert.Empty((await RunAsync(events)).Results);
}
// ── ANALYZE-DEV-014: not offered after an earlier failure ───────────────
[Fact]
public async Task DEV_014_fires_when_nothing_is_offered_after_an_earlier_failure()
{
var outcome = await RunAsync(NothingOfferedAfterFailure());
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-DEV-014", result.RuleId);
Assert.Equal("137", Value(result, "days_out_of_date")); // {{days_out_of_date}}
Assert.Equal("1", Value(result, "previous_failures")); // {{previous_failures}}
}
[Theory]
[InlineData(0, 1)] // the device is current: nothing to offer
[InlineData(137, 0)] // no earlier failure on record
public async Task DEV_014_stays_silent_without_both_the_backlog_and_the_earlier_failure(int days, int previousFailures)
{
var events = Visit(days, previousFailures);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
PageStop("OobeNDUP", "cancel"),
});
Assert.Empty((await RunAsync(events)).Results);
}
// ── ANALYZE-DEV-015: scan without a result ──────────────────────────────
[Fact]
public async Task DEV_015_fires_when_the_page_ends_without_a_scan_result()
{
var outcome = await RunAsync(ScanWithoutResult());
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-DEV-015", result.RuleId);
Assert.Equal("438", Value(result, "days_out_of_date"));
Assert.Equal("page_stopped", Value(result, "update_page_ended"));
}
[Fact]
public async Task DEV_015_stays_silent_while_the_page_still_runs()
{
// The session ended during the scan: no page end, so no claim that the page gave up.
var events = ScanWithoutResult();
events.RemoveAll(e => e.EventType == "oobe_update_page" && (string)e.Data["cxhEvent"] == "page_stopped");
Assert.Empty((await RunAsync(events)).Results);
}
[Fact]
public async Task DEV_015_stays_silent_when_the_agent_missed_the_scan_start()
{
// A late agent sees the page end but not the scan: the missing result proves nothing.
var events = ScanWithoutResult();
events.RemoveAll(e => e.EventType == "oobe_update_page" && (e.Data.TryGetValue("name", out var n) ? (string)n : "").StartsWith("ExpeditedUpdate_startWUScan"));
Assert.Empty((await RunAsync(events)).Results);
}
// ── shared vetoes and the sessions that fire nothing ────────────────────
[Theory]
[MemberData(nameof(FiringShapes))]
public async Task The_names_capped_marker_vetoes_every_rule(string shape)
{
// Once the agent capped the page's names, a missing success or scan result proves nothing.
var events = Shapes[shape]();
events.Add(NamesCapped());
Assert.Empty((await RunAsync(events)).Results);
}
[Fact]
public async Task A_page_the_policy_switched_off_fires_nothing()
{
var events = new List<EnrollmentEvent>
{
PageStart("OobeNDUP"),
Name("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "false"),
PageStop("OobeNDUP", "cancel"),
PageStart("RebootNDUP"),
PageStop("RebootNDUP", "success"),
};
Assert.Empty((await RunAsync(events)).Results);
}
[Fact]
public async Task An_installed_update_fires_nothing()
{
Assert.Empty((await RunAsync(Installed())).Results);
}
[Theory]
[MemberData(nameof(AllShapes))]
public async Task No_session_fires_more_than_one_of_the_three(string shape)
{
var outcome = await RunAsync(Shapes[shape]());
Assert.True(outcome.Results.Count <= 1, $"{shape}: {string.Join(", ", outcome.Results.Select(r => r.RuleId))}");
}
// ── session shapes (field sessions, synthetic times) ────────────────────
private static readonly Dictionary<string, Func<List<EnrollmentEvent>>> Shapes = new()
{
["failed_download"] = FailedDownload,
["failure_then_skip"] = FailureThenSkip,
["installed_despite_failure_helper"] = InstalledDespiteFailureHelper,
["nothing_offered_after_failure"] = NothingOfferedAfterFailure,
["scan_without_result"] = ScanWithoutResult,
["installed"] = Installed,
};
public static IEnumerable<object[]> AllShapes() => Shapes.Keys.Select(k => new object[] { k });
public static IEnumerable<object[]> FiringShapes() =>
new[] { "failed_download", "failure_then_skip", "nothing_offered_after_failure", "scan_without_result" }
.Select(k => new object[] { k });
/// <summary>The names every visit logs before its scan.</summary>
private static List<EnrollmentEvent> Visit(int days, int previousFailures) => new()
{
PageStart("OobeNDUP"),
Name("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true"),
Name("ExpeditedUpdate_getDaysOutOfDateSucceeded", days.ToString()),
Name("SdxWebAppCloudNDUP_initialize_NDUPInstallCanceledInOptOut", "false"),
Name("SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", previousFailures.ToString()),
Name("ExpeditedUpdate_startWUScanStarted"),
Name("ExpeditedUpdate_startWUScanStartedSuccedded"),
};
/// <summary>e7551b82: only the servicing stack staged, the download failed after 25 min; the second visit found nothing to offer.</summary>
private static List<EnrollmentEvent> FailedDownload()
{
var events = Visit(days: 136, previousFailures: 0);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsStarted"),
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI"), // a list: no safe value
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name(DownloadFailed),
Name(CommitFailed),
Name("SdxWebAppCloudNDUP_downloadInstallFailureHelper"),
PageStop("OobeNDUP", "fail"),
PageStart("RebootNDUP"),
});
events.AddRange(Visit(days: 137, previousFailures: 1));
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
PageStop("OobeNDUP", "cancel"),
PageStart("RebootNDUP"),
PageStop("RebootNDUP", "success"),
});
return events;
}
/// <summary>cb1992b8: visit 1 failed; visit 2 downloaded again and someone selected Skip before the install.</summary>
private static List<EnrollmentEvent> FailureThenSkip()
{
var events = Visit(days: 438, previousFailures: 0);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name(DownloadFailed),
Name(CommitFailed),
Name(InstallFailed),
PageStop("OobeNDUP", "fail"),
PageStart("RebootNDUP"),
});
events.AddRange(Visit(days: 438, previousFailures: 1));
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name("CloudNDUPOptoutLinkClicked", "0"),
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
Name("SdxWebAppCloudNDUP_updateUSOProgressBar_DownloadPhase_progress100"),
Name("SdxWebAppCloudNDUP_manageProgressBars_waitingForInstallStartError"),
Name("CloudNDUPSkipDownloadInstallButtonClicked", "0"),
PageStop("OobeNDUP", "cancel"),
PageStart("RebootNDUP"),
PageStop("RebootNDUP", "success"),
});
return events;
}
/// <summary>e4ecd6f8: the failure helper and a start timeout after "restart required" — the update installed after three restarts.</summary>
private static List<EnrollmentEvent> InstalledDespiteFailureHelper()
{
var events = Visit(days: 99, previousFailures: 0);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name("SdxWebAppCloudNDUP_updateUSOProgressBar_installPhase_progress100"),
Name("SdxWebAppCloudNDUP_downloadInstallFailureHelper"),
Name("SdxWebAppCloudNDUP_startEventArrivalTimeout_TimerExpired"),
PageStart("RebootNDUP"),
OsBuildChanged(),
PageStart("OobeNDUP"),
PageStop("OobeNDUP", "success"),
});
return events;
}
/// <summary>b2d9f12c: the same device the next day — out of date, an earlier failure on record, nothing offered.</summary>
private static List<EnrollmentEvent> NothingOfferedAfterFailure()
{
var events = Visit(days: 137, previousFailures: 1);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsStarted"),
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
Name("SdxWebAppCloudNDUP_exit_appResult"),
PageStop("OobeNDUP", "cancel"),
PageStart("RebootNDUP"),
PageStop("RebootNDUP", "success"),
});
return events;
}
/// <summary>c5b20dfa: 438 days behind; the page ended two minutes after the scan started, without a result.</summary>
private static List<EnrollmentEvent> ScanWithoutResult()
{
var events = Visit(days: 438, previousFailures: 0);
events.AddRange(new[]
{
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededNoReboot"), // the language check, every visit
Name("SdxWebAppCloudNDUP_exit_appResult"),
PageStop("OobeNDUP", "cancel"),
PageStart("RebootNDUP"),
PageStop("RebootNDUP", "success"),
});
return events;
}
/// <summary>6a7d241d: downloaded, installed, restarted.</summary>
private static List<EnrollmentEvent> Installed()
{
var events = Visit(days: 120, previousFailures: 0);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
Name("SdxWebAppCloudNDUP_rebootCountdown_starting"),
PageStart("RebootNDUP"),
OsBuildChanged(),
});
return events;
}
// ── event builders — mirror the agent's OobeUpdateTelemetry payload ─────
private static long _seq;
private static EnrollmentEvent Evt(string eventType, Dictionary<string, object> data)
{
var seq = Interlocked.Increment(ref _seq);
return new EnrollmentEvent
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = eventType,
Timestamp = T0.AddSeconds(seq),
Sequence = seq,
Data = data,
};
}
private static Dictionary<string, object> PageData(string cxhEvent, int windowsEventId) => new()
{
["cxhEvent"] = cxhEvent,
["windowsEventId"] = windowsEventId,
["backfill"] = false,
["occurrence"] = 1,
};
private static EnrollmentEvent PageStart(string page)
{
var data = PageData("page_started", 62404);
data["page"] = page;
return Evt("oobe_update_page", data);
}
private static EnrollmentEvent PageStop(string page, string result)
{
var data = PageData("page_stopped", 62405);
data["page"] = page;
data["result"] = result;
return Evt("oobe_update_page", data);
}
private static EnrollmentEvent Name(string name, string? value = null)
{
var data = PageData("event_name", value == null ? 62406 : 62407);
data["name"] = name;
if (value != null) data["value"] = value;
return Evt("oobe_update_page", data);
}
private static EnrollmentEvent NamesCapped()
{
var data = PageData("names_capped", 0);
data.Remove("windowsEventId");
data.Remove("occurrence");
data["limit"] = 150;
return Evt("oobe_update_page", data);
}
private static EnrollmentEvent OsBuildChanged() => Evt("os_build_changed", new Dictionary<string, object>
{
["previousBuild"] = "26200.8037",
["currentBuild"] = "26200.8655",
});
// ── harness (as WindowsUpdateRuleTests) ─────────────────────────────────
private static AnalyzeRule Rule(string ruleId) => BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == ruleId);
private static string Value(RuleResult result, string signal)
{
var evidence = Assert.IsType<Dictionary<string, object>>(result.MatchedConditions[signal]);
return evidence["value"]?.ToString() ?? string.Empty;
}
private static async Task<AnalysisOutcome> RunAsync(List<EnrollmentEvent> events)
{
var rules = RuleIds.Select(Rule).ToList();
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(rules);
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<OobeUpdateRuleTests>.Instance);
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
}
}
@@ -0,0 +1,45 @@
using System.Globalization;
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The versioned platform-stats file is served immutable for a year, so every publish must write
/// a new name. Maintenance publishes every 2 hours; the per-day name it used to write was rewritten
/// up to 12 times a day, and caches kept the first copy of the day.
/// </summary>
public class PlatformStatsPublishTests
{
private static readonly DateTime Publish = new(2026, 10, 3, 6, 0, 4, DateTimeKind.Utc);
[Fact]
public void Versioned_file_name_carries_the_publish_instant_to_the_second()
{
Assert.Equal("platform-stats.2026-10-03T060004Z.json", MaintenanceService.PlatformStatsVersionedFileName(Publish));
}
[Fact]
public void Two_publishes_of_one_day_get_different_names()
{
var early = MaintenanceService.PlatformStatsVersionedFileName(new DateTime(2026, 10, 3, 2, 0, 3, DateTimeKind.Utc));
var later = MaintenanceService.PlatformStatsVersionedFileName(new DateTime(2026, 10, 3, 4, 0, 3, DateTimeKind.Utc));
Assert.NotEqual(early, later);
}
[Fact]
public void Versioned_file_name_ignores_the_current_culture()
{
var previous = CultureInfo.CurrentCulture;
try
{
// th-TH counts years in the Buddhist era (2569 for 2026).
CultureInfo.CurrentCulture = new CultureInfo("th-TH");
Assert.Equal("platform-stats.2026-10-03T060004Z.json", MaintenanceService.PlatformStatsVersionedFileName(Publish));
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
}
@@ -0,0 +1,142 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Progress;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The Progress Portal's tenant status: when an organization reads as unused (no session, signed up
/// longer than <see cref="ProgressTenantStatusFunction.UnusedAfter"/> ago), and that a failed read never
/// does.
/// </summary>
public class ProgressTenantStatusFunctionTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private static readonly DateTime Now = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc);
private static TenantConfiguration Config(DateTime? onboardedAt)
=> new() { TenantId = TenantId, OnboardedAt = onboardedAt };
// ── Policy catalog ───────────────────────────────────────────────────────
[Fact]
public void Route_is_AuthenticatedUser_scoped_to_the_JWT_tenant()
{
// Members without a role are the audience, so the tier must not require membership; the route
// takes no tenant parameter, so no caller can ask about another organization.
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "progress/tenant-status");
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.AuthenticatedUser, entry!.Policy);
Assert.Equal(TenantScoping.Jwt, entry.TenantScoping);
}
// ── Evaluate ─────────────────────────────────────────────────────────────
[Fact]
public void No_session_and_signed_up_a_month_ago_is_unused()
{
var signedUp = Now.AddDays(-30);
var status = ProgressTenantStatusFunction.Evaluate(Config(signedUp), hasAnySession: false, Now);
Assert.True(status.Success);
Assert.True(status.Unused);
Assert.Equal(signedUp, status.SignedUpAt);
}
[Fact]
public void Any_session_means_in_use()
{
var status = ProgressTenantStatusFunction.Evaluate(Config(Now.AddDays(-300)), hasAnySession: true, Now);
Assert.False(status.Unused);
}
[Fact]
public void An_organization_still_being_set_up_is_not_unused()
{
var status = ProgressTenantStatusFunction.Evaluate(Config(Now.AddDays(-13)), hasAnySession: false, Now);
Assert.False(status.Unused);
}
[Fact]
public void The_window_closes_after_exactly_fourteen_days()
{
var status = ProgressTenantStatusFunction.Evaluate(
Config(Now - ProgressTenantStatusFunction.UnusedAfter), hasAnySession: false, Now);
Assert.True(status.Unused);
}
[Fact]
public void A_row_without_a_signup_date_is_old_and_reports_no_date()
{
var status = ProgressTenantStatusFunction.Evaluate(Config(onboardedAt: null), hasAnySession: false, Now);
Assert.True(status.Unused);
Assert.Null(status.SignedUpAt);
}
[Fact]
public void Without_a_configuration_row_nothing_is_unused()
{
var status = ProgressTenantStatusFunction.Evaluate(config: null, hasAnySession: false, Now);
Assert.True(status.Success);
Assert.False(status.Unused);
Assert.Null(status.SignedUpAt);
}
// ── HTTP ─────────────────────────────────────────────────────────────────
private static (ProgressTenantStatusFunction Sut, Mock<ISessionRepository> Sessions) Build(TenantConfiguration? row)
{
var configService = new Mock<TenantConfigurationService>(
Mock.Of<IConfigRepository>(), Mock.Of<ILogger<TenantConfigurationService>>(), Mock.Of<IMemoryCache>())
{ CallBase = false };
configService.Setup(s => s.GetConfigurationIfExistsAsync(TenantId)).ReturnsAsync(row);
var sessions = new Mock<ISessionRepository>(MockBehavior.Strict);
var sut = new ProgressTenantStatusFunction(
Mock.Of<ILogger<ProgressTenantStatusFunction>>(), configService.Object, sessions.Object);
return (sut, sessions);
}
[Fact]
public async Task Answers_for_the_callers_own_tenant()
{
var (sut, sessions) = Build(Config(DateTime.UtcNow.AddDays(-60)));
sessions.Setup(s => s.HasAnySessionStrictAsync(TenantId)).ReturnsAsync(false);
var (req, _) = EndpointHarness.Request(TenantId);
var response = await sut.GetTenantStatus(req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
response.Body.Position = 0;
using var doc = JsonDocument.Parse(response.Body);
Assert.True(doc.RootElement.GetProperty("unused").GetBoolean());
}
[Fact]
public async Task A_failed_session_read_is_an_error_never_unused()
{
var (sut, sessions) = Build(Config(DateTime.UtcNow.AddDays(-60)));
sessions.Setup(s => s.HasAnySessionStrictAsync(TenantId))
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
var (req, _) = EndpointHarness.Request(TenantId);
var response = await sut.GetTenantStatus(req);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
}
@@ -137,4 +137,27 @@ public class ProgressWireParityTests
Events = events
});
}
// GET /api/progress/tenant-status — typed from the start; the anonymous side is the literal the
// portal reads.
[Fact]
public void ProgressTenantStatus_unused_with_signup_date()
{
var signedUpAt = new DateTime(2026, 3, 12, 8, 30, 0, DateTimeKind.Utc);
ApiResponseWireParityTests.AssertWireIdentical(
new { success = true, unused = true, signedUpAt = signedUpAt },
new ProgressTenantStatusResponse { Success = true, Unused = true, SignedUpAt = signedUpAt });
}
// An unknown signup date drops the key on both sides (WhenWritingNull).
[Fact]
public void ProgressTenantStatus_without_signup_date_omits_the_key()
{
DateTime? signedUpAt = null;
ApiResponseWireParityTests.AssertWireIdentical(
new { success = true, unused = false, signedUpAt = signedUpAt },
new ProgressTenantStatusResponse { Success = true, Unused = false, SignedUpAt = signedUpAt });
}
}
@@ -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,88 @@
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")]
[InlineData("ShellCoreTracker")] // the update page, read back an hour on an agent's first run
public void Backfill_sources_are_not_anchor_eligible(string source)
{
Assert.False(Constants.EventSources.IsSessionAnchorEligible(source));
}
[Theory]
[InlineData("ImeLogTracker")]
[InlineData("Agent")]
[InlineData("MdmRebootPolicyWatcher")]
[InlineData("EspAndHelloTracker")] // the ESP and Hello signals from the same Shell-Core log
[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,10 @@ 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,
// How an OOBE quality update ended (OsUpdateSpan.Outcome in the time attribution).
["osUpdateOutcomes"] = OsUpdateOutcomes.All,
// Community rule submissions: kinds, effective statuses (incl. the derived "published"),
// attribution modes and reviewer decisions — the MCP tool schemas enumerate these.
["ruleSubmissionKinds"] = RuleSubmissionKinds.All,
@@ -87,4 +87,15 @@ public class TenantMemberRoleResolverTests
Assert.Equal(Constants.TenantRoles.Operator, role?.Role);
}
[Fact]
public async Task NoRow_ViewerClaim_IsAReadOnlyMember_WhenTheTenantOptedIn()
{
AppRolesEnabled();
var role = await _sut.ResolveAsync(Tenant, Upn, new[] { Constants.TenantRoles.Viewer });
Assert.Equal(Constants.TenantRoles.Viewer, role?.Role);
Assert.False(role!.CanManageBootstrapTokens);
}
}
@@ -0,0 +1,841 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Golden fixtures for the OOBE quality update in the time attribution (AttributionVersion 4,
/// D-314; v5): <c>os_update</c> from the update page start whose visit holds update evidence — or,
/// when the agent missed the start, from the update's first evidence — to the last update evidence
/// before the user is back, then — after a restart — <c>awaiting_sign_in</c> until the user is back;
/// the latest outcome evidence names the outcome. Sequences follow the field sessions of
/// 2026-10-02..05 (synthetic times, no customer data). EVERY fixture asserts the exact-partition
/// invariant.
/// </summary>
public class TimeAttributionOsUpdateTests
{
private static readonly DateTime T0 = new(2026, 10, 2, 14, 0, 0, DateTimeKind.Utc);
private const string Lcu = "Package_for_RollupFix~31bf3856ad364e35~amd64~~26100.6899.1.7";
private const string LcuKb = "Package_for_KB5129195~31bf3856ad364e35~amd64~~26100.6899.1.7";
private static long _seq;
private static EnrollmentEvent Evt(
DateTime ts, string eventType,
EnrollmentPhase phase = EnrollmentPhase.Unknown,
Dictionary<string, object>? data = null)
=> new()
{
EventType = eventType,
Timestamp = ts,
Sequence = ++_seq,
Phase = phase,
Data = data ?? new Dictionary<string, object>(),
};
private static EnrollmentEvent PageStart(DateTime ts, string page) =>
Evt(ts, "oobe_update_page", data: new() { ["cxhEvent"] = "page_started", ["page"] = page });
private static EnrollmentEvent PageStop(DateTime ts, string page, string result) =>
Evt(ts, "oobe_update_page", data: new() { ["cxhEvent"] = "page_stopped", ["page"] = page, ["result"] = result });
private static EnrollmentEvent PageName(DateTime ts, string name, string? value = null)
{
var data = new Dictionary<string, object> { ["cxhEvent"] = "event_name", ["name"] = name };
if (value != null) data["value"] = value;
return Evt(ts, "oobe_update_page", data: data);
}
private static EnrollmentEvent Servicing(DateTime ts, string step, string package = LcuKb, string targetState = "Installed") =>
Evt(ts, "windows_update_servicing", data: new()
{
["step"] = step,
["package"] = package,
["client"] = "UpdateAgentLCU",
["targetState"] = targetState,
});
/// <summary>A restart: the agent's next run detects it; the gap runs from the last event before the boot to the first after.</summary>
private static EnrollmentEvent RebootDetected(DateTime detectedAt, DateTime lastBoot) =>
Evt(detectedAt, "system_reboot_detected", data: new() { ["lastBootUtc"] = lastBoot.ToString("o") });
private static TimeAttributionInput Input(
List<EnrollmentEvent> events, DateTime completedAt, int durationSeconds,
string status = "Succeeded", bool wg = false, DateTime? resumedAt = null)
=> new()
{
TenantId = "00000000-0000-0000-0000-0000000000t1",
SessionId = "00000000-0000-0000-0000-0000000000s1",
Status = status,
StartedAt = T0,
CompletedAt = completedAt,
DurationSeconds = durationSeconds,
IsPreProvisioned = wg,
ResumedAt = resumedAt,
Events = events,
};
private static void AssertExactPartition(SessionTimeBreakdown b)
{
Assert.Equal(b.WallClockSeconds, b.Segments.Sum(s => s.Seconds) + b.UnattributedSeconds);
Assert.True(b.UnattributedSeconds >= 0, "unattributed must never be negative");
}
private static int Total(SessionTimeBreakdown b, string segment) =>
b.GetSegmentTotals().TryGetValue(segment, out var seconds) ? seconds : 0;
private static DateTime At(int minutes, int seconds = 0) => T0.AddMinutes(minutes).AddSeconds(seconds);
// ── the device ESP and the update page, shared by the fixtures ──────────
private static List<EnrollmentEvent> DeviceEsp() => new()
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(At(20), "esp_exiting"),
};
// ── golden: long update, three restarts, second page visit (field: 83 min) ──
private static List<EnrollmentEvent> LongUpdateWithSecondVisit()
{
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 5), "OobeNDUP"),
PageName(At(20, 10), "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true"),
// The agent declares AccountSetup while the page still runs — no evidence of the user.
Evt(At(22, 36), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Servicing(At(43, 35), "initiating", targetState: "Staged"),
Servicing(At(63), "state_reached", targetState: "Staged"),
Servicing(At(63, 10), "initiating"),
Servicing(At(86, 46), "reboot_required"),
PageName(At(102, 39), "ExpeditedUpdate_downloadInstallFailureHelper"),
PageStart(At(103, 11), "RebootNDUP"),
RebootDetected(At(104, 43), lastBoot: At(104)),
Evt(At(106), "agent_shutting_down"),
RebootDetected(At(108), lastBoot: At(107)),
Evt(At(110), "agent_shutting_down"),
RebootDetected(At(112), lastBoot: At(111)),
Servicing(At(119, 54), "state_reached"),
PageStart(At(120, 6), "OobeNDUP"),
PageStop(At(120, 36), "OobeNDUP", "success"),
Evt(At(122, 8), "desktop_arrived"),
Evt(At(125), "enrollment_complete"),
});
return events;
}
[Fact]
public void LongUpdate_IsOneUpdateSegment_FromThePageStartToTheSecondVisit_ThenTheWait()
{
var b = TimeAttributionCalculator.Compute(Input(LongUpdateWithSecondVisit(), At(125), 7500))!;
AssertExactPartition(b);
Assert.Equal(TimeAttributionCalculator.CurrentVersion, b.AttributionVersion);
Assert.Equal(120, Total(b, TimeAttributionSegments.DevicePrep));
Assert.Equal(18 * 60 + 5, Total(b, TimeAttributionSegments.EspApps)); // +2m → page start
Assert.Equal(100 * 60 + 31, Total(b, TimeAttributionSegments.OsUpdate)); // page start → second visit ends
Assert.Equal(92, Total(b, TimeAttributionSegments.AwaitingSignIn)); // → desktop
Assert.Equal(0, Total(b, TimeAttributionSegments.IdentityHello)); // the early AccountSetup lay inside the update
Assert.Equal(172, Total(b, TimeAttributionSegments.DesktopHandoff));
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(20, 5), update.StartUtc);
Assert.Equal(At(120, 36), update.EndUtc);
Assert.Equal(100 * 60 + 31, update.Seconds);
Assert.Equal(new[] { "KB5129195" }, update.Kbs);
Assert.Empty(update.NotInstalledKbs);
Assert.Equal(3, update.RebootCount);
// downloadInstallFailureHelper and a start timeout after "restart required" are no failure:
// the package reached Installed after the restarts (field shape e4ecd6f8).
Assert.Equal(OsUpdateOutcomes.Installed, update.Outcome);
// The update's restarts sit in its own segment.
Assert.Equal(3, b.RebootSpans.Count);
Assert.All(b.RebootSpans, r => Assert.Equal(TimeAttributionSegments.OsUpdate, r.SegmentKey));
}
[Fact]
public void LongUpdate_WasIdentityHelloBeforeV4()
{
// Same stream without the update signals = what v3 saw: the AccountSetup declaration
// took the whole update.
var withoutUpdate = LongUpdateWithSecondVisit()
.Where(e => e.EventType != "oobe_update_page" && e.EventType != "windows_update_servicing")
.ToList();
var b = TimeAttributionCalculator.Compute(Input(withoutUpdate, At(125), 7500))!;
AssertExactPartition(b);
Assert.Equal(0, Total(b, TimeAttributionSegments.OsUpdate));
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn));
Assert.Equal((122 * 60 + 8) - (22 * 60 + 36), Total(b, TimeAttributionSegments.IdentityHello));
Assert.Empty(b.OsUpdates);
}
// ── golden: update inside the apps phase, long wait with standby (field: 48 min) ──
[Fact]
public void UpdateBeforeAccountSetup_TakesItsTimeFromTheAppsPhase_AndTheWaitHoldsTheStandby()
{
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 4), "OobeNDUP"),
PageName(At(21), "ExpeditedUpdate_startWUScanStarted"),
Servicing(At(30), "initiating", package: Lcu, targetState: "Staged"),
Servicing(At(38, 50), "reboot_required", package: Lcu),
PageStart(At(38, 55), "RebootNDUP"),
RebootDetected(At(40, 10), lastBoot: At(39, 30)),
Evt(At(41), "agent_shutting_down"),
RebootDetected(At(42, 30), lastBoot: At(42)),
Servicing(At(43), "state_reached", package: Lcu),
// The user is away: the device sleeps 45 minutes at the lock screen.
Evt(At(89), "system_sleep_episode", data: new()
{
["enteredAt"] = At(44).ToString("o"),
["exitedAt"] = At(89).ToString("o"),
["kind"] = "modern_standby",
}),
Evt(At(90, 50), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Evt(At(91), "hello_wizard_started"),
Evt(At(91), "esp_phase_changed", EnrollmentPhase.FinalizingSetup),
Evt(At(93), "desktop_arrived"),
Evt(At(95), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(95), 5700))!;
AssertExactPartition(b);
Assert.Equal(18 * 60 + 4, Total(b, TimeAttributionSegments.EspApps)); // +2m → page start only
Assert.Equal(22 * 60 + 56, Total(b, TimeAttributionSegments.OsUpdate)); // → servicing reports Installed
// The wait ends at the first evidence of the user: the Hello wizard, not the AccountSetup row.
Assert.Equal((91 - 43) * 60, Total(b, TimeAttributionSegments.AwaitingSignIn));
Assert.Equal(120, Total(b, TimeAttributionSegments.IdentityHello)); // Hello → desktop
var update = Assert.Single(b.OsUpdates);
Assert.Empty(update.Kbs); // RollupFix names no KB
Assert.Equal(2, update.RebootCount);
var sleep = Assert.Single(b.SleepSpans);
Assert.Equal(TimeAttributionSegments.AwaitingSignIn, sleep.SegmentKey);
}
// ── no update, no segment ───────────────────────────────────────────────
private static List<EnrollmentEvent> WithAccountPhase(IEnumerable<EnrollmentEvent> updatePart)
{
var events = DeviceEsp();
events.AddRange(updatePart);
events.AddRange(new[]
{
Evt(At(21), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Evt(At(26), "hello_wizard_started"),
Evt(At(26), "esp_phase_changed", EnrollmentPhase.FinalizingSetup),
Evt(At(28), "desktop_arrived"),
Evt(At(30), "enrollment_complete"),
});
// Canonical order = event time here: the update part was created first.
var ordered = events.OrderBy(e => e.Timestamp).ToList();
foreach (var evt in ordered) evt.Sequence = ++_seq;
return ordered;
}
[Fact]
public void CancelledPage_LeavesThePhasePartitionUnchanged()
{
// AllowOOBEUpdates=0: the page checks the CSP gate and cancels within seconds.
var cancelled = WithAccountPhase(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(20, 3), "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "false"),
PageStop(At(20, 5), "OobeNDUP", "cancel"),
PageStart(At(20, 6), "RebootNDUP"),
});
var plain = WithAccountPhase(Array.Empty<EnrollmentEvent>());
var b = TimeAttributionCalculator.Compute(Input(cancelled, At(30), 1800))!;
var v3 = TimeAttributionCalculator.Compute(Input(plain, At(30), 1800))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
Assert.Equal(v3.Segments.Select(s => (s.SegmentKey, s.Seconds)), b.Segments.Select(s => (s.SegmentKey, s.Seconds)));
}
[Fact]
public void ScanThatFoundNothing_IsNoUpdate()
{
// Every visit logs the same init names — with "Install" and "Download" in them.
var events = WithAccountPhase(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(20, 3), "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true"),
PageName(At(20, 4), "SdxWebAppCloudNDUP_initialize_NDUPInstallCanceledInOptOut", "false"),
PageName(At(20, 4), "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", "0"),
PageName(At(20, 9), "ExpeditedUpdate_startWUScanStarted"),
PageName(At(20, 58), "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
PageStop(At(20, 59), "OobeNDUP", "cancel"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
Assert.Equal(0, Total(b, TimeAttributionSegments.OsUpdate));
}
[Fact]
public void ServicingBesideACancelledPage_IsSomeOtherUpdate()
{
// The page was seen and did not update; a servicing step during the account phase is
// not the OOBE update.
var events = WithAccountPhase(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageStop(At(20, 5), "OobeNDUP", "cancel"),
Servicing(At(23), "initiating", targetState: "Staged"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
}
[Fact]
public void UpdateAfterTheDesktop_StaysATimelineEvent()
{
var events = WithAccountPhase(new[]
{
Servicing(At(28, 30), "initiating", targetState: "Staged"),
Servicing(At(29, 30), "state_reached", targetState: "Staged"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
Assert.Equal(120, Total(b, TimeAttributionSegments.DesktopHandoff));
}
// ── update without a restart: no wait ──────────────────────────────────
[Fact]
public void UpdateWithoutRestart_HasNoWaitForSignIn()
{
// Without a restart the user who started the enrollment is still signed in.
var events = WithAccountPhase(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(20, 40), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
PageStop(At(20, 50), "OobeNDUP", "success"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(0, update.RebootCount);
Assert.Equal(48, Total(b, TimeAttributionSegments.OsUpdate)); // page start → page stop
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn));
}
// ── the page start was not observed ─────────────────────────────────────
[Fact]
public void WithoutAnObservedPageStart_TheFirstServicingStepAfterTheEspExitBeginsTheUpdate()
{
var events = DeviceEsp();
events.AddRange(new[]
{
Servicing(At(10), "initiating", targetState: "Staged"), // during the ESP: not the OOBE update
Servicing(At(25), "initiating", targetState: "Staged"),
Servicing(At(35), "reboot_required"),
PageStart(At(35, 10), "RebootNDUP"),
RebootDetected(At(37), lastBoot: At(36)),
Servicing(At(38), "state_reached"),
Evt(At(40), "desktop_arrived"),
Evt(At(42), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(42), 2520))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(25), update.StartUtc);
Assert.Equal(At(38), update.EndUtc);
Assert.Equal(120, Total(b, TimeAttributionSegments.AwaitingSignIn));
Assert.Equal(23 * 60, Total(b, TimeAttributionSegments.EspApps)); // the hole until the first step stays the phase's
}
// ── the agent started after the page (v5) ───────────────────────────────
/// <summary>
/// Field shape 6a7d241d: the agent's first run began after the device ESP and after the page
/// start — it saw neither (a first run reads no Shell-Core history), only the update's later
/// page names and the servicing steps the watcher backfills an hour.
/// </summary>
private static List<EnrollmentEvent> LateAgentUpdate()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
// A package the OOBE itself finished during the ESP: no part of the update.
Servicing(At(5), "initiating", package: "KB5128942", targetState: "Staged"),
Servicing(At(6), "state_reached", package: "KB5128942", targetState: "Staged"),
Servicing(At(6, 5), "initiating", package: "KB5128942"),
Servicing(At(7), "state_reached", package: "KB5128942"),
// The update's servicing stack, staged before the agent ran.
Servicing(At(20), "initiating", package: "KB5124007", targetState: "Staged"),
Servicing(At(20, 5), "state_reached", package: "KB5124007", targetState: "Staged"),
// The agent runs from here on.
PageName(At(27, 40), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
Servicing(At(32, 25), "initiating", package: "KB5124007"),
Servicing(At(33, 22), "state_reached", package: "KB5124007"),
Servicing(At(34), "initiating", package: "KB5129195", targetState: "Staged"),
Servicing(At(36), "state_reached", package: "KB5129195", targetState: "Staged"),
Servicing(At(36, 10), "initiating", package: "KB5054156", targetState: "Staged"),
Servicing(At(38), "state_reached", package: "KB5054156", targetState: "Staged"),
Servicing(At(38, 10), "initiating", package: "KB5129195"),
Servicing(At(38, 20), "initiating", package: "KB5054156"),
Servicing(At(43, 5), "reboot_required", package: "KB5129195"),
Servicing(At(43, 6), "reboot_required", package: "KB5054156"),
PageName(At(43, 10), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired"),
PageName(At(43, 12), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
PageName(At(43, 30), "SdxWebAppCloudNDUP_rebootCountdown_starting"),
PageStart(At(43, 35), "RebootNDUP"),
RebootDetected(At(46), lastBoot: At(45)),
Servicing(At(50), "state_reached", package: "KB5129195"),
Evt(At(52), "agent_shutting_down"),
// The second restart; the backfilled servicing step is the first event after it.
Servicing(At(59, 17), "state_reached", package: "KB5054156"),
RebootDetected(At(59, 30), lastBoot: At(58)),
Evt(At(62, 10), "desktop_arrived"),
Evt(At(63), "enrollment_complete"),
};
return events;
}
[Fact]
public void LateAgent_UpdateBeginsAtTheFirstStepOfAPackageItAlsoServiced()
{
var b = TimeAttributionCalculator.Compute(Input(LateAgentUpdate(), At(63), 63 * 60))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
// Not the OOBE's own package at 5 min: it has no step after the update's first name.
Assert.Equal(At(20), update.StartUtc);
Assert.Equal(At(59, 17), update.EndUtc); // the last step, where the second restart ends
Assert.Equal(39 * 60 + 17, Total(b, TimeAttributionSegments.OsUpdate));
Assert.Equal(2 * 60 + 53, Total(b, TimeAttributionSegments.AwaitingSignIn)); // → desktop
Assert.Equal(18 * 60, Total(b, TimeAttributionSegments.EspApps));
Assert.Equal(new[] { "KB5124007", "KB5129195", "KB5054156" }, update.Kbs);
Assert.Empty(update.NotInstalledKbs);
Assert.Equal(2, update.RebootCount);
Assert.Equal(OsUpdateOutcomes.Installed, update.Outcome);
}
[Fact]
public void LateAgent_WithoutItsNames_StaysTheAppsPhase()
{
// Without its names the update is invisible to a window without an observed exit (v4 saw
// it so even with them): the servicing steps alone never begin an update there.
var withoutNames = LateAgentUpdate().Where(e => e.EventType != "oobe_update_page").ToList();
var b = TimeAttributionCalculator.Compute(Input(withoutNames, At(63), 63 * 60))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
Assert.Equal((62 * 60 + 10) - 2 * 60, Total(b, TimeAttributionSegments.EspApps));
}
[Fact]
public void LateAgent_NamesWithoutServicing_BeginAtTheFirstName()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
PageName(At(24), "SdxWebAppCloudNDUP_updateUSOProgressBar_DownloadPhase_progress40"),
PageName(At(29), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
PageStop(At(29, 20), "OobeNDUP", "success"),
Evt(At(31), "desktop_arrived"),
Evt(At(32), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(Input(events, At(32), 32 * 60))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(24), update.StartUtc);
Assert.Equal(At(29, 20), update.EndUtc);
Assert.Equal(0, update.RebootCount);
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn)); // no restart: nobody was signed out
Assert.Equal(OsUpdateOutcomes.Installed, update.Outcome);
Assert.Empty(update.Kbs);
}
[Fact]
public void MissedFirstVisit_BeginsAtItsNames_NotAtTheSecondVisit()
{
// The agent missed visit 1 (it updated and restarted) and saw the confirmation visit,
// which holds no evidence of its own: v4 found no update at all.
var events = DeviceEsp();
events.RemoveAll(e => e.EventType == "esp_exiting");
events.AddRange(new[]
{
Servicing(At(21), "initiating", targetState: "Staged"),
PageName(At(23), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Servicing(At(30), "state_reached", targetState: "Staged"),
Servicing(At(30, 10), "initiating"),
Servicing(At(36), "reboot_required"),
PageName(At(36, 5), "SdxWebAppCloudNDUP_rebootCountdown_starting"),
PageStart(At(36, 10), "RebootNDUP"),
RebootDetected(At(38), lastBoot: At(37)),
Servicing(At(39), "state_reached"),
PageStart(At(39, 10), "OobeNDUP"),
PageStop(At(39, 30), "OobeNDUP", "success"),
Evt(At(41), "desktop_arrived"),
Evt(At(42), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(42), 42 * 60))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(21), update.StartUtc); // the package's first step, before the name
Assert.Equal(At(39, 30), update.EndUtc);
Assert.Equal(new[] { "KB5129195" }, update.Kbs);
Assert.Equal(1, update.RebootCount);
Assert.Equal(90, Total(b, TimeAttributionSegments.AwaitingSignIn));
}
[Fact]
public void EspExitAfterTheNames_DoesNotBoundTheBegin()
{
// An exit the agent saw after the update's names belongs to a later handoff; the begin
// comes from the packages the update serviced, as without an exit.
var events = LateAgentUpdate();
events.Add(Evt(At(44), "esp_exiting"));
var b = TimeAttributionCalculator.Compute(Input(events, At(63), 63 * 60))!;
AssertExactPartition(b);
Assert.Equal(At(20), Assert.Single(b.OsUpdates).StartUtc);
}
[Fact]
public void EspExitBeforeTheNames_BoundsTheBeginAsInV4()
{
// A known exit before the names: the first servicing step after it begins the update —
// the KB staged during the ESP stays out, whatever the update did with it later.
var events = LateAgentUpdate();
events.Add(Evt(At(25), "esp_exiting"));
var b = TimeAttributionCalculator.Compute(Input(events, At(63), 63 * 60))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(27, 40), update.StartUtc); // no step between the exit and the first name
Assert.Equal(new[] { "KB5124007", "KB5129195", "KB5054156" }, update.Kbs);
}
[Fact]
public void NamesAfterTheUserIsBack_AreNoUpdate()
{
var events = WithAccountPhase(new[]
{
PageName(At(28, 30), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
PageName(At(29), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
}
[Fact]
public void WhiteGlove_PartOneExit_DoesNotBeginAnUpdateInPartTwo()
{
// v4 took the session's first exit: part 1's let a servicing step in part 2 begin an
// update. Part 2 here has no exit, no page and no update name — no update.
var part1End = At(30);
var resumedAt = T0.AddDays(2);
var completedAt = resumedAt.AddMinutes(20);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(At(25), "esp_exiting"),
Evt(part1End, "whiteglove_part1_complete"),
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.DeviceSetup),
Evt(resumedAt.AddMinutes(1), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Servicing(resumedAt.AddMinutes(5), "initiating", targetState: "Staged"),
Servicing(resumedAt.AddMinutes(8), "state_reached", targetState: "Staged"),
Evt(resumedAt.AddMinutes(15), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Evt(resumedAt.AddMinutes(16), "hello_wizard_started"),
Evt(resumedAt.AddMinutes(19), "desktop_arrived"),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, durationSeconds: 30 * 60 + 20 * 60, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
}
// ── outcome ─────────────────────────────────────────────────────────────
[Fact]
public void FailedDownload_IsFailed_AndTheStagedKbIsNotInstalled()
{
// Field shape e7551b82: only the servicing stack staged; the download failed after 25
// minutes, the page restarted the device, and the second visit found nothing to offer.
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(20, 3), "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true"),
PageName(At(21, 42), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Servicing(At(22), "initiating", package: "KB5124007", targetState: "Staged"),
Servicing(At(26), "state_reached", package: "KB5124007", targetState: "Staged"),
PageName(At(45, 10), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError"),
PageName(At(45, 12), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure"),
PageName(At(45, 13), "SdxWebAppCloudNDUP_downloadInstallFailureHelper"),
PageStop(At(45, 20), "OobeNDUP", "fail"),
PageStart(At(45, 20), "RebootNDUP"),
RebootDetected(At(46, 30), lastBoot: At(45, 50)),
PageStart(At(46, 51), "OobeNDUP"),
PageName(At(46, 52), "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", "1"),
PageName(At(47, 10), "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
PageStop(At(47, 13), "OobeNDUP", "cancel"),
Evt(At(48, 15), "desktop_arrived"),
Evt(At(50), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(50), 3000))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(20, 2), update.StartUtc);
Assert.Equal(At(47, 13), update.EndUtc);
Assert.Equal(OsUpdateOutcomes.Failed, update.Outcome);
Assert.Empty(update.Kbs);
Assert.Equal(new[] { "KB5124007" }, update.NotInstalledKbs);
Assert.Equal(1, update.RebootCount);
}
[Fact]
public void FailureThenSkip_IsSkipped()
{
// Field shape cb1992b8: visit 1 failed; visit 2 downloaded again, and someone selected
// Skip before the install began.
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(21), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
PageName(At(30), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError"),
PageName(At(30, 2), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure"),
PageStop(At(30, 10), "OobeNDUP", "fail"),
PageStart(At(30, 10), "RebootNDUP"),
RebootDetected(At(31, 30), lastBoot: At(30, 40)),
PageStart(At(32), "OobeNDUP"),
PageName(At(32, 1), "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", "1"),
PageName(At(33), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
PageName(At(40), "SdxWebAppCloudNDUP_manageProgressBars_waitingForInstallStartError"),
PageName(At(41), "CloudNDUPSkipDownloadInstallButtonClicked", "0"),
PageStop(At(41, 5), "OobeNDUP", "cancel"),
Evt(At(43), "desktop_arrived"),
Evt(At(44), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(44), 2640))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(OsUpdateOutcomes.Skipped, update.Outcome);
Assert.Equal(At(41, 5), update.EndUtc);
}
[Fact]
public void FailureRetriedAndInstalled_IsInstalled()
{
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(21), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
PageName(At(25), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError"),
PageName(At(26), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
Servicing(At(27), "initiating"),
Servicing(At(30), "state_reached"),
PageName(At(30, 5), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
PageStop(At(30, 10), "OobeNDUP", "success"),
Evt(At(32), "desktop_arrived"),
Evt(At(33), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(33), 1980))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(OsUpdateOutcomes.Installed, update.Outcome);
Assert.Equal(new[] { "KB5129195" }, update.Kbs);
}
[Fact]
public void FailedServicingStep_IsFailed()
{
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
Servicing(At(22), "initiating"),
Servicing(At(28), "failed"),
PageStop(At(28, 30), "OobeNDUP", "cancel"),
Evt(At(30), "desktop_arrived"),
Evt(At(31), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(31), 1860))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(OsUpdateOutcomes.Failed, update.Outcome);
Assert.Empty(update.Kbs);
Assert.Equal(new[] { "KB5129195" }, update.NotInstalledKbs);
}
[Theory]
[InlineData("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted", true)]
[InlineData("SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded", true)]
[InlineData("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired_lcu", true)]
[InlineData("SdxWebAppCloudNDUP_updateUSOProgressBar_DownloadPhase_progress100", true)]
[InlineData("SdxWebAppCloudNDUP_downloadInstallFailureHelper", true)]
[InlineData("SdxWebAppCloudNDUP_rebootCountdown_starting", true)]
[InlineData("SdxWebAppCloudNDUP_initialize_NDUPInstallCanceledInOptOut", false)]
[InlineData("SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", false)]
[InlineData("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededNoReboot", false)]
[InlineData("SdxWebAppCloudNDUP_handleInstallHelper", false)]
[InlineData("ExpeditedUpdate_getUpdateResultsSucceeded", false)]
[InlineData(null, false)]
public void UpdateActivityNames_AreTheOnesOnlyAnUpdateWrites(string? name, bool expected)
{
Assert.Equal(expected, TimeAttributionCalculator.IsUpdateActivityName(name));
}
// ── restarts ────────────────────────────────────────────────────────────
[Fact]
public void ClockSyncAtBoot_DoesNotShortenTheRestart()
{
// The hardware clock sync is logged 0.1 s before lastBootUtc; the restart began with
// the last real event before it.
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
Servicing(At(25), "initiating", targetState: "Staged"),
Servicing(At(30), "reboot_required"),
PageStart(At(30, 5), "RebootNDUP"),
Evt(At(31).AddMilliseconds(-140), "system_clock_changed"),
RebootDetected(At(31, 20), lastBoot: At(31)),
Servicing(At(32), "state_reached"),
Evt(At(34), "desktop_arrived"),
Evt(At(35), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(35), 2100))!;
AssertExactPartition(b);
var restart = Assert.Single(b.RebootSpans);
Assert.Equal(At(30, 5), restart.StartUtc); // not the clock sync
Assert.Equal(At(31, 20), restart.EndUtc);
Assert.Equal(75, restart.Seconds);
Assert.Equal(TimeAttributionSegments.OsUpdate, restart.SegmentKey);
}
// ── robustness ──────────────────────────────────────────────────────────
[Fact]
public void BackfillDuplicates_ChangeNothing()
{
var once = LongUpdateWithSecondVisit();
var twice = LongUpdateWithSecondVisit();
// An agent restart re-sends the page records it read before, with their event times.
twice.AddRange(twice.Where(e => e.EventType == "oobe_update_page").Select(e => Evt(e.Timestamp, e.EventType, data: e.Data)).ToList());
var a = TimeAttributionCalculator.Compute(Input(once, At(125), 7500))!;
var b = TimeAttributionCalculator.Compute(Input(twice, At(125), 7500))!;
AssertExactPartition(b);
Assert.Equal(a.Segments.Select(s => (s.SegmentKey, s.Seconds)), b.Segments.Select(s => (s.SegmentKey, s.Seconds)));
Assert.Equal(a.OsUpdates.Single().Seconds, b.OsUpdates.Single().Seconds);
}
[Fact]
public void FailedDeclaration_EndsTheUpdateToo()
{
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
Servicing(At(25), "initiating", targetState: "Staged"),
Evt(At(50), "enrollment_failed", EnrollmentPhase.Failed),
Servicing(At(55), "state_reached", targetState: "Staged"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(60), 3600, status: "Failed"))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(25), update.EndUtc); // the step after the failure is not attributed
Assert.Equal(4 * 60 + 58, Total(b, TimeAttributionSegments.OsUpdate));
Assert.Empty(update.Kbs); // only staged before the bound
Assert.Equal(new[] { "KB5129195" }, update.NotInstalledKbs);
Assert.Equal(OsUpdateOutcomes.Unknown, update.Outcome);
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn));
Assert.True(b.UnattributedSeconds >= 10 * 60); // the post-failure tail stays unattributed
}
[Fact]
public void WhiteGlove_UpdateInTheUserPart_StaysInsideItsWindow()
{
var part1End = At(30);
var resumedAt = T0.AddDays(2);
var completedAt = resumedAt.AddMinutes(40);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(part1End, "whiteglove_part1_complete"),
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.DeviceSetup),
Evt(resumedAt.AddMinutes(3), "esp_exiting"),
PageStart(resumedAt.AddMinutes(3).AddSeconds(4), "OobeNDUP"),
Servicing(resumedAt.AddMinutes(10), "initiating", targetState: "Staged"),
Servicing(resumedAt.AddMinutes(20), "reboot_required"),
PageStart(resumedAt.AddMinutes(20).AddSeconds(5), "RebootNDUP"),
RebootDetected(resumedAt.AddMinutes(23), lastBoot: resumedAt.AddMinutes(22)),
Servicing(resumedAt.AddMinutes(24), "state_reached"),
Evt(resumedAt.AddMinutes(30), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Evt(resumedAt.AddMinutes(32), "hello_wizard_started"),
Evt(resumedAt.AddMinutes(38), "desktop_arrived"),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, durationSeconds: 30 * 60 + 40 * 60, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(resumedAt.AddMinutes(3).AddSeconds(4), update.StartUtc);
Assert.Equal(resumedAt.AddMinutes(24), update.EndUtc);
Assert.Equal(8 * 60, Total(b, TimeAttributionSegments.AwaitingSignIn)); // → Hello wizard
Assert.All(b.Segments, s => Assert.True(s.EndUtc <= part1End || s.StartUtc >= resumedAt, "no span in the pause"));
}
}
@@ -51,6 +51,15 @@ public class TimeAttributionPersistenceAndAggregationTests
{
new() { StartUtc = T0.AddMinutes(20), EndUtc = T0.AddMinutes(26), Seconds = 360, SegmentKey = TimeAttributionSegments.EspApps, Kind = "modern_standby" },
},
OsUpdates = new List<OsUpdateSpan>
{
new()
{
StartUtc = T0.AddMinutes(9), EndUtc = T0.AddMinutes(19), Seconds = 600,
Kbs = new List<string> { "KB5129195", "KB5054156" }, NotInstalledKbs = new List<string> { "KB5124007" },
Outcome = OsUpdateOutcomes.Failed, RebootCount = 2,
},
},
BlockingApps = new List<BlockingAppInterval>
{
new() { AppId = AppX, AppName = "App X", StartUtc = T0.AddMinutes(6), EndUtc = T0.AddMinutes(16), Seconds = 600 },
@@ -89,12 +98,47 @@ public class TimeAttributionPersistenceAndAggregationTests
Assert.Equal(T0.AddMinutes(20), sleep.StartUtc);
Assert.Equal(360, sleep.Seconds);
var update = Assert.Single(mapped.OsUpdates);
Assert.Equal(T0.AddMinutes(9), update.StartUtc);
Assert.Equal(T0.AddMinutes(19), update.EndUtc);
Assert.Equal(600, update.Seconds);
Assert.Equal(new[] { "KB5129195", "KB5054156" }, update.Kbs);
Assert.Equal(new[] { "KB5124007" }, update.NotInstalledKbs);
Assert.Equal(OsUpdateOutcomes.Failed, update.Outcome);
Assert.Equal(2, update.RebootCount);
var app = Assert.Single(mapped.BlockingApps);
Assert.Equal(AppX, app.AppId);
Assert.Equal("App X", app.AppName);
Assert.Equal(600, app.Seconds);
}
[Fact]
public void BreakdownEntity_WrittenBeforeOsUpdates_MapsToAnEmptyList()
{
// v3 rows have no OsUpdatesJson column; the sweep recomputes them, reads must not fail.
var entity = TableStorageService.BuildSessionTimeBreakdownEntity(
new SessionTimeBreakdown { TenantId = TenantA, SessionId = "s-1", WallClockSeconds = 600 });
entity.Remove("OsUpdatesJson");
Assert.Empty(TableStorageService.MapToSessionTimeBreakdown(entity).OsUpdates);
}
[Fact]
public void BreakdownEntity_OsUpdateWrittenBeforeTheOutcome_MapsToUnknown()
{
// v4 spans carry no Outcome/NotInstalledKbs until the sweep recomputes them.
var entity = TableStorageService.BuildSessionTimeBreakdownEntity(
new SessionTimeBreakdown { TenantId = TenantA, SessionId = "s-1", WallClockSeconds = 600 });
entity["OsUpdatesJson"] =
"[{\"StartUtc\":\"2026-10-02T14:00:00Z\",\"EndUtc\":\"2026-10-02T14:10:00Z\",\"Seconds\":600,\"Kbs\":[\"KB5129195\"],\"RebootCount\":1}]";
var update = Assert.Single(TableStorageService.MapToSessionTimeBreakdown(entity).OsUpdates);
Assert.Equal(OsUpdateOutcomes.Unknown, update.Outcome);
Assert.Empty(update.NotInstalledKbs);
Assert.Equal(new[] { "KB5129195" }, update.Kbs);
}
[Fact]
public void BreakdownEntity_OccupancyTriState_AbsentColumnMapsToNull_NeverZero()
{
@@ -376,9 +420,12 @@ public class TimeAttributionPersistenceAndAggregationTests
pairs, Array.Empty<SessionSummary>(), T0)
.Single(a => a.TenantId == TenantA);
Assert.Equal(6, aggregate.SegmentStats.Count); // 5 canonical + unattributed — the full stack
Assert.Equal(8, aggregate.SegmentStats.Count); // 7 canonical + unattributed — the full stack
// The fixture has no user_esp span → honest 0, not absent.
Assert.Equal(0, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.UserEsp).MedianSeconds);
// A session without an OOBE update contributes 0 to the update and the wait.
Assert.Equal(0, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.OsUpdate).MedianSeconds);
Assert.Equal(0, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.AwaitingSignIn).MedianSeconds);
Assert.Equal(42, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.Unattributed).MedianSeconds);
}
@@ -57,6 +57,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<RawPage<SessionSummary>> GetSessionsPageAsync(string tenantId, int? days, int pageSize, string? continuation)
=> _storage.GetSessionsPageAsync(tenantId, days, pageSize, continuation);
public Task<bool> HasAnySessionStrictAsync(string tenantId)
=> _storage.HasAnySessionStrictAsync(tenantId);
public Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation, IReadOnlyCollection<string>? allowedTenantIds = null, IEnumerable<string>? select = null, CancellationToken cancellationToken = default)
=> _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation, allowedTenantIds, select, cancellationToken);
@@ -0,0 +1,75 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Progress;
/// <summary>
/// Tells the Progress Portal whether the caller's own organization looks unused. A member without a
/// role only ever sees the portal; without this hint they never learn that their organization already
/// runs the product, or that nobody looks after it anymore. Reveals no session data: the JWT tenant
/// only, a flag and the signup date.
/// </summary>
public class ProgressTenantStatusFunction
{
/// <summary>A younger organization is still being set up, not abandoned.</summary>
internal static readonly TimeSpan UnusedAfter = TimeSpan.FromDays(14);
private readonly ILogger<ProgressTenantStatusFunction> _logger;
private readonly TenantConfigurationService _tenantConfigService;
private readonly ISessionRepository _sessionRepo;
public ProgressTenantStatusFunction(
ILogger<ProgressTenantStatusFunction> logger,
TenantConfigurationService tenantConfigService,
ISessionRepository sessionRepo)
{
_logger = logger;
_tenantConfigService = tenantConfigService;
_sessionRepo = sessionRepo;
}
/// <summary>GET /api/progress/tenant-status — the caller's own tenant (catalog: TenantScoping.Jwt).</summary>
[Function("ProgressGetTenantStatus")]
public async Task<HttpResponseData> GetTenantStatus(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "progress/tenant-status")] HttpRequestData req)
{
try
{
var tenantId = req.GetRequestContext().TenantId;
// Both reads are strict: a storage failure must surface as 500, never as "unused".
var configTask = _tenantConfigService.GetConfigurationIfExistsAsync(tenantId);
var hasSessionTask = _sessionRepo.HasAnySessionStrictAsync(tenantId);
await Task.WhenAll(configTask, hasSessionTask);
return await req.OkAsync(Evaluate(configTask.Result, hasSessionTask.Result, DateTime.UtcNow));
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "ProgressGetTenantStatus");
}
}
internal static ProgressTenantStatusResponse Evaluate(TenantConfiguration? config, bool hasAnySession, DateTime utcNow)
{
// No configuration row: not an onboarded organization, nothing to call unused.
if (config == null)
return new ProgressTenantStatusResponse { Success = true };
// A row from before OnboardedAt existed is old by definition.
var signedUpAt = config.OnboardedAt;
var settled = signedUpAt == null || utcNow - signedUpAt.Value >= UnusedAfter;
return new ProgressTenantStatusResponse
{
Success = true,
Unused = !hasAnySession && settled,
SignedUpAt = signedUpAt
};
}
}
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -65,6 +66,12 @@ public sealed class TimeAttributionInput
/// <item><b>Reboot spans from event gaps</b> around the <c>lastBootUtc</c> payload — the
/// <c>system_reboot_detected</c> event is detection-time-stamped by the next run and never used
/// as the reboot moment (audit Q7). Reboots are a cross-cutting annotation, not a segment.</item>
/// <item><b>The OOBE quality update takes its time from the phase segments</b> (v4, D-314):
/// <c>os_update</c> from the start of the update page that worked on an update — or, when the
/// agent started after the page, from the update's first evidence (v5) — to the last update
/// evidence before the user is back, then — after a restart — <c>awaiting_sign_in</c> until the
/// first evidence of the user. Without update evidence (the page cancelled or found nothing) the
/// partition is the phase partition unchanged. The latest outcome evidence names the outcome.</item>
/// </list>
/// </summary>
public static class TimeAttributionCalculator
@@ -74,7 +81,11 @@ public static class TimeAttributionCalculator
// pre-enrollment state on disk are now flagged and excluded from fleet segment stats.
// v3: SleepSpans — completed sleep/standby episodes (system_sleep_episode) as cross-cutting
// annotations mirroring RebootSpans; the maintenance sweep recomputes older rows.
public const int CurrentVersion = 3;
// v4: os_update + awaiting_sign_in — the OOBE quality update and the wait for the user after
// its restart, cut out of the phase segment they overlap (D-314), with OsUpdates naming the KBs.
// v5: an update whose page start the agent missed begins at its first evidence; the device-ESP
// exit is the window's own; OsUpdates carry the outcome and split installed from not installed KBs.
public const int CurrentVersion = 5;
/// <summary>
/// Enrollment class for fleet aggregation — classes are NEVER mixed in one aggregate (a
@@ -142,6 +153,12 @@ public static class TimeAttributionCalculator
var anchors = BuildAnchors(events, ref flags);
var spans = BuildSegmentSpans(windows, anchors);
// D-310: the OOBE quality update and the wait for the user after its restart take their
// time from the phase segment they overlap — the ESP is over, the device is updating.
var rebootGaps = FindRebootGaps(events);
var osUpdates = BuildOsUpdates(events, windows, anchors, rebootGaps);
spans = ApplyOsUpdates(spans, windows, osUpdates);
var attributedSeconds = spans.Sum(s => s.Seconds);
var unattributed = wallClock - attributedSeconds;
@@ -169,7 +186,7 @@ public static class TimeAttributionCalculator
occupancy = ComputeOccupancySeconds(blockingAppSegments, espAppsSpans);
}
var rebootSpans = BuildRebootSpans(events, windows, spans);
var rebootSpans = BuildRebootSpans(rebootGaps, windows, spans);
var sleepSpans = BuildSleepSpans(events, windows, spans);
if (events.Any(e => e.EventType == Constants.EventTypes.AgentLateStart))
@@ -196,6 +213,18 @@ public static class TimeAttributionCalculator
RebootSpans = rebootSpans,
SleepSeconds = sleepSpans.Sum(s => s.Seconds),
SleepSpans = sleepSpans,
OsUpdates = osUpdates
.Select(u => new OsUpdateSpan
{
StartUtc = u.Start,
EndUtc = u.End,
Seconds = ClampToWindows(u.Start, u.End, windows)?.Seconds ?? 0,
Kbs = u.Kbs,
NotInstalledKbs = u.NotInstalledKbs,
Outcome = u.Outcome,
RebootCount = u.RebootCount,
})
.ToList(),
BlockingApps = blockingApps
.OrderByDescending(a => a.Seconds)
.ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase)
@@ -642,16 +671,17 @@ public static class TimeAttributionCalculator
// ── reboot spans ────────────────────────────────────────────────────────
/// <summary>
/// One span per distinct observed boot: the event-stream gap bracketing <c>lastBootUtc</c>
/// One gap per distinct observed boot: the event-stream gap bracketing <c>lastBootUtc</c>
/// (last event before the boot → first event at/after it). Fallback without a usable
/// payload: previous event → the detection event itself (which is genuinely post-boot —
/// usable as an observation bound, never as the boot moment). Spans are clipped to the
/// observation hull, so a WhiteGlove-pause reboot contributes nothing.
/// usable as an observation bound, never as the boot moment). Unclipped; the update interval
/// and <see cref="BuildRebootSpans"/> read the same gaps. A <c>system_clock_changed</c> row is
/// no bound (v4): the hardware clock sync at boot is logged a fraction of a second before
/// <c>lastBootUtc</c> and shrank the outage to seconds (field: 4–10 s for restarts of a minute).
/// </summary>
private static List<RebootSpan> BuildRebootSpans(
List<EnrollmentEvent> events, List<Window> windows, List<TimeAttributionSpan> spans)
private static List<(DateTime Start, DateTime End)> FindRebootGaps(List<EnrollmentEvent> events)
{
var result = new List<RebootSpan>();
var result = new List<(DateTime Start, DateTime End)>();
var seenBoots = new HashSet<DateTime>();
for (var i = 0; i < events.Count; i++)
@@ -677,6 +707,7 @@ public static class TimeAttributionCalculator
if (!seenBoots.Add(lastBoot.Value)) continue; // duplicate detection of the same boot
foreach (var e in events)
{
if (e.EventType == Constants.EventTypes.SystemClockChanged) continue;
if (e.Timestamp < lastBoot.Value)
{
if (!gapStart.HasValue || e.Timestamp > gapStart.Value) gapStart = e.Timestamp;
@@ -694,8 +725,23 @@ public static class TimeAttributionCalculator
}
if (!gapStart.HasValue || !gapEnd.HasValue || gapEnd.Value <= gapStart.Value) continue;
result.Add((gapStart.Value, gapEnd.Value));
}
var clamped = ClampToWindows(gapStart.Value, gapEnd.Value, windows);
return result;
}
/// <summary>
/// One span per reboot gap, clipped to the observation hull, so a WhiteGlove-pause reboot
/// contributes nothing.
/// </summary>
private static List<RebootSpan> BuildRebootSpans(
List<(DateTime Start, DateTime End)> gaps, List<Window> windows, List<TimeAttributionSpan> spans)
{
var result = new List<RebootSpan>();
foreach (var gap in gaps)
{
var clamped = ClampToWindows(gap.Start, gap.End, windows);
if (!clamped.HasValue) continue;
// In-window seconds — a reboot gap bracketing the WhiteGlove pause contributes
// only its in-window flanks, never the pause itself.
@@ -714,6 +760,488 @@ public static class TimeAttributionCalculator
return result;
}
// ── OOBE quality update (D-310) ─────────────────────────────────────────
/// <summary>CXID of the OOBE update page; <see cref="UpdateRestartPage"/> follows it and restarts for the update.</summary>
internal const string UpdatePage = "OobeNDUP";
internal const string UpdateRestartPage = "RebootNDUP";
// "Package_for_KB5129195~31bf…": the KB follows an underscore, so no \b before it.
private static readonly Regex KbPattern = new(
@"(?<![A-Za-z0-9])KB(\d{6,8})(?![0-9])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
private sealed class OsUpdate
{
public DateTime Start;
public DateTime End;
/// <summary>End of the wait for the user after the update's restart; null without a restart.</summary>
public DateTime? AwaitingSignInEnd;
public List<string> Kbs = new();
public List<string> NotInstalledKbs = new();
public string Outcome = OsUpdateOutcomes.Unknown;
public int RebootCount;
}
private readonly struct PageRecord
{
public PageRecord(DateTime at, string cxhEvent, string? page, string? name, string? result)
{
At = at;
CxhEvent = cxhEvent;
Page = page;
Name = name;
Result = result;
}
public DateTime At { get; }
public string CxhEvent { get; }
public string? Page { get; }
public string? Name { get; }
public string? Result { get; }
public bool IsStartOf(string page) => CxhEvent == "page_started" && string.Equals(Page, page, StringComparison.OrdinalIgnoreCase);
public bool IsStopOf(string page) => CxhEvent == "page_stopped" && string.Equals(Page, page, StringComparison.OrdinalIgnoreCase);
}
/// <summary>One CBS servicing step (<c>windows_update_servicing</c>) of an OS package.</summary>
private readonly struct ServicingStep
{
public ServicingStep(DateTime at, string? package, string? step, string? targetState)
{
At = at;
Package = package;
Step = step;
TargetState = targetState;
}
public DateTime At { get; }
public string? Package { get; }
public string? Step { get; }
public string? TargetState { get; }
public bool ReachedInstalled =>
string.Equals(Step, "state_reached", StringComparison.OrdinalIgnoreCase) &&
string.Equals(TargetState, "Installed", StringComparison.OrdinalIgnoreCase);
public bool Failed => string.Equals(Step, "failed", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Page names that only an update in progress writes (field data 2026-10-02). Every visit
/// logs init names such as <c>initialize_NDUPInstallCanceledInOptOut</c> and
/// <c>initialize_NDUPDownloadInstallPreviousFailureCount</c>, a scan that found nothing
/// included, and <c>installSucceededNoReboot</c> follows the language check of visits whose
/// update came later — so "Download"/"Install" in a name proves nothing.
/// </summary>
private static readonly string[] UpdateActivityMarkers =
{
"commitExpeditionDownloadInstall", // the page commits the download and install
"downloadSucceeded",
"installSucceededRebootRequired",
"DownloadPhase", // the progress bars of the download …
"installPhase", // … and of the install
"downloadInstallFailureHelper", // a download or install failed
"rebootCountdown", // the page restarts for the update
};
/// <summary>
/// The update page's own record of a download or an install — evidence of an update next to
/// the servicing steps (an agent caps the page names per run, so this is the weaker of the two).
/// </summary>
internal static bool IsUpdateActivityName(string? name) => HasMarker(name, UpdateActivityMarkers);
// Outcome evidence on the update page (field data 2026-10-03/05). Only the explicit failures
// count: an update that installed fine can still log downloadInstallFailureHelper and a start
// timeout after its "restart required" (session shape e4ecd6f8).
private static readonly string[] UpdateSucceededMarkers =
{
"installSucceededRebootRequired", // also the _lcu twin
"commitExpeditionDownloadInstallAsyncSucceeded",
};
private static readonly string[] UpdateFailedMarkers =
{
"downloadFailedError",
"installFailedError",
"commitExpeditionDownloadInstallAsyncFailure",
};
private static readonly string[] UpdateSkippedMarkers =
{
"SkipDownloadInstallButtonClicked", // someone selected Skip on the page
};
private static bool HasMarker(string? name, string[] markers) =>
name != null &&
markers.Any(marker => name.IndexOf(marker, StringComparison.OrdinalIgnoreCase) >= 0);
/// <summary>
/// At most one update per observation window. <b>Begin:</b> update-activity names the agent
/// saw before the first observed start of the update page (<see cref="UpdatePage"/>) — it
/// started after the visit they belong to — begin at the earliest of the first such name and
/// the update's first servicing step (see <see cref="FindUpdateBegin"/>). Otherwise the start
/// of the update page whose visit holds update evidence — a servicing step or an
/// update-activity name before the visit ends (its stop, the <see cref="UpdateRestartPage"/>
/// start or the next visit). Only when no page start and no such name was observed in the
/// window: the first servicing step after the window's device-ESP exit. Never after the user
/// is back. <b>Bound:</b> the first evidence of the user (user ESP apps, desktop, Hello
/// wizard, a Complete declaration), the end of attribution (Failed) or the window end.
/// <b>End:</b> the latest update evidence before the bound — page record, servicing step, or
/// the end of a restart that began in the interval. With a restart in the interval the user
/// is signed out: <c>awaiting_sign_in</c> runs from the end to the bound. <b>Outcome:</b> the
/// latest outcome evidence in [begin, bound) — see <see cref="ResolveOutcome"/>.
/// </summary>
private static List<OsUpdate> BuildOsUpdates(
List<EnrollmentEvent> events, List<Window> windows, List<Anchor> anchors,
List<(DateTime Start, DateTime End)> rebootGaps)
{
var result = new List<OsUpdate>();
var pages = new List<PageRecord>();
var servicing = new List<ServicingStep>();
foreach (var evt in events)
{
if (evt.EventType == Constants.EventTypes.OobeUpdatePage && evt.Data != null)
{
pages.Add(new PageRecord(evt.Timestamp,
DataString(evt.Data, "cxhEvent") ?? string.Empty,
DataString(evt.Data, "page"),
DataString(evt.Data, "name"),
DataString(evt.Data, "result")));
}
else if (evt.EventType == Constants.EventTypes.WindowsUpdateServicing)
{
servicing.Add(evt.Data != null
? new ServicingStep(evt.Timestamp, DataString(evt.Data, "package"), DataString(evt.Data, "step"), DataString(evt.Data, "targetState"))
: new ServicingStep(evt.Timestamp, null, null, null));
}
}
if (servicing.Count == 0 && !pages.Any(p => IsUpdateActivityName(p.Name))) return result;
pages = pages.OrderBy(p => p.At).ToList();
servicing = servicing.OrderBy(c => c.At).ToList();
var evidence = servicing.Select(c => c.At)
.Concat(pages.Where(p => IsUpdateActivityName(p.Name)).Select(p => p.At))
.OrderBy(t => t)
.ToList();
// The user is back (or the enrollment declared itself complete): from here on the time
// is not the update's. A Failed declaration ends attribution altogether.
var userBack = anchors
.Where(a => a.Bucket == TimeAttributionSegments.UserEsp || a.Bucket == TimeAttributionSegments.DesktopHandoff)
.Select(a => a.Ts)
.Concat(events.Where(e => e.EventType == Constants.EventTypes.HelloWizardStarted).Select(e => e.Timestamp))
.OrderBy(t => t)
.ToList();
var attributionEnd = anchors.FirstOrDefault(a => a.Bucket == null)?.Ts;
// esp_exiting fires for the Device→Account handoff AND the final exit and is never
// replayed; each window picks its own (a WhiteGlove part-1 exit never bounds part 2).
var espExits = events
.Where(e => e.EventType == Constants.EventTypes.EspExiting)
.Select(e => e.Timestamp)
.OrderBy(t => t)
.ToList();
foreach (var window in windows)
{
var begin = FindUpdateBegin(window, pages, evidence, servicing, espExits, userBack, attributionEnd);
if (!begin.HasValue) continue;
var bound = window.End;
if (attributionEnd.HasValue && attributionEnd.Value < bound) bound = attributionEnd.Value;
foreach (var t in userBack)
{
if (t <= begin.Value) continue;
if (t < bound) bound = t;
break;
}
if (bound <= begin.Value) continue;
var update = new OsUpdate { Start = begin.Value, End = begin.Value };
foreach (var page in pages)
{
if (page.At > update.End && page.At < bound) update.End = page.At;
}
var servicedKbs = new List<string>();
var installedKbs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var step in servicing)
{
if (step.At < begin.Value || step.At >= bound) continue;
if (step.At > update.End) update.End = step.At;
var kb = ExtractKb(step.Package);
if (kb == null) continue;
if (!servicedKbs.Contains(kb)) servicedKbs.Add(kb);
if (step.ReachedInstalled) installedKbs.Add(kb);
}
// A KB counts as installed only once its package reached "Installed"; a failed
// update can leave packages staged.
update.Kbs = servicedKbs.Where(installedKbs.Contains).ToList();
update.NotInstalledKbs = servicedKbs.Where(kb => !installedKbs.Contains(kb)).ToList();
update.Outcome = ResolveOutcome(pages, servicing, begin.Value, bound);
foreach (var (gapStart, gapEnd) in rebootGaps)
{
if (gapStart < begin.Value || gapStart >= bound) continue;
update.RebootCount++;
var restartEnd = gapEnd < bound ? gapEnd : bound;
if (restartEnd > update.End) update.End = restartEnd;
}
// The update's restart signed the user out; until the user is back the time is
// waiting, not updating.
if (update.RebootCount > 0 && bound > update.End)
update.AwaitingSignInEnd = bound;
result.Add(update);
}
return result;
}
private static DateTime? FindUpdateBegin(
Window window, List<PageRecord> pages, List<DateTime> evidence, List<ServicingStep> servicing,
List<DateTime> espExits, List<DateTime> userBack, DateTime? attributionEnd)
{
// An update after the user is back runs beside the enrollment, not instead of it.
bool UserBackBy(DateTime t) => userBack.Any(u => u > window.Start && u <= t);
bool InWindow(DateTime t) => t >= window.Start && t < window.End;
// This window's device-ESP exit before the given moment, if one was observed.
DateTime? EspExitBefore(DateTime before)
{
foreach (var exit in espExits)
{
if (exit < window.Start) continue;
if (exit >= window.End || exit >= before) break;
return exit;
}
return null;
}
DateTime? firstPageStart = null;
foreach (var page in pages)
{
if (!page.IsStartOf(UpdatePage) || !InWindow(page.At)) continue;
firstPageStart = page.At;
break;
}
// Update-activity names before the first observed page start: the agent started after
// the visit they belong to (a first run reads no Shell-Core history), so its start is
// missing. Evaluated first — their visit lies before any observed one.
DateTime? firstOrphanName = null;
foreach (var page in pages)
{
if (firstPageStart.HasValue && page.At >= firstPageStart.Value) break;
if (!InWindow(page.At) || !IsUpdateActivityName(page.Name) || UserBackBy(page.At)) continue;
firstOrphanName = page.At;
break;
}
if (firstOrphanName.HasValue)
{
var firstName = firstOrphanName.Value;
DateTime? firstStep = null;
var exitBefore = EspExitBefore(firstName);
if (exitBefore.HasValue)
{
// A known exit bounds it as in v4: the first servicing step after the exit.
foreach (var step in servicing)
{
if (step.At < exitBefore.Value || step.At < window.Start) continue;
if (step.At < firstName) firstStep = step.At;
break;
}
}
else
{
// No exit observed: the earliest step of a package this update also serviced at or
// after its first name — the servicing watcher backfills an hour, so the update's
// early staging is there; a package finished before (the OOBE's own) is not.
var limit = window.End;
if (attributionEnd.HasValue && attributionEnd.Value < limit) limit = attributionEnd.Value;
foreach (var u in userBack)
{
if (u <= firstName) continue;
if (u < limit) limit = u;
break;
}
var members = new HashSet<string>(
servicing.Where(s => s.At >= firstName && s.At < limit && !string.IsNullOrEmpty(s.Package)).Select(s => s.Package!),
StringComparer.OrdinalIgnoreCase);
foreach (var step in servicing)
{
if (step.At < window.Start) continue;
if (step.At >= firstName) break;
if (string.IsNullOrEmpty(step.Package) || !members.Contains(step.Package!)) continue;
firstStep = step.At;
break;
}
}
return firstStep.HasValue && firstStep.Value < firstName ? firstStep.Value : firstName;
}
var pageStartSeen = false;
for (var i = 0; i < pages.Count; i++)
{
var start = pages[i];
if (!start.IsStartOf(UpdatePage) || start.At < window.Start || start.At >= window.End) continue;
pageStartSeen = true;
if (UserBackBy(start.At)) continue;
// The visit ends with its stop, the restart page or the next visit — whichever comes
// first; an update page that restarts the device writes no stop of its own.
var visitEnd = window.End;
for (var j = i + 1; j < pages.Count; j++)
{
var next = pages[j];
if (next.IsStopOf(UpdatePage) || next.IsStartOf(UpdateRestartPage) || next.IsStartOf(UpdatePage))
{
visitEnd = next.At;
break;
}
}
foreach (var t in userBack)
{
if (t <= start.At) continue;
if (t < visitEnd) visitEnd = t;
break;
}
if (evidence.Any(t => t >= start.At && t <= visitEnd)) return start.At;
}
// A page that was seen and did not update (it cancelled, or found nothing) is the answer;
// a servicing step beside it is some other update.
if (pageStartSeen) return null;
// Fallback: neither the page start nor an update name was observed (the agent started
// after the page and saw only CBS). The first servicing step after this window's
// device-ESP exit before the user is back — without the download before it.
DateTime? deviceEspExit = null;
foreach (var exit in espExits)
{
if (exit < window.Start) continue;
if (exit >= window.End || UserBackBy(exit)) break;
deviceEspExit = exit;
break;
}
if (!deviceEspExit.HasValue) return null;
foreach (var step in servicing)
{
if (step.At < deviceEspExit.Value || step.At < window.Start || step.At >= window.End) continue;
return UserBackBy(step.At) ? null : step.At;
}
return null;
}
/// <summary>
/// How the update in [begin, bound) ended: the latest outcome evidence decides — a package
/// reaching "Installed" or the page's install success (installed), an explicit download or
/// install failure, a failed servicing step or a page stop with result "fail" (failed), or the
/// page's Skip button (skipped). A later attempt overrides an earlier one (a failure retried
/// and installed is installed; a failure followed by Skip is skipped). No evidence: unknown.
/// </summary>
private static string ResolveOutcome(List<PageRecord> pages, List<ServicingStep> servicing, DateTime begin, DateTime bound)
{
DateTime? latest = null;
var outcome = OsUpdateOutcomes.Unknown;
void Consider(DateTime at, string kind)
{
if (latest.HasValue && at < latest.Value) return;
latest = at;
outcome = kind;
}
foreach (var page in pages)
{
if (page.At < begin || page.At >= bound) continue;
if (HasMarker(page.Name, UpdateSucceededMarkers)) Consider(page.At, OsUpdateOutcomes.Installed);
else if (HasMarker(page.Name, UpdateFailedMarkers)) Consider(page.At, OsUpdateOutcomes.Failed);
else if (HasMarker(page.Name, UpdateSkippedMarkers)) Consider(page.At, OsUpdateOutcomes.Skipped);
else if (page.IsStopOf(UpdatePage) && string.Equals(page.Result, "fail", StringComparison.OrdinalIgnoreCase))
Consider(page.At, OsUpdateOutcomes.Failed);
}
foreach (var step in servicing)
{
if (step.At < begin || step.At >= bound) continue;
if (step.ReachedInstalled) Consider(step.At, OsUpdateOutcomes.Installed);
else if (step.Failed) Consider(step.At, OsUpdateOutcomes.Failed);
}
return outcome;
}
private static string? ExtractKb(string? package)
{
if (string.IsNullOrEmpty(package)) return null;
try
{
var match = KbPattern.Match(package);
return match.Success ? "KB" + match.Groups[1].Value : null;
}
catch (RegexMatchTimeoutException)
{
return null;
}
}
private static string? DataString(Dictionary<string, object> data, string key) =>
data.TryGetValue(key, out var value) ? value?.ToString() : null;
/// <summary>
/// Cuts the update and the wait for the user out of the phase spans and adds them as their
/// own spans, clipped to the windows — a hole inside the interval is filled, the WhiteGlove
/// pause never. Sessions without an update keep their phase partition unchanged.
/// </summary>
private static List<TimeAttributionSpan> ApplyOsUpdates(
List<TimeAttributionSpan> spans, List<Window> windows, List<OsUpdate> updates)
{
if (updates.Count == 0) return spans;
var overrides = new List<(DateTime Start, DateTime End, string Key)>();
foreach (var update in updates)
{
overrides.Add((update.Start, update.End, TimeAttributionSegments.OsUpdate));
if (update.AwaitingSignInEnd.HasValue)
overrides.Add((update.End, update.AwaitingSignInEnd.Value, TimeAttributionSegments.AwaitingSignIn));
}
var result = new List<TimeAttributionSpan>();
foreach (var span in spans)
{
var pieces = new List<(DateTime Start, DateTime End)> { (span.StartUtc, span.EndUtc) };
foreach (var cut in overrides)
{
var next = new List<(DateTime Start, DateTime End)>();
foreach (var (start, end) in pieces)
{
if (cut.End <= start || cut.Start >= end)
{
next.Add((start, end));
continue;
}
if (cut.Start > start) next.Add((start, cut.Start));
if (cut.End < end) next.Add((cut.End, end));
}
pieces = next;
}
foreach (var (start, end) in pieces)
AddSpan(result, span.SegmentKey, start, end);
}
foreach (var (start, end, key) in overrides)
{
foreach (var window in windows)
{
var clippedStart = start > window.Start ? start : window.Start;
var clippedEnd = end < window.End ? end : window.End;
AddSpan(result, key, clippedStart, clippedEnd);
}
}
return result.OrderBy(sp => sp.StartUtc).ToList();
}
// ── sleep spans ─────────────────────────────────────────────────────────
/// <summary>
@@ -256,6 +256,9 @@ public static class EndpointAccessPolicyCatalog
// ProgressPortalFunction; regression history c4dabeee).
new("GET", "progress/sessions/lookup", EndpointPolicy.AuthenticatedUserWithRole, TenantScoping.QueryParam),
new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser, TenantScoping.QueryParam),
// The caller's own (JWT) tenant only, and no session data: a "looks unused" flag plus the signup
// date, so the portal can tell a member without a role whom to ask for access.
new("GET", "progress/tenant-status", EndpointPolicy.AuthenticatedUser, TenantScoping.Jwt),
// AuthenticatedUserWithRole (not plain AuthenticatedUser): the activating user is still
// roleless during first-touch signup, so the tier must admit non-members — but the address is
// tenant-shared state feeding the welcome/farewell mails, so the function re-gates on the
@@ -36,16 +36,16 @@ public enum TableMemberState
/// <see cref="MemberRoleInfo.CanManageBootstrapTokens"/>, or to revoke access) by adding an
/// explicit table row for that user.
///
/// Only Admin and Operator are mappable from claims; Viewer and the platform-wide GlobalAdmin
/// role are intentionally never derived from claims.
/// The tenant roles Admin, Operator and Viewer are mappable from claims; the platform-wide
/// GlobalAdmin role is intentionally never derived from claims.
/// </summary>
public static class EntraAppRoleResolver
{
/// <summary>
/// Maps raw Entra app-role values to a <see cref="MemberRoleInfo"/>. Admin outranks Operator
/// when both are present. Returns null when no mappable role exists.
/// Maps raw Entra app-role values to a <see cref="MemberRoleInfo"/>. The highest role wins when
/// several are present: Admin, then Operator, then Viewer. Returns null when no mappable role exists.
/// A claim-derived Admin implicitly gets <see cref="MemberRoleInfo.CanManageBootstrapTokens"/>;
/// a claim Operator does not (granular bootstrap permission is a table-only override).
/// a claim Operator or Viewer does not (granular bootstrap permission is a table-only override).
/// </summary>
public static MemberRoleInfo? MapClaimRole(IEnumerable<string>? appRoles)
{
@@ -63,6 +63,9 @@ public static class EntraAppRoleResolver
if (roles.Any(r => string.Equals(r, Constants.TenantRoles.Operator, StringComparison.OrdinalIgnoreCase)))
return new MemberRoleInfo { Role = Constants.TenantRoles.Operator, CanManageBootstrapTokens = false };
if (roles.Any(r => string.Equals(r, Constants.TenantRoles.Viewer, StringComparison.OrdinalIgnoreCase)))
return new MemberRoleInfo { Role = Constants.TenantRoles.Viewer, CanManageBootstrapTokens = false };
return null;
}
@@ -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;
@@ -405,7 +406,7 @@ namespace AutopilotMonitor.Functions.Services
/// contributes no per-app intervals); excluded and missing ones are counted, never
/// silently dropped (rule 7).
/// Rows are written even below the ≥20 UI gate — the UI needs the n (rule 4). Segment
/// stats always carry the five canonical segments + unattributed (a session without a
/// stats always carry the seven canonical segments + unattributed (a session without a
/// span of a segment contributes 0 — the honest "per enrollment of this class" answer).
/// Per-app rows gate at ≥5 sessions, order by median interval, cap 20.
/// </summary>
@@ -468,6 +469,8 @@ namespace AutopilotMonitor.Functions.Services
{
TimeAttributionSegments.DevicePrep,
TimeAttributionSegments.EspApps,
TimeAttributionSegments.OsUpdate,
TimeAttributionSegments.AwaitingSignIn,
TimeAttributionSegments.IdentityHello,
TimeAttributionSegments.UserEsp,
TimeAttributionSegments.DesktopHandoff,
@@ -793,7 +796,7 @@ namespace AutopilotMonitor.Functions.Services
var containerClient = new BlobContainerClient(new Uri(containerSasUrl));
var generatedAtUtc = DateTime.UtcNow;
var versionedFileName = $"platform-stats.{generatedAtUtc:yyyy-MM-dd}.json";
var versionedFileName = PlatformStatsVersionedFileName(generatedAtUtc);
var versionedPayload = new
{
@@ -829,19 +832,34 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Name of the versioned stats file of one publish. The file is served immutable for a year
/// (<see cref="PlatformStatsVersionedCacheControl"/>), so a name must never be written twice:
/// maintenance publishes every 2 hours, and the per-day name of the once-a-night schedule was
/// rewritten up to 12 times a day under that header — caches kept the first copy of the day.
/// Browsers revalidate only the manifest, which names the current file.
/// </summary>
internal static string PlatformStatsVersionedFileName(DateTime generatedAtUtc) =>
$"platform-stats.{generatedAtUtc.ToString("yyyy-MM-dd'T'HHmmss'Z'", CultureInfo.InvariantCulture)}.json";
private async Task UploadJsonBlobAsync(BlobContainerClient containerClient, string blobName, object payload, string cacheControl)
{
var blobClient = containerClient.GetBlobClient(blobName);
var json = JsonConvert.SerializeObject(payload);
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
await blobClient.UploadAsync(stream, overwrite: true);
await blobClient.SetHttpHeadersAsync(new BlobHttpHeaders
// Headers and tier travel with the upload, which still overwrites (the manifest relies on
// it). Set in follow-up calls, the blob was briefly served without Cache-Control and
// content type, and stayed that way when a follow-up call failed.
await blobClient.UploadAsync(stream, new BlobUploadOptions
{
ContentType = "application/json; charset=utf-8",
CacheControl = cacheControl
HttpHeaders = new BlobHttpHeaders
{
ContentType = "application/json; charset=utf-8",
CacheControl = cacheControl
},
AccessTier = AccessTier.Hot
});
await blobClient.SetAccessTierAsync(AccessTier.Hot);
}
/// <summary>
@@ -60,6 +60,7 @@ namespace AutopilotMonitor.Functions.Services
private const string PlatformStatsAliasFileName = "platform-stats.json";
private const string PlatformStatsAliasCacheControl = "public, max-age=300, stale-while-revalidate=86400";
// Holds only because every publish writes a new name (PlatformStatsVersionedFileName).
private const string PlatformStatsVersionedCacheControl = "public, max-age=31536000, immutable";
public MaintenanceService(
@@ -1144,6 +1144,31 @@ namespace AutopilotMonitor.Functions.Services
return new RawPage<SessionSummary>(page.Sessions, page.HasMore ? page.NextCursor : null);
}
/// <summary>
/// Key-only existence probe: the tenant's SessionsIndex partition first, then the primary
/// table (the page read's pre-migration fallback). No catch — a failed read must never
/// answer "no sessions".
/// </summary>
public async Task<bool> HasAnySessionStrictAsync(string tenantId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
var filter = $"PartitionKey eq '{tenantId}'";
var keysOnly = new[] { "PartitionKey", "RowKey" };
foreach (var table in new[] { Constants.TableNames.SessionsIndex, Constants.TableNames.Sessions })
{
var rows = _tableServiceClient.GetTableClient(table)
.QueryAsync<TableEntity>(filter: filter, maxPerPage: 1, select: keysOnly)
.GetAsyncEnumerator();
await using (rows)
{
if (await rows.MoveNextAsync())
return true;
}
}
return false;
}
/// <summary>
/// Cross-tenant paged variant of <see cref="GetAllSessionsAsync"/>.
/// <paramref name="tenantIdFilter"/> optionally restricts to one tenant.
@@ -3515,18 +3540,15 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Event sources whose rows never anchor the session. The system timeline watcher
/// backfills environment observations (clock steps, sleep episodes) from the Windows
/// event log with their ORIGINAL timestamps — up to 24h before the agent started.
/// They belong on the timeline, but they are not enrollment activity: anchoring on
/// them silently stretches StartedAt/DurationSeconds toward pre-enrollment OOBE idle
/// time (field case d0c5b672: a pre-agent clock correction pulled StartedAt ~14min
/// before the first real activity; the 2h backward-shift guard only caps, not
/// prevents, this). User decision 2026-08-21: the session anchor stays on real
/// enrollment activity.
/// Whether an event row may anchor the session (<see cref="Constants.EventSources.SessionAnchorIneligible"/>).
/// Backfilled environment observations carry their ORIGINAL event time, up to 24h before
/// the agent started; anchoring on them silently stretches StartedAt/DurationSeconds into
/// pre-enrollment time (field case d0c5b672: a pre-agent clock correction, ~14 min; the 2h
/// backward-shift guard only caps this). User decision 2026-08-21: the session anchor
/// stays on real enrollment activity.
/// </summary>
internal static bool IsSessionAnchorEligible(TableEntity eventRow)
=> eventRow.GetString("Source") != "SystemTimelineWatcher";
=> Constants.EventSources.IsSessionAnchorEligible(eventRow.GetString("Source"));
/// <summary>
/// Returns the earliest ANCHOR-ELIGIBLE event timestamp persisted for a session, if
@@ -123,6 +123,7 @@ namespace AutopilotMonitor.Functions.Services
["SegmentsJson"] = JsonSerializer.Serialize(b.Segments),
["RebootSpansJson"] = JsonSerializer.Serialize(b.RebootSpans),
["SleepSpansJson"] = JsonSerializer.Serialize(b.SleepSpans),
["OsUpdatesJson"] = JsonSerializer.Serialize(b.OsUpdates),
["BlockingAppsJson"] = JsonSerializer.Serialize(b.BlockingApps),
["ComputedAt"] = DateTime.UtcNow,
};
@@ -151,6 +152,7 @@ namespace AutopilotMonitor.Functions.Services
Segments = DeserializeJsonColumn<TimeAttributionSpan>(entity.GetString("SegmentsJson")),
RebootSpans = DeserializeJsonColumn<RebootSpan>(entity.GetString("RebootSpansJson")),
SleepSpans = DeserializeJsonColumn<SleepSpan>(entity.GetString("SleepSpansJson")),
OsUpdates = DeserializeJsonColumn<OsUpdateSpan>(entity.GetString("OsUpdatesJson")),
BlockingApps = DeserializeJsonColumn<BlockingAppInterval>(entity.GetString("BlockingAppsJson")),
};
}
@@ -12,7 +12,7 @@ import { describe, it, expect } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadDocsCorpus, parseFrontmatter, docUrl, slugify, docSections } from '../docs-corpus.js';
import { loadDocsCorpus, parseFrontmatter, docUrl, slugify, docSections, summaryFolderSlugs } from '../docs-corpus.js';
async function withBundle<T>(
files: Record<string, string>,
@@ -60,9 +60,50 @@ describe('parseFrontmatter', () => {
});
});
const SUMMARY = [
'# Table of contents',
'',
'* [Introduction](README.md)',
'',
'## Concepts',
'',
'* [Roles & Permissions](concepts/roles-and-permissions.md)',
'',
'## Trust & Security',
'',
'* [Security & Privacy FAQ](trust/security-faq.md)',
'',
'## Troubleshooting & Support',
'',
'* [FAQ](troubleshooting/faq.md)',
'* [How to Purchase](troubleshooting-and-support/how-to-purchase/README.md)',
' * [Cleverbridge](troubleshooting-and-support/how-to-purchase/cleverbridge.md)',
].join('\r\n');
describe('summaryFolderSlugs', () => {
it('maps only the folders GitBook publishes under a different group slug', () => {
expect(summaryFolderSlugs(SUMMARY)).toEqual(
new Map([
['trust', 'trust-and-security'],
['troubleshooting', 'troubleshooting-and-support'],
]),
);
});
});
describe('docUrl', () => {
it('maps a page path to its published URL', () => {
expect(docUrl('trust/security-faq.md')).toBe('https://docs.autopilotmonitor.com/trust/security-faq');
expect(docUrl('concepts/roles-and-permissions.md')).toBe('https://docs.autopilotmonitor.com/concepts/roles-and-permissions');
});
it('publishes a page under its SUMMARY group slug, not its folder', () => {
// The folder URL of a page created after the group rename is a 404 (measured 2026-10-02).
const slugs = summaryFolderSlugs(SUMMARY);
expect(docUrl('troubleshooting/faq.md', slugs)).toBe('https://docs.autopilotmonitor.com/troubleshooting-and-support/faq');
expect(docUrl('trust/security-faq.md', slugs)).toBe('https://docs.autopilotmonitor.com/trust-and-security/security-faq');
expect(docUrl('troubleshooting-and-support/how-to-purchase/README.md', slugs)).toBe(
'https://docs.autopilotmonitor.com/troubleshooting-and-support/how-to-purchase',
);
});
it('collapses README.md to its folder', () => {
@@ -84,6 +125,22 @@ describe('slugify', () => {
describe('loadDocsCorpus', () => {
const page = (fm: string, body: string) => `---\n${fm}\n---\n\n${body}`;
it('cites the URL GitBook publishes, read from SUMMARY.md', async () => {
const chunks = await withBundle(
{
'SUMMARY.md': SUMMARY,
'troubleshooting/faq.md': page('type: Troubleshooting', ['# FAQ', '', '## Who is my admin?', '', 'Ask the team that runs Intune or device enrollment in your organization.'].join('\n')),
},
(root) => loadDocsCorpus(root),
);
expect(chunks.map((c) => c.metadata.url)).toContain(
'https://docs.autopilotmonitor.com/troubleshooting-and-support/faq#who-is-my-admin',
);
// The section filter keeps the folder taxonomy.
expect(chunks[0].metadata.section).toBe('troubleshooting');
});
it('splits on h2/h3 headings and carries a breadcrumb', async () => {
const chunks = await withBundle(
{
@@ -0,0 +1,95 @@
/**
* D-316: a platform run whose script_failed came from another script's end block is corrected by a later
* script_output_reconciliation (outcome "foreign", IME result Success). get_session_summary must not count the
* stored script_failed as an error, must not rank it as one and must not attach the foreign exit code's catalog
* text; the correction itself stays a Warning key event.
*
* Pure unit tests: fetch is stubbed and the registered tool handler is invoked directly.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { McpServer } from '@modelcontextprotocol/server';
import { registerTools } from '../tools.js';
import { runWithCaller } from '../client.js';
import { foreignSuccessRunIds, isCorrectedForeignFailure } from '../tools/shared.js';
type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }>;
const SESSION = 'e259c121-1234-4abc-9def-0123456789ab';
const GA = { token: 'ga', isGlobalAdmin: true };
const extra = { signal: new AbortController().signal };
function summaryHandler(): ToolHandler {
const server = new McpServer({ name: 'test', version: '0.0.0' });
registerTools(server, undefined, undefined, undefined, true, true, false);
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
return registry['get_session_summary'].handler;
}
function stubBackend(events: unknown[]): void {
const body = {
success: true,
session: { sessionId: SESSION, tenantId: 't', status: 'Succeeded', startedAt: '2026-10-03T20:49:00Z' },
events,
results: [],
annotations: [],
count: events.length,
};
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) }) as unknown as Response));
}
afterEach(() => vi.unstubAllGlobals());
const foreignFailure = {
timestamp: '2026-10-03T20:59:04Z',
eventType: 'script_failed',
severity: 'Error',
source: 'ImeLogTracker',
message: 'Platform script d94468af: Success (exit: 1)',
data: { scriptType: 'platform', result: 'Success', exitCode: '1', runId: 'run-1' },
};
const correction = (data: Record<string, unknown>) => ({
timestamp: '2026-10-03T20:59:08Z',
eventType: 'script_output_reconciliation',
severity: 'Warning',
source: 'RegistryScriptResult',
message: "Platform script d94468af: output corrected from IME's saved result",
data: { scriptType: 'platform', outcome: 'foreign', result: 'Success', runId: 'run-1', ...data },
});
async function summary(events: unknown[]): Promise<Record<string, any>> {
stubBackend(events);
const result = await runWithCaller(GA, () => summaryHandler()({ sessionId: SESSION }, extra));
return JSON.parse((result.content ?? []).map((c) => c.text ?? '').join(''));
}
describe('get_session_summary with a corrected platform run', () => {
it('does not count the foreign script_failed as an error and drops its exit-code text', async () => {
const body = await summary([foreignFailure, correction({})]);
expect(body.stats.errorCount).toBe(0);
expect(body.stats.warningCount).toBe(1);
const failed = body.keyEvents.find((e: { eventType: string }) => e.eventType === 'script_failed');
expect(failed.correctedByImeSavedResult).toBe(true);
expect(failed.errorCode).toBeUndefined();
expect(body.keyEvents.some((e: { eventType: string }) => e.eventType === 'script_output_reconciliation')).toBe(true);
});
it('keeps counting a failure IME itself reported, and an uncorrected one', async () => {
expect((await summary([foreignFailure, correction({ result: 'Failed' })])).stats.errorCount).toBe(1);
expect((await summary([foreignFailure, correction({ outcome: 'repaired' })])).stats.errorCount).toBe(1);
expect((await summary([foreignFailure, correction({ runId: 'another-run' })])).stats.errorCount).toBe(1);
expect((await summary([foreignFailure])).stats.errorCount).toBe(1);
});
});
describe('foreignSuccessRunIds / isCorrectedForeignFailure', () => {
it('names only foreign corrections whose IME result is Success, and only script_failed rows', () => {
const runs = foreignSuccessRunIds([correction({}), correction({ runId: 'r2', result: 'Failed' }), correction({ runId: 'r3', outcome: 'repaired' })]);
expect([...runs]).toEqual(['run-1']);
expect(isCorrectedForeignFailure('script_failed', { runId: 'run-1' }, runs)).toBe(true);
expect(isCorrectedForeignFailure('script_completed', { runId: 'run-1' }, runs)).toBe(false);
expect(isCorrectedForeignFailure('script_failed', { runId: 'r2' }, runs)).toBe(false);
expect(isCorrectedForeignFailure('script_failed', undefined, runs)).toBe(false);
});
});
@@ -188,21 +188,49 @@ export function slugify(text: string): string {
.slice(0, 60);
}
/**
* Top-level folder → URL segment, read from SUMMARY.md. GitBook publishes a page under
* the slug of the `## Group` heading that lists it, not under its folder:
* `troubleshooting/faq.md` below "Troubleshooting & Support" is
* `/troubleshooting-and-support/faq`. The folder URL of a page that predates a group
* rename still redirects; for a newer page it is a 404. Only folders whose slug differs
* are returned.
*/
export function summaryFolderSlugs(summary: string): Map<string, string> {
const slugs = new Map<string, string>();
let group: string | undefined;
for (const line of normalizeNewlines(summary).split('\n')) {
const heading = /^##\s+(.+)$/.exec(line.trim());
if (heading) {
group = heading[1].toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
continue;
}
const link = /\]\(([^)#\s]+\.md)\)/.exec(line);
if (!group || !link || !link[1].includes('/')) continue;
const folder = link[1].split('/')[0];
if (folder !== group) slugs.set(folder, group);
}
return slugs;
}
/**
* Public GitBook URL for a bundle-relative path, or undefined when the file is
* not published as a page.
* not published as a page. `folderSlugs` (from {@link summaryFolderSlugs}) maps a
* top-level folder to the segment GitBook publishes it under.
*
* `index.md` is an OKF reserved file that the bundle deliberately keeps out of
* SUMMARY.md, so GitBook never renders it — emitting a link would hand callers a
* 404. Its content is still indexed (it is the documentation map); it just has no
* citable URL.
*/
export function docUrl(relPath: string): string | undefined {
export function docUrl(relPath: string, folderSlugs: ReadonlyMap<string, string> = new Map()): string | undefined {
const posix = relPath.split(sep).join('/');
if (posix === 'index.md') return undefined;
const withoutExt = posix.replace(/\.md$/, '');
const path = withoutExt === 'README' ? '' : withoutExt.replace(/\/README$/, '');
return path ? `${DOCS_BASE_URL}/${path}` : DOCS_BASE_URL;
const [folder, ...rest] = path.split('/');
const published = rest.length > 0 && folderSlugs.has(folder) ? [folderSlugs.get(folder), ...rest].join('/') : path;
return published ? `${DOCS_BASE_URL}/${published}` : DOCS_BASE_URL;
}
// ── Sectioning ───────────────────────────────────────────────
@@ -407,6 +435,8 @@ export async function loadDocsCorpus(docsRoot: string): Promise<SearchDocument[]
const files = (await walkMarkdown(docsRoot)).sort();
const docs: SearchDocument[] = [];
const usedIds = new Set<string>();
// No SUMMARY.md (a partial local bundle): folder URLs, as GitBook would serve an unrenamed group.
const folderSlugs = summaryFolderSlugs(await readFile(join(docsRoot, 'SUMMARY.md'), 'utf-8').catch(() => ''));
for (const relPath of files) {
let raw: string;
@@ -430,7 +460,7 @@ export async function loadDocsCorpus(docsRoot: string): Promise<SearchDocument[]
// Top-level directory doubles as the bundle's section taxonomy
// (getting-started, concepts, portal-guide, …). Root files are 'general'.
const section = posixPath.includes('/') ? posixPath.split('/')[0] : 'general';
const url = docUrl(relPath);
const url = docUrl(relPath, folderSlugs);
// Drop the h1 line itself — it is carried in every chunk's header instead.
const withoutH1 = body.replace(/^#\s+.+$/m, '');
@@ -3054,6 +3054,22 @@ export interface OpsEventListResponse {
nextLink?: string;
}
/** One OOBE quality update behind an OsUpdate span (D-310) — the interval and what the evidence names. Annotation like RebootSpan; the partition itself is in Segments. */
export interface OsUpdateSpan {
startUtc: string;
endUtc: string;
/** In-window seconds of the interval. */
seconds: number;
/** KB numbers of the packages that reached "Installed" in the interval ("KB5129195"), in order of first appearance; empty when none did or no package named one. */
kbs: string[];
/** KB numbers of the packages Windows serviced in the interval without reaching "Installed" (for example only staged before a failed download). */
notInstalledKbs: string[];
/** How the update ended — one of OsUpdateOutcomes; the latest outcome evidence in the interval decides. */
outcome: string;
/** Restarts that began inside the interval. */
rebootCount: number;
}
/** Body of PATCH global/config: { "fields": { <fieldName>: <value>, ... } } with at least one field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take the field's own JSON type. */
export interface PatchAdminConfigurationRequest {
fields?: Record<string, unknown>;
@@ -3231,6 +3247,15 @@ export interface ProgressLookupSessionResponse {
session?: SessionSummary;
}
/** Response of GET /api/progress/tenant-status — whether the caller's own organization looks unused, so the Progress Portal can tell a member without a role whom to ask for access. */
export interface ProgressTenantStatusResponse {
success: boolean;
/** True when the organization holds no session and signed up more than 14 days ago. */
unused: boolean;
/** When the organization signed up; null (key omitted on the wire) when unknown. */
signedUpAt?: string;
}
/** Error body of POST /api/global/raw/logs when the telemetry store rejected or failed the query (400 for a caller-side KQL error, 502 for store/grant failures): the envelope prefix plus the store's own error code, HTTP status and — capped — its full response, so nothing the CLI would print is lost. hint tells the caller how to fix the query. */
export interface QueryBackendLogsErrorResponse {
error: string;
@@ -4334,6 +4359,8 @@ export interface SessionTimeBreakdown {
/** Total observed in-window sleep seconds. Cross-cutting annotation like RebootSeconds — the wall clock keeps the pause; this discloses it. */
sleepSeconds: number;
sleepSpans: SleepSpan[];
/** The OOBE quality updates behind the OsUpdate spans (at most one per observation window). */
osUpdates: OsUpdateSpan[];
/** Install intervals of ESP-blocking apps (positive-evidence join against the latest esp_config_detected lists), top 20 by duration. BlockingAppCount carries the uncapped count of matched blocking apps (including those without a measurable interval, e.g. unobserved start). */
blockingApps: BlockingAppInterval[];
/** Uncapped count of apps matched against the blocking set (see BlockingApps). */
@@ -4904,7 +4931,7 @@ export interface TenantConfiguration {
unrestrictedModeEnabled: boolean;
/** When effective, agent guardrails are relaxed: all registry paths, WMI queries, and commands are allowed via GatherRules. File paths and diagnostics paths are allowed except C:\Users. Default: false. Can only be toggled by tenant admins when UnrestrictedModeEnabled is true. EFFECTIVE only while the tenant's edition is Pro (read-time re-gate, fail-closed on trial expiry/downgrade) — see TenantEntitlementService.IsUnrestrictedModeActive. */
unrestrictedMode: boolean;
/** When enabled, tenant member roles (Admin / Operator) may also be granted via Entra ID app-role assignments on the application's Enterprise App (the "roles" claim in the user's token), in addition to the TenantAdmins table. Resolution is table-first: an explicit TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator CanManageBootstrapTokens). Only Admin and Operator are claim-mappable; the platform-wide GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in. Backend-only setting: not delivered to the agent (no ConfigVersion impact). */
/** When enabled, tenant member roles (Admin / Operator / Viewer) may also be granted via Entra ID app-role assignments on the application's Enterprise App (the "roles" claim in the user's token), in addition to the TenantAdmins table. Resolution is table-first: an explicit TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator CanManageBootstrapTokens). The platform-wide GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in. Backend-only setting: not delivered to the agent (no ConfigVersion impact). */
entraAppRolesEnabled: boolean;
/** JSON-serialized list of tenant-specific additional log paths/wildcards to include in the diagnostics ZIP package (additive to global paths). Each entry: { "path": "...", "description": "...", "isBuiltIn": false } */
diagnosticsLogPathsJson: string;
@@ -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',
@@ -2057,9 +2057,11 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
title: 'Enrollment Time Attribution',
description:
'Answer "where did the enrollment time go?". Two modes: pass sessionId for one terminal session\'s ' +
'time breakdown — wall-clock partition into device_prep / esp_apps / identity_hello / user_esp / ' +
'desktop_handoff spans plus an EXPLICIT unattributed remainder (sums are exact against the session\'s ' +
'authoritative duration; the WhiteGlove pause is excluded by design), the ESP-blocking app install ' +
'time breakdown — wall-clock partition into device_prep / esp_apps / os_update / awaiting_sign_in / ' +
'identity_hello / user_esp / desktop_handoff spans plus an EXPLICIT unattributed remainder (sums are exact ' +
'against the session\'s authoritative duration; the WhiteGlove pause is excluded by design; os_update = the ' +
'OOBE quality update, its outcome, installed and not installed KBs and restarts in osUpdates; ' +
'awaiting_sign_in = after that update\'s restart until the user is back), the ESP-blocking app install ' +
'intervals with critical-path occupancy (an app\'s seconds = its ACTIVE install time summed across all ' +
'IME passes/attempts — deliberately broader than get_app_install_metrics\' final-attempt duration, ' +
'because a failed attempt and an evaluation pass still occupied the critical path), reboot outage ' +
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';
import { ApiError, apiFetch, buildQuery, jsonBody, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, pageSizeForCall, pickGlobalOrTenantPath, scanUntilMatch, scanWithTimeoutFallback } from '../client.js';
import { withToolTelemetry } from '../telemetry.js';
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, foreignSuccessRunIds, isCorrectedForeignFailure, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
import { toolError } from './error-handler.js';
import { lookupErrorCode } from '../error-code-catalog.js';
import { withEventTypeNote, assertKnownDevicePropertyKeys } from '../resource-catalog.js';
@@ -534,6 +534,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
// Observation coverage: what the agent could and could not see, folded from the
// health/lifecycle events that the triage timeline below ranks low or drops.
const coverage = buildSessionCoverage(s, allEvents);
// Platform runs IME's saved result proved to carry another script's exit code (D-316).
const foreignRuns = foreignSuccessRunIds(allEvents);
let errorCount = 0;
let warningCount = 0;
@@ -545,7 +547,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
const sev = String(e.severity ?? '');
// A compliant health-script detection mis-stamped script_failed/Error is benign —
// exclude it so a green session's errorCount isn't inflated by routine compliance reports.
const benign = isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data);
const benign = isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data)
|| isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns);
if (!benign && (sev === 'Error' || sev === 'Critical')) errorCount++;
if (sev === 'Warning') warningCount++;
const et = String(e.eventType ?? '');
@@ -571,6 +574,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
const relevanceScore = (e: Partial<EnrollmentEvent>): number => {
// Benign compliant detection mis-stamped Error → rank as info-level, not top.
if (isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data)) return 10;
// A failure that was another script's exit code is not a failure of this one.
if (isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns)) return 10;
const sev = SEVERITY_RANK[String(e.severity ?? '')] ?? -1;
if (sev >= 3) return 100; // Error/Critical
if (PHASE_EVENT_TYPES.has(String(e.eventType ?? ''))) return 60;
@@ -597,7 +602,10 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
phase: phaseName(e.phase, s.enrollmentType),
message: e.message,
source: e.source,
...keyEventErrorCode(e.data, e.source),
// The exit code on a corrected run was another script's: no catalog text for it.
...(isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns)
? { correctedByImeSavedResult: true }
: keyEventErrorCode(e.data, e.source)),
}));
let analysis = null;
@@ -75,6 +75,8 @@ export function leanFieldSelection(
export const SUMMARY_EVENT_FIELDS =
'eventType,severity,source,phase,timestamp,message,sequence,' +
'data.scriptType,data.script_type,data.scriptPart,data.script_part,data.result,' +
// Platform-run corrections from IME's saved result (foreignSuccessRunIds below).
'data.runId,data.outcome,' +
'data.rejectedSourceTimestamp,data.rejected_source_timestamp,' +
'data.errorCode,data.exitCode,data.hresult,data.hresultFromWin32,data.hresultSymbol,' +
'data.errorCodeInfo,data.exitCodeInfo,data.hresultInfo,data.hresultFromWin32Info,' +
@@ -125,6 +127,38 @@ export function isBenignHealthDetectionReport(
return true;
}
/**
* Run ids of platform runs whose `script_failed` came from another script's end block while IME
* itself reported Success: `script_output_reconciliation` with outcome "foreign" and result Success
* (D-316 — the agent had attributed a foreign executor's exit code and output; IME's saved result
* proved it). The stored `script_failed` stays as written; read sides treat it like the benign
* detection reports above: no error count, info-level rank, no exit-code catalog text.
*/
export function foreignSuccessRunIds(
events: ReadonlyArray<{ eventType?: string | null; data?: Record<string, unknown> }>,
): Set<string> {
const runs = new Set<string>();
for (const e of events) {
if (e.eventType !== 'script_output_reconciliation' || !e.data) continue;
if (String(e.data.outcome ?? '') !== 'foreign') continue;
if (String(e.data.result ?? '').toLowerCase() !== 'success') continue;
const runId = e.data.runId;
if (typeof runId === 'string' && runId.length > 0) runs.add(runId);
}
return runs;
}
/** True for a `script_failed` whose run `foreignSuccessRunIds` names. */
export function isCorrectedForeignFailure(
eventType: string | undefined,
data: Record<string, unknown> | undefined,
foreignRuns: ReadonlySet<string>,
): boolean {
if (eventType !== 'script_failed' || !data || foreignRuns.size === 0) return false;
const runId = data.runId;
return typeof runId === 'string' && foreignRuns.has(runId);
}
/**
* Builds the role-aware description for a tenant-boundable tool's `tenantId` argument. MCP clients weigh
* the per-arg schema heavily, so a delegated (MSP) caller — for whom omitting tenantId is REJECTED, not
@@ -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/
@@ -651,7 +658,7 @@ namespace AutopilotMonitor.Shared
// update page (CXID OobeNDUP / RebootNDUP) and its ExpeditedUpdate_* event names, plus the
// registry state behind it (NDUP keys and both ESP "Install Windows quality updates"
// policy paths). Bounded per agent run.
public const string OobeUpdatePage = "oobe_update_page"; // Shell-Core 62404/62405 of an NDUP page, 62406/62407 update-page names — Data.cxhEvent+page+result+name(+value: boolean/integer/HRESULT only)
public const string OobeUpdatePage = "oobe_update_page"; // Shell-Core 62404/62405 of an NDUP page, 62406/62407 update-page names — Data.cxhEvent+page+result+name(+value: boolean/integer/HRESULT only); cxhEvent names_capped = the run's later names were not sent
public const string OobeUpdateState = "oobe_update_state"; // NDUP + ESP-policy registry values at agent start/stop and at the update page — Data.moment + one entry per key alias; only when values exist and changed
// Corroboration snapshots (gather rules, config-delivered). Secondary evidence that an
// update landed during enrollment even when the watcher's backfill window missed it.
@@ -913,6 +920,46 @@ 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>
/// The OOBE update page telemetry from the Shell-Core log (<c>oobe_update_page</c>,
/// <c>oobe_update_state</c>). The ESP and Hello signals read from the same log carry
/// the source "EspAndHelloTracker".
/// </summary>
public const string ShellCoreTracker = "ShellCoreTracker";
/// <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 and the update page 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,
ShellCoreTracker,
};
/// <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>
@@ -54,6 +54,13 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<RawPage<SessionSummary>> GetSessionsPageAsync(
string tenantId, int? days, int pageSize, string? continuation);
/// <summary>
/// True when <paramref name="tenantId"/> holds at least one session (SessionsIndex, with the
/// same primary-table fallback as <see cref="GetSessionsPageAsync"/>). Strict: storage failures
/// PROPAGATE — the page read answers an empty list on failure, which would read as "no sessions".
/// </summary>
Task<bool> HasAnySessionStrictAsync(string tenantId);
/// <summary>
/// Cross-tenant variant of <see cref="GetSessionsPageAsync"/> (Global Admin).
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant.
@@ -662,12 +662,12 @@ namespace AutopilotMonitor.Shared.Models
// ===== ENTRA APP ROLES (RBAC) =====
/// <summary>
/// When enabled, tenant member roles (Admin / Operator) may also be granted via Entra ID
/// When enabled, tenant member roles (Admin / Operator / Viewer) may also be granted via Entra ID
/// app-role assignments on the application's Enterprise App (the "roles" claim in the user's
/// token), in addition to the TenantAdmins table. Resolution is table-first: an explicit
/// TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator
/// CanManageBootstrapTokens). Only Admin and Operator are claim-mappable; the platform-wide
/// GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in.
/// CanManageBootstrapTokens). The platform-wide GlobalAdmin role is never derived from
/// claims. Off by default — per-tenant opt-in.
/// Backend-only setting: not delivered to the agent (no ConfigVersion impact).
/// </summary>
public bool EntraAppRolesEnabled { get; set; } = false;
@@ -17,6 +17,22 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>Device-scope ESP apps phase (EnrollmentPhase.AppsDevice spans).</summary>
public const string EspApps = "esp_apps";
/// <summary>
/// The OOBE quality update (D-310): from the start of the update page that worked on an
/// update — or, when the agent started after the page, from the update's first evidence —
/// to the last update evidence before the user is back: download, install, the restarts
/// and the servicing after them. Takes the time from the phase segment it overlaps; one
/// <see cref="OsUpdateSpan"/> per interval names the packages and the outcome.
/// </summary>
public const string OsUpdate = "os_update";
/// <summary>
/// After an OOBE update that restarted the device: from the update's end to the first
/// evidence of the user (desktop, Hello wizard, user ESP apps) — the restart signed the
/// user out, so this is the time until someone signs in again.
/// </summary>
public const string AwaitingSignIn = "awaiting_sign_in";
/// <summary>Identity / sign-in / Windows Hello (AccountSetup + FinalizingSetup spans).</summary>
public const string IdentityHello = "identity_hello";
@@ -168,6 +184,53 @@ namespace AutopilotMonitor.Shared.Models
public string Kind { get; set; } = string.Empty;
}
/// <summary>
/// One OOBE quality update behind an <see cref="TimeAttributionSegments.OsUpdate"/> span
/// (D-310) — the interval and what the evidence names. Annotation like
/// <see cref="RebootSpan"/>; the partition itself is in <see cref="SessionTimeBreakdown.Segments"/>.
/// </summary>
public class OsUpdateSpan
{
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
/// <summary>In-window seconds of the interval.</summary>
public int Seconds { get; set; }
/// <summary>KB numbers of the packages that reached "Installed" in the interval ("KB5129195"), in order of first appearance; empty when none did or no package named one.</summary>
public List<string> Kbs { get; set; } = new List<string>();
/// <summary>KB numbers of the packages Windows serviced in the interval without reaching "Installed" (for example only staged before a failed download).</summary>
public List<string> NotInstalledKbs { get; set; } = new List<string>();
/// <summary>How the update ended — one of <see cref="OsUpdateOutcomes"/>; the latest outcome evidence in the interval decides.</summary>
public string Outcome { get; set; } = OsUpdateOutcomes.Unknown;
/// <summary>Restarts that began inside the interval.</summary>
public int RebootCount { get; set; }
}
/// <summary>
/// Outcome of an OOBE quality update (<see cref="OsUpdateSpan.Outcome"/>), decided by the
/// latest outcome evidence in its interval. Exported to the portal via shared-manifests.json.
/// </summary>
public static class OsUpdateOutcomes
{
/// <summary>A package reached "Installed", or the update page reported the install as succeeded.</summary>
public const string Installed = "installed";
/// <summary>The update page or servicing reported the download or install as failed.</summary>
public const string Failed = "failed";
/// <summary>Someone selected Skip on the update page; the update was not installed.</summary>
public const string Skipped = "skipped";
/// <summary>No outcome evidence before the interval ended (for example the session ended first).</summary>
public const string Unknown = "unknown";
public static readonly string[] All = { Installed, Failed, Skipped, Unknown };
}
/// <summary>
/// Per-session enrollment time attribution (F1, insights spec) — an exact partition of the
/// session's authoritative wall clock into named segments plus an explicit unattributed
@@ -213,6 +276,9 @@ namespace AutopilotMonitor.Shared.Models
public List<SleepSpan> SleepSpans { get; set; } = new List<SleepSpan>();
/// <summary>The OOBE quality updates behind the <see cref="TimeAttributionSegments.OsUpdate"/> spans (at most one per observation window).</summary>
public List<OsUpdateSpan> OsUpdates { get; set; } = new List<OsUpdateSpan>();
/// <summary>
/// Install intervals of ESP-blocking apps (positive-evidence join against the latest
/// <c>esp_config_detected</c> lists), top 20 by duration. <see cref="BlockingAppCount"/>
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
@@ -30,4 +31,20 @@ namespace AutopilotMonitor.Shared.Models
public int Count { get; set; }
public IReadOnlyList<EnrollmentEvent> Events { get; set; } = default!;
}
/// <summary>
/// Response of GET /api/progress/tenant-status — whether the caller's own organization looks
/// unused, so the Progress Portal can tell a member without a role whom to ask for access.
/// </summary>
// Declaration order == wire order.
public class ProgressTenantStatusResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>True when the organization holds no session and signed up more than 14 days ago.</summary>
public bool Unused { get; set; }
/// <summary>When the organization signed up; null (key omitted on the wire) when unknown.</summary>
public DateTime? SignedUpAt { get; set; }
}
}
@@ -5,6 +5,8 @@ import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchOk, jsonBody } from "@/lib/apiClient";
import { landingTarget } from "@/lib/landingTarget";
import { consumePostLoginReturnUrl } from "@/lib/postLoginReturn";
import { DOCS_URL } from "@/utils/config";
import { BrandMark } from "../../components/BrandMark";
import type { SaveNotificationEmailRequest } from "@/utils/wire-types.generated";
@@ -62,6 +64,7 @@ export default function ActivationPage() {
// are asked for an address; an already-activated tenant opening /activation later is passed
// straight through, exactly as before.
const arrivedPendingRef = useRef<boolean | null>(null);
const leftRef = useRef(false);
// Redirect out of the activation page — but not while the address question is still open.
useEffect(() => {
@@ -85,7 +88,11 @@ export default function ActivationPage() {
// which is why almost no tenant ever got a welcome mail. Hold until the user has answered.
if (arrivedPendingRef.current && !addressDecided) return;
router.push(user.isTenantAdmin || user.isGlobalAdmin ? "/dashboard" : "/progress");
// Leave once: the deep link is consumed here, so a re-run before unmount must not route again
// without it. The landing gate kept it during activation for exactly this hand-off.
if (leftRef.current) return;
leftRef.current = true;
router.push(landingTarget(user, consumePostLoginReturnUrl()));
}, [demo, isAuthenticated, isLoading, user, isActivationPending, addressDecided, router]);
// Poll while activation is pending: the auto-approve worker activates the tenant
@@ -4,8 +4,10 @@ import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { AddMemberForm } from "@/components/members/AddMemberForm";
import { MemberList } from "@/components/members/MemberList";
import type { AddTenantAdminRequest, TenantAdminRow, UpdateMemberPermissionsRequest } from "@/utils/wire-types.generated";
import { isApplicationKey, looksLikeGuid, principalLabel, type MemberKind } from "@/utils/principalKeys";
import { looksLikeGuid, type MemberKind } from "@/utils/principalKeys";
// Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows).
type TenantAdmin = TenantAdminRow;
@@ -35,16 +37,11 @@ export function TenantAdminSection({
const [addingAdmin, setAddingAdmin] = useState(false);
const [removingAdmin, setRemovingAdmin] = useState<string | null>(null);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
const [currentAdminPage, setCurrentAdminPage] = useState(0);
const [adminSearchQuery, setAdminSearchQuery] = useState("");
const adminsPerPage = 5;
// Fetch tenant admins when tenantId changes
const fetchTenantAdmins = async (tid: string) => {
try {
setLoadingAdmins(true);
setCurrentAdminPage(0); // Reset to first page when loading new tenant
const data = await fetchJson<TenantAdmin[]>(api.tenants.admins(tid), getAccessToken);
setTenantAdmins(data);
} catch (err) {
@@ -98,11 +95,7 @@ export function TenantAdminSection({
};
const handleRemoveTenantAdmin = async (adminUpn: string) => {
if (!canMutate) return; // read-only Global Reader
if (!confirm(`Are you sure you want to remove ${adminUpn} as an admin?`)) {
return;
}
if (!canMutate) return; // read-only Global Reader (the row asks for confirmation first)
try {
setRemovingAdmin(adminUpn);
setError(null);
@@ -176,261 +169,38 @@ export function TenantAdminSection({
}
};
// Admin Pagination with Search
const filteredAdmins = tenantAdmins.filter(admin =>
principalLabel(admin.upn).toLowerCase().includes(adminSearchQuery.toLowerCase())
);
const totalAdminPages = Math.ceil(filteredAdmins.length / adminsPerPage);
const startAdminIndex = currentAdminPage * adminsPerPage;
const endAdminIndex = startAdminIndex + adminsPerPage;
const paginatedAdmins = filteredAdmins.slice(startAdminIndex, endAdminIndex);
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">
<p className="font-semibold text-purple-900 mb-2">Admin Users Management</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>
{/* Current Admins List */}
<div className="mb-3">
<p className="text-sm text-purple-800 font-medium mb-2">
Current Admins ({tenantAdmins.length}):
</p>
<AddMemberForm
value={newAdminEmail}
onValueChange={setNewAdminEmail}
role={newMemberRole}
onRoleChange={setNewMemberRole}
kind={newMemberKind}
onKindChange={setNewMemberKind}
adding={addingAdmin}
onAdd={handleAddTenantAdmin}
disabled={!canMutate}
stacked
/>
{/* Search Field */}
<div className="mb-3">
<div className="relative">
<input
type="text"
name="admin-search-modal"
value={adminSearchQuery}
onChange={(e) => {
setAdminSearchQuery(e.target.value);
setCurrentAdminPage(0);
}}
placeholder="Search by email..."
autoComplete="off"
className="w-full px-3 py-2 pl-9 pr-9 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
/>
<svg className="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{adminSearchQuery && (
<button
onClick={() => {
setAdminSearchQuery("");
setCurrentAdminPage(0);
}}
className="absolute right-2.5 top-2.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear search"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{loadingAdmins ? (
<div className="text-sm text-purple-700">Loading admins...</div>
) : tenantAdmins.length === 0 ? (
<div className="text-sm text-purple-700 italic">No admins configured yet</div>
) : filteredAdmins.length === 0 ? (
<div className="text-sm text-purple-700 italic p-3 text-center bg-purple-50 rounded-lg">
No admins match your search
</div>
) : (
<>
<div className="space-y-2">
{paginatedAdmins.map((admin) => {
const isApplication = isApplicationKey(admin.upn);
const effectiveRole = isApplication ? "Viewer" : (admin.role ?? "Admin");
return (
<div
key={admin.upn}
className={`p-2 bg-white border rounded text-sm ${
admin.isEnabled ? 'border-purple-200' : 'border-gray-300 bg-gray-50'
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<div className="font-medium text-gray-900 truncate">{principalLabel(admin.upn)}</div>
{isApplication && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700" title="Service principal — automation with its own token, or an app whose users connect through it; read-only">
App
</span>
)}
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
effectiveRole === "Admin"
? "bg-purple-100 text-purple-800"
: effectiveRole === "Operator"
? "bg-blue-100 text-blue-800"
: "bg-gray-100 text-gray-700"
}`}>
{effectiveRole}
</span>
{!admin.isEnabled && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-700">
Disabled
</span>
)}
</div>
<div className="text-xs text-gray-500">
Added {new Date(admin.addedDate).toLocaleDateString()} by {admin.addedBy}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={effectiveRole}
onChange={(e) => handleUpdatePermissions(admin.upn, e.target.value, admin.canManageBootstrapTokens)}
disabled={!canMutate || isApplication || togglingAdmin === admin.upn}
title={isApplication ? "A service principal is always read-only (Viewer)" : undefined}
className="px-2 py-1 text-xs border border-gray-300 rounded bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-purple-500 disabled:opacity-50"
>
<option value="Admin">Admin</option>
<option value="Operator">Operator</option>
<option value="Viewer">Viewer</option>
</select>
<button
onClick={() => handleToggleTenantAdmin(admin.upn, admin.isEnabled)}
disabled={!canMutate || togglingAdmin === admin.upn}
className={`px-2 py-1 text-xs rounded hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed ${
admin.isEnabled
? 'bg-yellow-600 text-white hover:bg-yellow-700'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{togglingAdmin === admin.upn
? "..."
: admin.isEnabled ? "Disable" : "Enable"}
</button>
<button
onClick={() => handleRemoveTenantAdmin(admin.upn)}
disabled={!canMutate || removingAdmin === admin.upn}
className="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{removingAdmin === admin.upn ? "..." : "Remove"}
</button>
</div>
</div>
{/* Bootstrap token permission toggle for Operators */}
{effectiveRole === "Operator" && (
<div className="mt-2 pt-2 border-t border-gray-200">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={admin.canManageBootstrapTokens}
onChange={(e) => handleUpdatePermissions(admin.upn, "Operator", e.target.checked)}
disabled={!canMutate || togglingAdmin === admin.upn}
className="h-4 w-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500 disabled:opacity-50"
/>
<span className="text-sm text-gray-700">Can manage bootstrap tokens</span>
</label>
</div>
)}
</div>
);
})}
</div>
{/* Pagination */}
{totalAdminPages > 1 && (
<div className="flex items-center justify-between pt-3 mt-3 border-t border-purple-200">
<button
onClick={() => setCurrentAdminPage(p => Math.max(0, p - 1))}
disabled={currentAdminPage === 0}
className="px-3 py-1 text-xs font-medium text-purple-700 bg-white border border-purple-300 rounded hover:bg-purple-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-xs text-purple-700">
Page {currentAdminPage + 1} of {totalAdminPages} ({filteredAdmins.length} admin{filteredAdmins.length !== 1 ? 's' : ''})
</span>
<button
onClick={() => setCurrentAdminPage(p => Math.min(totalAdminPages - 1, p + 1))}
disabled={currentAdminPage >= totalAdminPages - 1}
className="px-3 py-1 text-xs font-medium text-purple-700 bg-white border border-purple-300 rounded hover:bg-purple-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
{/* Add New Admin */}
<div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<p className="text-sm text-purple-800 font-medium">Add New Admin:</p>
<div className="inline-flex rounded border border-purple-300 overflow-hidden text-xs" role="group" aria-label="Member type">
{([["user", "User"], ["application", "Service principal"]] as const).map(([kind, label]) => (
<button
key={kind}
type="button"
onClick={() => { setNewMemberKind(kind); setNewAdminEmail(""); }}
className={`px-2 py-1 ${newMemberKind === kind ? "bg-purple-600 text-white" : "bg-white text-purple-700 hover:bg-purple-50"}`}
>
{label}
</button>
))}
</div>
</div>
{addingApplication && (
<p className="text-xs text-purple-700 mb-2">
An application in this tenant, always read-only (Viewer). Automation calling with an app-only token
(for example through a federated credential) needs the{" "}
<span className="font-mono">access_as_application</span> permission. An app that signs users in and
sends their tokens needs the delegated{" "}
<span className="font-mono">access_as_user</span> permission, and its users can only read. Either is
granted by admin consent in the tenant.
</p>
)}
<div className="flex space-x-2">
<input
type={addingApplication ? "text" : "email"}
name="modal-new-admin-email"
id="modal-add-admin-email-input"
value={newAdminEmail}
onChange={(e) => setNewAdminEmail(e.target.value)}
placeholder={addingApplication ? "Application (client) ID, e.g. 00000000-0000-0000-0000-000000000000" : "admin@tenant.com"}
autoComplete="off"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTenantAdmin();
}
}}
/>
<select
value={addingApplication ? "Viewer" : newMemberRole}
onChange={(e) => setNewMemberRole(e.target.value)}
disabled={addingApplication}
title={addingApplication ? "A service principal is always read-only (Viewer)" : undefined}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors disabled:opacity-50"
>
<option value="Admin">Admin</option>
<option value="Operator">Operator</option>
<option value="Viewer">Viewer</option>
</select>
<button
onClick={() => handleAddTenantAdmin()}
disabled={!canMutate || addingAdmin || !newMemberInputValid}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
{addingAdmin ? "Adding..." : "Add"}
</button>
</div>
</div>
</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>
);
}
@@ -1255,7 +1255,7 @@ function TenantManagementSectionInner({
<span className="text-sm font-medium text-gray-700">Enable Entra App Roles</span>
</label>
<p className="text-xs text-gray-400 mt-1 ml-6">
Allow Admin/Operator roles to be granted via Entra app-role assignments on the Enterprise App (the token&apos;s roles claim), in addition to the member table. The member table always wins.
Allow Admin/Operator/Viewer roles to be granted via Entra app-role assignments on the Enterprise App (the token&apos;s roles claim), in addition to the member table. The member table always wins.
</p>
</div>
@@ -88,6 +88,10 @@ function formatByKind(kind: RowKind | undefined, value: unknown): string {
// ── Row and section primitives ───────────────────────────────────────────────
// Heavier rule between grouped rows. It sits on the cell: the section tbody's divide utility overrides
// a row's border width and color, while the collapsed table border keeps the wider cell edge.
const GROUP_SEPARATOR = 'border-t-2 border-gray-200 dark:border-gray-600';
interface ConfigRowProps {
label: string;
display: string;
@@ -185,8 +189,8 @@ function ChannelRows({ config }: { config: TenantConfiguration }) {
return (
<>
{channels.map((ch, i) => (
<tr key={ch.id ?? i} className={i > 0 ? 'border-t-2 border-gray-200 dark:border-gray-600' : ''}>
<td colSpan={2} className="p-0">
<tr key={ch.id ?? i}>
<td colSpan={2} className={`p-0 ${i > 0 ? GROUP_SEPARATOR : ''}`}>
<table className="w-full">
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
<ConfigRow label="Channel" display={`${ch.name || ch.id}${ch.enabled === false ? ' (disabled)' : ''}`} />
@@ -214,8 +218,8 @@ function SelfHostedAiClientRows({ list }: { list: McpClientRegistrationListRespo
<ConfigRow label="Self-hosted AI (platform switch)" display={list.enabled ? 'On' : 'Off'} />
{list.registrations.length === 0 && <ConfigRow label="Self-hosted AI Clients" display="—" />}
{list.registrations.map((r) => (
<tr key={r.registrationId} className="border-t-2 border-gray-200 dark:border-gray-600">
<td colSpan={2} className="p-0">
<tr key={r.registrationId}>
<td colSpan={2} className={`p-0 ${GROUP_SEPARATOR}`}>
<table className="w-full">
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
<ConfigRow label="Self-hosted AI Client" display={r.name} />
@@ -4,6 +4,8 @@
* and are marked noindex (events.guard.test.ts pins all four).
*/
import { SUPPORT_EMAIL } from "@/lib/supportContact";
export interface EventArt {
src: string;
width: number;
@@ -21,7 +23,7 @@ export interface FollowupEvent {
art?: { hero: EventArt; starPointer: EventArt };
}
export const WALKTHROUGH_EMAIL = "support@autopilotmonitor.com";
export const WALKTHROUGH_EMAIL = SUPPORT_EMAIL;
/** Robots metadata of every event page. Not a robots.txt disallow: crawlers would then never see it. */
export const EVENT_PAGE_ROBOTS = {
@@ -53,6 +53,8 @@ const MIN_SESSIONS_FOR_PANEL = 20;
const SEGMENT_META: { key: string; label: string; color: string }[] = [
{ key: "device_prep", label: "Device preparation", color: "bg-slate-400" },
{ key: "esp_apps", label: "Apps (ESP)", color: "bg-blue-500" },
{ key: "os_update", label: "Windows Update", color: "bg-cyan-500" },
{ key: "awaiting_sign_in", label: "Waiting for sign-in", color: "bg-sky-200" },
{ key: "identity_hello", label: "Identity & Hello", color: "bg-violet-500" },
{ key: "user_esp", label: "User ESP", color: "bg-indigo-400" },
{ key: "desktop_handoff", label: "Desktop handoff", color: "bg-emerald-500" },
@@ -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",
@@ -436,8 +436,8 @@ export default function GeographicPerformancePage() {
key={loc.locationKey}
id={`loc-${loc.locationKey.replace(/[^a-zA-Z0-9]/g, "-")}`}
className={`hover:bg-gray-50 transition-colors cursor-pointer ${
loc.isOutlier ? "border-l-4 border-l-red-400" : ""
} ${selectedLocation === loc.locationKey ? "bg-blue-50" : ""}`}
selectedLocation === loc.locationKey ? "bg-blue-50" : ""
}`}
onClick={() => {
const days = timeRange === "7d" ? 7 : timeRange === "30d" ? 30 : 90;
// Carry the selected tenant into the drill-in so a cross-tenant caller (GA override
@@ -447,7 +447,8 @@ export default function GeographicPerformancePage() {
}}
onMouseEnter={() => setSelectedLocation(loc.locationKey)}
>
<td className="px-4 py-3 text-sm text-gray-900">
{/* Outlier accent on the cell, not the row: the tbody's divide color overrides a row's border-color. */}
<td className={`px-4 py-3 text-sm text-gray-900 ${loc.isOutlier ? "border-l-4 border-l-red-400" : ""}`}>
<div className="flex items-center">
<span className="font-medium">{loc.locationKey}</span>
{loc.isOutlier && (
+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()} />;
}
@@ -0,0 +1,42 @@
import { DOCS_PATHS } from "@/lib/docsPaths";
import { SUPPORT_EMAIL } from "@/lib/supportContact";
import { DOCS_URL } from "@/utils/config";
import type { ProgressAccessHint as Hint } from "../hooks/progressAccessHint";
export const ACCESS_HINT_DOCS_URL = `${DOCS_URL}${DOCS_PATHS.progressPortalOnly}`;
const LINK_CLASS = "text-green-700 underline hover:text-green-800";
/** One muted line below the search telling a member without a role whom to ask for the full portal. */
export function ProgressAccessHint({ hint }: { hint: Hint }) {
const learnMore = (
<a href={ACCESS_HINT_DOCS_URL} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
Learn more
</a>
);
if (hint.kind === "member") {
return (
<p className="mt-10 text-center text-sm text-gray-500">
{"Looking for the full portal? Your organization already uses Autopilot Monitor. Ask your Autopilot Monitor admin for a role."}
{" "}
{learnMore}
</p>
);
}
const signedUp = hint.signedUpOn ? ` on ${hint.signedUpOn}` : "";
return (
<p className="mt-10 text-center text-sm text-gray-500">
{`Looking for the full portal? Your organization signed up for Autopilot Monitor${signedUp}, but no device has been monitored recently. Ask your Autopilot Monitor admin for a role, or write to`}
{" "}
<a href={`mailto:${SUPPORT_EMAIL}`} className={LINK_CLASS}>
{SUPPORT_EMAIL}
</a>
{" "}
{"if no one looks after it anymore."}
{" "}
{learnMore}
</p>
);
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ACCESS_HINT_DOCS_URL, ProgressAccessHint } from "../ProgressAccessHint";
import { SUPPORT_EMAIL } from "@/lib/supportContact";
import { DOCS_URL } from "@/utils/config";
const render = (hint: Parameters<typeof ProgressAccessHint>[0]["hint"]) =>
renderToStaticMarkup(createElement(ProgressAccessHint, { hint }));
describe("ProgressAccessHint", () => {
it("points a member to their admins and names no contact address", () => {
const html = render({ kind: "member" });
expect(html).toContain(
"Looking for the full portal? Your organization already uses Autopilot Monitor. Ask your Autopilot Monitor admin for a role.",
);
expect(html).not.toContain("mailto:");
});
it("offers the support mailbox for an unused organization, with spaces around the link", () => {
const html = render({ kind: "unused", signedUpOn: "12 March 2026" });
expect(html).toContain("signed up for Autopilot Monitor on 12 March 2026, but no device has been monitored recently.");
expect(html).toContain(`href="mailto:${SUPPORT_EMAIL}"`);
expect(html).toMatch(/or write to <a [^>]+>support@autopilotmonitor\.com<\/a> if no one looks after it anymore\./);
});
it("drops the date clause cleanly when the date is unknown", () => {
const html = render({ kind: "unused", signedUpOn: null });
expect(html).toContain("signed up for Autopilot Monitor, but no device");
});
it("links both variants to the troubleshooting article, after a space", () => {
// GitBook publishes troubleshooting/ under its group slug; the folder URL of a new page is a 404.
expect(ACCESS_HINT_DOCS_URL).toBe(`${DOCS_URL}/troubleshooting-and-support/progress-portal-only`);
// Plain substring match: a URL interpolated into a RegExp needs every dot escaped.
const link = `. <a href="${ACCESS_HINT_DOCS_URL}" target="_blank" rel="noopener noreferrer"`;
for (const hint of [{ kind: "member" as const }, { kind: "unused" as const, signedUpOn: null }]) {
const html = render(hint);
const start = html.indexOf(link);
expect(start).toBeGreaterThanOrEqual(0);
expect(html.slice(start, html.indexOf("</a>", start))).toMatch(/>Learn more$/);
}
});
});
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { formatSignupDate, progressAccessHint } from "../progressAccessHint";
describe("progressAccessHint", () => {
it("keeps the member hint while the status is pending or failed", () => {
expect(progressAccessHint(null)).toEqual({ kind: "member" });
});
it("keeps the member hint for an organization in use", () => {
expect(progressAccessHint({ success: true, unused: false, signedUpAt: "2026-03-12T08:30:00Z" })).toEqual({
kind: "member",
});
});
it("names the signup date of an unused organization", () => {
expect(progressAccessHint({ success: true, unused: true, signedUpAt: "2026-03-12T08:30:00Z" })).toEqual({
kind: "unused",
signedUpOn: "12 March 2026",
});
});
it("leaves the date out when the backend does not know it", () => {
expect(progressAccessHint({ success: true, unused: true })).toEqual({ kind: "unused", signedUpOn: null });
});
});
describe("formatSignupDate", () => {
it("reads the date in UTC, so a late-evening signup keeps its day", () => {
expect(formatSignupDate("2026-03-12T23:30:00Z")).toBe("12 March 2026");
});
it("returns null for an unparsable value", () => {
expect(formatSignupDate("not a date")).toBeNull();
});
});
@@ -0,0 +1,23 @@
import type { ProgressTenantStatusResponse } from "@/utils/wire-types.generated";
/** The note below the Progress Portal search for a member without a role. */
export type ProgressAccessHint =
| { kind: "member" }
| { kind: "unused"; signedUpOn: string | null };
/**
* A member without a role always gets the hint; the tenant status only sharpens it. A pending or
* failed status read (null) keeps the plain member hint, which needs no data.
*/
export function progressAccessHint(status: ProgressTenantStatusResponse | null): ProgressAccessHint {
if (!status?.unused) return { kind: "member" };
return { kind: "unused", signedUpOn: formatSignupDate(status.signedUpAt) };
}
/** "12 March 2026" (the docs' date style); null for a missing or unparsable date. */
export function formatSignupDate(iso: string | undefined): string | null {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" });
}
@@ -0,0 +1,34 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { fetchJson, type GetAccessToken } from "@/lib/apiClient";
import type { ProgressTenantStatusResponse } from "@/utils/wire-types.generated";
/**
* One read of the caller's tenant status for the access hint, only for members without a role
* (`enabled`). Any failure leaves null: the hint then stays the plain member note, so nothing here
* is worth a notification.
*/
export function useProgressTenantStatus(
enabled: boolean,
getAccessToken: GetAccessToken,
): ProgressTenantStatusResponse | null {
const [status, setStatus] = useState<ProgressTenantStatusResponse | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
const run = async () => {
const data = await fetchJson<ProgressTenantStatusResponse>(api.progress.tenantStatus(), getAccessToken)
.catch(() => null);
if (!cancelled) setStatus(data);
};
void run();
return () => {
cancelled = true;
};
}, [enabled, getAccessToken]);
return status;
}
@@ -10,8 +10,12 @@ import { useProgressEvents } from "./hooks/useProgressEvents";
import { useProgressSignalR } from "./hooks/useProgressSignalR";
import { useProgressDerivedData } from "./hooks/useProgressDerivedData";
import { DeviceStatusChips } from "./components/DeviceStatusChips";
import { ProgressAccessHint } from "./components/ProgressAccessHint";
import type { PresentationKind } from "./hooks/progressLayout";
import { progressAccessHint } from "./hooks/progressAccessHint";
import { useProgressTenantStatus } from "./hooks/useProgressTenantStatus";
import { useNotifications } from "../../contexts/NotificationContext";
import { hasTenantReadScope } from "../../lib/tenantScope";
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
@@ -49,10 +53,14 @@ function stepState(index: number, activeStepIndex: number, kind: PresentationKin
export default function ProgressPortalPage() {
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const { user, getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const signalR = useSignalR();
// Members without a role only ever see this page; tell them the full portal exists.
const showAccessHint = !!user && !hasTenantReadScope(user);
const tenantStatus = useProgressTenantStatus(showAccessHint, getAccessToken);
const search = useProgressSearch({
tenantId,
getAccessToken,
@@ -506,6 +514,9 @@ export default function ProgressPortalPage() {
</div>
</div>
)}
{/* Search view only: someone following their device never sees it under the progress. */}
{showAccessHint && !session && <ProgressAccessHint hint={progressAccessHint(tenantStatus)} />}
</div>
</div>
</ProtectedRoute>
@@ -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;
@@ -2,6 +2,7 @@
import React from "react";
import type { SessionTimeBreakdown } from "@/utils/wire-types.generated";
import { osUpdateDetail } from "./timeAttributionLogic";
/**
* F1 time attribution (insights spec §F1): renders the PRE-COMPUTED breakdown row of a
@@ -49,6 +50,8 @@ export type SessionTimeBreakdownDto = SessionTimeBreakdown;
const SEGMENT_META: { key: string; label: string; color: string }[] = [
{ key: "device_prep", label: "Device preparation", color: "bg-slate-400" },
{ key: "esp_apps", label: "Apps (ESP)", color: "bg-blue-500" },
{ key: "os_update", label: "Windows Update", color: "bg-cyan-500" },
{ key: "awaiting_sign_in", label: "Waiting for sign-in", color: "bg-sky-200" },
{ key: "identity_hello", label: "Identity & Hello", color: "bg-violet-500" },
{ key: "user_esp", label: "User ESP", color: "bg-indigo-400" },
{ key: "desktop_handoff", label: "Desktop handoff", color: "bg-emerald-500" },
@@ -83,8 +86,16 @@ export default function TimeAttributionLane({ breakdown }: { breakdown: SessionT
}
totals.set("unattributed", breakdown.unattributedSeconds);
// How the OOBE quality update ended, what it installed and how often it restarted — named on
// the Windows Update slice.
const updateDetail = osUpdateDetail(breakdown.osUpdates ?? []);
const parts = SEGMENT_META
.map(meta => ({ ...meta, seconds: totals.get(meta.key) ?? 0 }))
.map(meta => ({
...meta,
seconds: totals.get(meta.key) ?? 0,
detail: meta.key === "os_update" ? updateDetail : "",
}))
.filter(p => p.seconds > 0);
const flags = breakdown.qualityFlags
@@ -132,7 +143,7 @@ export default function TimeAttributionLane({ breakdown }: { breakdown: SessionT
key={p.key}
className={`${p.color} h-full`}
style={{ width: `${(p.seconds / wallClock) * 100}%` }}
title={`${p.label}: ${formatDuration(p.seconds)}`}
title={`${p.label}: ${formatDuration(p.seconds)}${p.detail ? ` · ${p.detail}` : ""}`}
/>
))}
</div>
@@ -143,7 +154,7 @@ export default function TimeAttributionLane({ breakdown }: { breakdown: SessionT
<span key={p.key} className="inline-flex items-center text-xs text-gray-600">
<span className={`w-2.5 h-2.5 rounded-sm mr-1.5 ${p.color}`} />
{p.label}
<span className="ml-1 text-gray-400">{formatDuration(p.seconds)}</span>
<span className="ml-1 text-gray-400">{p.detail ? `${formatDuration(p.seconds)} · ${p.detail}` : formatDuration(p.seconds)}</span>
</span>
))}
</div>
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
import { osUpdateDetail } from "../timeAttributionLogic";
describe("osUpdateDetail", () => {
it("names the outcome, the installed KBs and the restarts of an installed update", () => {
expect(osUpdateDetail([
{ outcome: "installed", kbs: ["KB5124007", "KB5129195", "KB5054156"], notInstalledKbs: [], rebootCount: 2 },
])).toBe("installed · KB5124007, KB5129195, KB5054156 · 2 restarts");
});
it("names a failed update's staged KB as not installed", () => {
// Field shape e7551b82: only the servicing stack was staged before the download failed.
expect(osUpdateDetail([
{ outcome: "failed", kbs: [], notInstalledKbs: ["KB5124007"], rebootCount: 1 },
])).toBe("failed · not installed: KB5124007 · 1 restart");
});
it("names a skipped update", () => {
expect(osUpdateDetail([{ outcome: "skipped", kbs: [], notInstalledKbs: [], rebootCount: 0 }])).toBe("skipped");
});
it("names no outcome for an unknown one or a row written before the outcome existed", () => {
expect(osUpdateDetail([{ outcome: "unknown", kbs: ["KB5129195"], rebootCount: 0 }])).toBe("KB5129195");
expect(osUpdateDetail([{ kbs: ["KB5129195"], rebootCount: 3 }])).toBe("KB5129195 · 3 restarts");
});
it("is empty without updates", () => {
expect(osUpdateDetail([])).toBe("");
});
it("labels every outcome the backend can send", () => {
// A new backend outcome must get its words here, not fall through as unnamed.
for (const outcome of SHARED_MANIFEST.osUpdateOutcomes) {
const detail = osUpdateDetail([{ outcome, kbs: [], notInstalledKbs: [], rebootCount: 0 }]);
expect(detail).toBe(outcome === "unknown" ? "" : outcome);
}
});
});
@@ -0,0 +1,37 @@
import type { OsUpdateSpan } from "@/utils/wire-types.generated";
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
/** How an OOBE quality update ended (backend `OsUpdateOutcomes`). */
export type OsUpdateOutcome = (typeof SHARED_MANIFEST.osUpdateOutcomes)[number];
/** The words the Windows Update slice names an outcome with; "unknown" names nothing. */
const OUTCOME_LABELS: Record<OsUpdateOutcome, string | null> = {
installed: "installed",
failed: "failed",
skipped: "skipped",
unknown: null,
};
/**
* What the OOBE quality update did, for the Windows Update slice: the outcome, the KBs that
* reached "Installed", the ones it serviced without installing, and its restarts — e.g.
* "failed · not installed: KB5124007 · 1 restart". The server computed every part; this only
* joins the lists. Rows written before the outcome existed carry none and name no outcome.
*/
export function osUpdateDetail(osUpdates: ReadonlyArray<Partial<OsUpdateSpan>>): string {
const outcomes = distinct(osUpdates.map(u => OUTCOME_LABELS[u.outcome as OsUpdateOutcome] ?? null));
const installed = distinct(osUpdates.flatMap(u => u.kbs ?? []));
const notInstalled = distinct(osUpdates.flatMap(u => u.notInstalledKbs ?? []));
const restarts = osUpdates.reduce((sum, u) => sum + (u.rebootCount ?? 0), 0);
return [
outcomes.join(", "),
installed.join(", "),
notInstalled.length > 0 ? `not installed: ${notInstalled.join(", ")}` : "",
restarts > 0 ? `${restarts} restart${restarts !== 1 ? "s" : ""}` : "",
].filter(Boolean).join(" · ");
}
function distinct(values: ReadonlyArray<string | null>): string[] {
return Array.from(new Set(values.filter((v): v is string => !!v)));
}
@@ -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]
);

Some files were not shown because too many files have changed in this diff Show More