mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8c84f41c1 | |||
| 12b51bbc24 | |||
| a369ff99cd | |||
| 7a131af74d | |||
| e750cfe9dd | |||
| 3629728d93 | |||
| e521fd5cf4 | |||
| 0ed1e6f542 | |||
| 522911c5a7 | |||
| 0bfe1e327b | |||
| 2cf7216be5 | |||
| 587adc5ddf |
@@ -68,7 +68,12 @@ jobs:
|
||||
- name: Build MSI
|
||||
shell: pwsh
|
||||
run: |
|
||||
wix build "$env:MSI_PROJECT_DIR/Package.wxs" -arch x64 -o $env:STABLE_MSI
|
||||
# wix resolves File/@Source relative to the working directory, not the wxs —
|
||||
# build from inside the project dir (same as the local build documented in the wxs).
|
||||
$out = Join-Path (Get-Location) $env:STABLE_MSI
|
||||
Push-Location $env:MSI_PROJECT_DIR
|
||||
wix build Package.wxs -arch x64 -o $out
|
||||
Pop-Location
|
||||
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
|
||||
$sha256 = (Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
type: concept
|
||||
title: WDP Scenario Gates & Completion Backstop
|
||||
description: How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation (WDP) sessions and how a WDP session resolves without any ESP signal.
|
||||
resource: agent
|
||||
tags: [wdp, device-preparation, decision-engine, collectors, completion]
|
||||
timestamp: 2026-08-18
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Windows Device Preparation (Autopilot v2, "WDP") has **no deployment profile and no ESP**.
|
||||
Before the afee7ae0 audit (2026-08-18) the agent ran the full Classic apparatus anyway:
|
||||
misleading `autopilot_profile_missing` warnings, a ZTD event-log query + HTTP probe,
|
||||
`esp_config_detected` "unknown/unknown" noise, an Autopilot-channel noise flood
|
||||
(EventIDs 100/417/418/1005, 900+ occurrences per session), a subtree registry watcher
|
||||
woken by every WDP progress write, and — worst — no completion path when Hello never
|
||||
resolved (park until the 360-min watchdog).
|
||||
|
||||
## Detection: deterministic vs. fallback
|
||||
|
||||
`EnrollmentRegistryDetector` classifies v2 through three rules. Only the first is
|
||||
**deterministic WDP**:
|
||||
|
||||
1. `AutopilotSettings\DevicePreparation\BootstrapperAgent` subkey with non-empty
|
||||
`ExecutionContext` → the WDP policy orchestration tree, present before the agent
|
||||
starts. Exposed as `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`
|
||||
and the `DevicePreparationExecutionContextRule` constant.
|
||||
2./3. `CloudAssignedDeviceRegistration == 2` / `CloudAssignedEspEnabled == 0` —
|
||||
profile-derived legacy signals. They imply a REAL Autopilot profile (just ESP-less),
|
||||
so every gate below deliberately ignores them: **gates require the deterministic
|
||||
marker and fail toward Classic behavior.**
|
||||
|
||||
## Agent-side gates (all keyed to the deterministic marker)
|
||||
|
||||
* `DeviceInfoCollector.CollectAutopilotProfile`: no `autopilot_profile` event, no
|
||||
`autopilot_profile_missing` warning, no ZTD event-log query, no ZTD HTTP probe.
|
||||
`enrollment_type_detected` still fires. (WDP devices DO carry an
|
||||
`AutopilotPolicyCache` with `ProfileAvailable=0` / error 807 `ZtdDeviceIsNotRegistered`
|
||||
— Windows runs the ZTD download regardless — so the probe fired for real before.)
|
||||
* `CollectEspConfigurationLocked`: `esp_config_detected` is emitted only when at least
|
||||
one FirstSync value or ESP tracking list exists (generic null-gate, mirrors the
|
||||
orchestrator bootstrap's signal gate) — fixes "unknown/unknown" for WDP AND plain
|
||||
Entra joins.
|
||||
* `DefaultComponentFactory`: `EspPolicyProviderStallHost` (60-s poll of the ESP-only
|
||||
`EnrollmentStatusTracking` key) is not created on WDP.
|
||||
* `EspAndHelloTracker.Start`: `ProvisioningStatusTracker` is not started on WDP — its
|
||||
subtree watcher sits on `AutopilotSettings`, exactly where WDP's BootstrapperAgent
|
||||
writes progress, so it woke on every write only to read three ESP category values
|
||||
that never exist there.
|
||||
* `ModernDeploymentTracker`: `DevicePreparationNoiseEventIds` {100, 417, 418, 1005}
|
||||
are suppressed entirely on WDP (before `FormatDescription()`); Critical (level 1)
|
||||
always passes; genuine WDP diagnostics (e.g. 408) keep flowing. The Autopilot error
|
||||
backfill (807/809/815/908 replay) is skipped — 807 is the EXPECTED state on WDP.
|
||||
The watcher itself stays on: WDP's provisioning stack logs into the same channels.
|
||||
* `StallProbeCollector`: skips the ESP category registry scan on WDP and unions the
|
||||
noise IDs into its harmless set; the channel scans remain (they carry genuine WDP
|
||||
diagnostics during a stall).
|
||||
* `EnrollmentTerminationHandler`: the `app_install_starved` terminal sweep is skipped
|
||||
on WDP — post-desktop installs are the DESIGNED order there (only DPP-policy apps
|
||||
run during provisioning); per-app data still travels via `app_tracking_summary` and
|
||||
final-status.json.
|
||||
|
||||
## DecisionCore: WDP completion without ESP facts
|
||||
|
||||
* **Seeding**: `EnrollmentFactsObserved` carries `enrollmentTypeDeterministic`;
|
||||
the deterministic marker seeds `DevicePreparation` at **High** confidence (same
|
||||
registry-determinism standard as the self-deploying OobeConfig bits), the fallback
|
||||
rules stay Medium. The three explicit downgrade guards from d0ab3eee remain as
|
||||
defence in depth.
|
||||
* **Arm D** (`ShouldTransitionToAwaitingHello`): on WDP the DAD-validated real-user
|
||||
desktop is a legitimate promotion basis — arms A–C are unsatisfiable by construction
|
||||
(no provisioning categories, no Shell-Core 62407 final exit). With a disabled Hello
|
||||
policy the fast-path now completes at desktop arrival.
|
||||
* **Prerequisites**: `completion_waiting` never lists
|
||||
`account_setup_provisioning_complete` on WDP (unsatisfiable; mirrors the
|
||||
`skipUserEsp` exemption).
|
||||
* **Backstop**: `DeadlineNames.DevicePrepCompletion` (30 min, replay-safe base) is
|
||||
armed by `HandleDesktopArrivedV1` when a WDP session parks desktop-first with Hello
|
||||
pending. On fire it mirrors the HelloSafety timeout: synthetic
|
||||
`HelloOutcome=Timeout` + completion through Finalizing. This closes the
|
||||
"no Hello → park until watchdog" hole; a timer surviving into a terminal stage is
|
||||
swallowed by the post-terminal dispatch guard.
|
||||
* **Tripwire**: `DecisionStepProcessor.IsParkedWithoutDeadline` has a WDP branch
|
||||
(dead-end zone = desktop arrived) so a regression in the backstop becomes visible
|
||||
as `session_parked_without_deadline`.
|
||||
|
||||
# Examples
|
||||
|
||||
Session afee7ae0 (the audit trigger): Succeeded in 9:32 min, but its timeline carried
|
||||
`autopilot_profile` (807), an `autopilot_profile_missing` WARN, `esp_config_detected`
|
||||
unknown/unknown, 30 `modern_deployment_log` rollup events and 10 `app_install_starved`
|
||||
entries — all structurally meaningless on WDP. After the gates, the same session shape
|
||||
emits none of these; completion rode Hello-skipped + desktop as before.
|
||||
|
||||
Deliberately NOT changed (open follow-ups, see the WDP audit topic memory): the ESP
|
||||
phase vocabulary (`esp_phase_changed`/stage names ride the Classic rails on WDP),
|
||||
subscription to the `BootstrapperAgentServiceLogProvider` event channel, and
|
||||
`registry_app_state` as the primary WDP app source.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs` — deterministic rule + helper
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs` — profile/ESP emission gates
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs` — `DevicePreparationNoiseEventIds`
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.DevicePreparation.cs` — backstop deadline
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs` — High/Medium seeding
|
||||
* Tests: `DevicePreparationEngineTests`, `ModernDeploymentTrackerDevicePrepTests`, `EnrollmentTerminationHandlerTests.StarvedApps_skipped_on_device_preparation`, `DeviceInfoCollectorEspRefreshTests.NoEspEvidenceAtAll_SuppressesTheEvent`
|
||||
@@ -58,6 +58,21 @@ timestamp runs backward are dropped (`ClockSkewDropped`).
|
||||
| Complete, `desktop_arrived` event | `desktop_handoff` |
|
||||
| Failed | attribution ends — tail stays unattributed |
|
||||
|
||||
## Quality flags (AttributionVersion 2)
|
||||
|
||||
Duration-critical flags (`ClockSkewDropped`, `PartialObservation`,
|
||||
`WhiteGloveAnchorsIncomplete`, `PriorEnrollmentResidue`) exclude a breakdown from fleet
|
||||
segment statistics with a disclosed count; the blocking-set flags (`BlockingSetUnknown`,
|
||||
`BlockingSetTruncated`) only limit per-app evidence and never gate fleet stats.
|
||||
|
||||
`PriorEnrollmentResidue` (v2) fires on `historic_ime_replay_detected`: the IME log on disk
|
||||
predates the enrollment, so the device was re-enrolled without a wipe — pre-installed apps
|
||||
complete as instant detections and pull the phase anchors ahead of the real ESP page
|
||||
(session f475e697: AccountSetup anchor 6.5 min early, 457 s misattributed to
|
||||
`identity_hello`). `registry_app_baseline` successes are deliberately NOT a trigger — they
|
||||
are by-design normal for Windows Device Preparation (DPP Batch-1 apps install before the
|
||||
agent exists) and would starve that class's aggregates.
|
||||
|
||||
## ESP-blocking apps (positive evidence only)
|
||||
|
||||
Per-app intervals use EVENT timestamps (first started/download event → LAST terminal
|
||||
@@ -117,7 +132,7 @@ gap before the first part-2 phase declaration is unattributed, not guessed.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 1).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 2).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.TimeAttribution.cs` — persistence.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs` — sweep + aggregation.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs` — golden fixtures pinning the invariant.
|
||||
|
||||
@@ -26,6 +26,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
|
||||
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
|
||||
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
|
||||
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
|
||||
|
||||
# Rules
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
## 2026-08-18
|
||||
|
||||
* **New**: `agent/wdp-scenario-gates.md` — WDP scenario gates (afee7ae0 audit): deterministic ExecutionContext marker as the only gate key (fail-toward-Classic), suppressed Autopilot/ESP-only emissions and hosts (autopilot_profile*/ZTD probe, esp_config unknown null-gate, EspPolicyProviderStallHost, ProvisioningStatusTracker, ModernDeployment noise IDs 100/417/418/1005 + error-backfill skip, starved sweep), and the WDP completion model (High-confidence v2 seed via `enrollmentTypeDeterministic`, desktop-based arm D, 30-min `device_prep_completion` backstop, parked-tripwire WDP branch).
|
||||
|
||||
* **Update**: `backend/time-attribution.md` — AttributionVersion 1 → 2: new duration-critical quality flag `PriorEnrollmentResidue` (fires on `historic_ime_replay_detected`; a re-enrollment without wipe pulls the phase anchors ahead of the real ESP page, so the segments are excluded from fleet stats; `registry_app_baseline` successes deliberately not a trigger — by-design normal for WDP). Added the quality-flags section.
|
||||
* **Update**: `agent/logs-and-persistence.md`, `agent/overview.md`, `agent/gather-rule-debug-log.md` — log files renamed to unified kebab-case: `agent.log` (date rotation dropped; size segments `agent-NNN.log`), `bootstrap-script.log` (was `bootstrap_agent.log`), `bootstrap-msi.log` (was `msi-bootstrap.log`), `crash-*.log`, `gather-rules-debug.log`, `ime-pattern-matches.log`. Old names still appear in archives from pre-rename agents.
|
||||
* **New**: `agent/registry-app-state-pillar.md` — the registry second pillar for IME app state (audit follow-up): snapshot-and-diff watcher on `HKLM\...\IntuneManagementExtension` (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), `registry_app_state` + `app_state_reconciliation` events, silent startup baseline, WDP reuse notes.
|
||||
|
||||
|
||||
+1
-1
@@ -629,7 +629,7 @@
|
||||
"category": "always",
|
||||
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). Complements IME-TOKEN-SUCCESS: an auth outage (WAM broken, conditional access, network) previously left every check-in silently doing nothing with no timeline evidence. errorCode can be empty when IME logged none; the agent dedups to one Warning per distinct code. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). Complements IME-TOKEN-SUCCESS: an auth outage (WAM broken, conditional access, network) previously left every check-in silently doing nothing with no timeline evidence. errorCode can be empty when IME logged none; the agent dedups to one Warning per distinct code. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
+35
@@ -61,6 +61,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.DeviceInfo
|
||||
return Assert.IsAssignableFrom<IReadOnlyList<string>>(data[key]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoEspEvidenceAtAll_SuppressesTheEvent()
|
||||
{
|
||||
// afee7ae0 (WDP / plain Entra join): FirstSync has no values and the ESP tracking
|
||||
// key does not exist — an event saying only "SkipUser=unknown, SkipDevice=unknown"
|
||||
// is noise and must not be emitted (mirrors the orchestrator bootstrap's null-gate).
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(T0);
|
||||
var collector = new DeviceInfoCollector(
|
||||
"session-nullgate", "tenant-1",
|
||||
new InformationalEventPost(ingress, clock), logger,
|
||||
signalIngress: null, clock: clock,
|
||||
startupGate: new StartupEventGate(tmp.Path, logger));
|
||||
|
||||
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
|
||||
_ => new EspFirstSyncSnapshot(
|
||||
skipUser: null, skipDevice: null,
|
||||
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => default))
|
||||
{
|
||||
collector.CollectEspConfiguration();
|
||||
}
|
||||
|
||||
Assert.Empty(EspConfigEvents(ingress));
|
||||
|
||||
// A later refresh where real evidence appeared must still emit normally.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1 }, Array.Empty<string>())))
|
||||
{
|
||||
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAppsDevice);
|
||||
}
|
||||
Assert.Single(EspConfigEvents(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refresh_reemits_when_lists_grew_and_stays_silent_when_unchanged()
|
||||
{
|
||||
|
||||
+36
@@ -56,6 +56,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
Assert.Equal(1, loaded.PeakDo.DownloadMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_then_load_roundtrips_terminal_metadata()
|
||||
{
|
||||
// Session bc803955 — CompletedAtUtc/VersionReached feed the summary dialog's
|
||||
// synthetic Office row (duration + version).
|
||||
using var tmp = new TempDirectory();
|
||||
var sut = new OfficeInstallStatePersistence(tmp.Path, NewLogger(tmp.Path));
|
||||
var completedAt = new DateTime(2026, 6, 8, 12, 3, 22, DateTimeKind.Utc);
|
||||
|
||||
sut.Save(new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateCompleted,
|
||||
CompletedAtUtc = completedAt,
|
||||
VersionReached = "16.0.20228.20190",
|
||||
});
|
||||
|
||||
var loaded = sut.Load();
|
||||
Assert.Equal(completedAt, loaded!.CompletedAtUtc);
|
||||
Assert.Equal("16.0.20228.20190", loaded.VersionReached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_tolerates_state_files_from_older_agents_without_terminal_metadata()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sut = new OfficeInstallStatePersistence(tmp.Path, NewLogger(tmp.Path));
|
||||
// Pre-terminal-metadata wire format (verbatim shape from a live 2.0.1404 session).
|
||||
File.WriteAllText(sut.StateFilePath,
|
||||
"{\"State\":\"Completed\",\"StartedAtUtc\":\"2026-08-18T07:56:16.8208141Z\",\"StartedTrigger\":\"registry\"}");
|
||||
|
||||
var loaded = sut.Load();
|
||||
Assert.Equal(OfficeInstallStateData.StateCompleted, loaded!.State);
|
||||
Assert.Null(loaded.CompletedAtUtc);
|
||||
Assert.Null(loaded.VersionReached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_overwrites_a_previous_state()
|
||||
{
|
||||
|
||||
+113
@@ -59,6 +59,71 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public void ClassifyEnforcementState_bands(int state, string expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyEnforcementState(state));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1000, "installed")]
|
||||
[InlineData(1999, "installed")]
|
||||
[InlineData(3000, "failed")]
|
||||
[InlineData(2000, "notApplicable")]
|
||||
[InlineData(2999, "notApplicable")]
|
||||
[InlineData(0, null)]
|
||||
[InlineData(999, null)]
|
||||
[InlineData(3001, null)]
|
||||
[InlineData(4000, null)]
|
||||
public void ClassifyStatusServiceStatus_bands(int status, string? expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyStatusServiceStatus(status));
|
||||
|
||||
[Fact]
|
||||
public void TerminalOutcome_prefers_enforcement_state_then_status_service()
|
||||
{
|
||||
// Enforcement state alone decides when terminal.
|
||||
Assert.Equal("success", ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 1000)));
|
||||
Assert.Equal("error", ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 5003)));
|
||||
// Non-terminal enforcement bands defer to the StatusService pillar.
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 2009)));
|
||||
Assert.Equal("error", ImeRegistryAppStateObserver.TerminalOutcome(
|
||||
Entry(enforcementState: 2009, statusServiceStatus: 3000)));
|
||||
Assert.Equal("success", ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 1000)));
|
||||
Assert.Equal("error", ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 3000)));
|
||||
// requirementsNotMet / notApplicable / empty entries are not judgeable.
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 3000)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 2500)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedFields_reports_each_field_and_ignores_espPhase_casing()
|
||||
{
|
||||
var prev = Entry(enforcementState: 2009, errorCode: null, exitCode: null,
|
||||
statusServiceStatus: null, espTracked: false, espPhase: "DevicePreparation");
|
||||
var next = Entry(enforcementState: 1000, errorCode: 0, exitCode: 0,
|
||||
statusServiceStatus: 1000, espTracked: true, espPhase: "devicepreparation");
|
||||
|
||||
var changed = ImeRegistryAppStateObserver.ChangedFields(prev, next);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "enforcementState", "errorCode", "exitCode", "statusServiceStatus", "espTracked" },
|
||||
changed);
|
||||
// Case-only espPhase difference is not a change; identity diffs to empty.
|
||||
Assert.DoesNotContain("espPhase", changed);
|
||||
Assert.Empty(ImeRegistryAppStateObserver.ChangedFields(next, next));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedFields_null_previous_reports_populated_fields_of_new_entry()
|
||||
{
|
||||
var fresh = Entry(enforcementState: 2000, espTracked: true, espPhase: "AccountSetup");
|
||||
|
||||
var changed = ImeRegistryAppStateObserver.ChangedFields(null, fresh);
|
||||
|
||||
Assert.Contains("enforcementState", changed);
|
||||
Assert.Contains("espTracked", changed);
|
||||
Assert.Contains("espPhase", changed);
|
||||
// Fields that are null on both sides are not "changes" for a brand-new entry.
|
||||
Assert.DoesNotContain("errorCode", changed);
|
||||
Assert.DoesNotContain("exitCode", changed);
|
||||
Assert.DoesNotContain("statusServiceStatus", changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffSnapshots_reports_only_changed_fields()
|
||||
{
|
||||
@@ -119,6 +184,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Equal("success", evt.Payload["enforcementClass"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonEmpty_baseline_emits_one_summary_with_counts()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = Snapshot((App1, 1000, 0), (App2, 5003, 101));
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
observer.Tick("periodic"); // summary is one-shot, not per tick
|
||||
}
|
||||
|
||||
var summary = Assert.Single(Events(sink, SharedEventTypes.RegistryAppBaseline));
|
||||
Assert.Equal("2", summary.Payload!["totalApps"]);
|
||||
Assert.Equal("1", summary.Payload["successCount"]);
|
||||
Assert.Equal("1", summary.Payload["errorCount"]);
|
||||
// The baseline stays silent on the diff rail.
|
||||
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_baseline_emits_no_summary()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = new ImeRegistrySnapshot();
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
}
|
||||
|
||||
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppBaseline));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unchanged_snapshot_emits_nothing()
|
||||
{
|
||||
@@ -230,6 +329,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
return (sink, clock, observer);
|
||||
}
|
||||
|
||||
private static AppRegistryEntry Entry(
|
||||
int? enforcementState = null, long? errorCode = null, int? exitCode = null,
|
||||
int? statusServiceStatus = null, bool espTracked = false, string? espPhase = null)
|
||||
{
|
||||
var entry = new AppRegistryEntry($"{ImeRegistrySnapshot.DeviceContext}|{App1}", ImeRegistrySnapshot.DeviceContext, App1);
|
||||
entry.EnforcementState = enforcementState;
|
||||
entry.ErrorCode = errorCode;
|
||||
entry.ExitCode = exitCode;
|
||||
entry.StatusServiceStatus = statusServiceStatus;
|
||||
entry.EspTracked = espTracked;
|
||||
entry.EspPhase = espPhase;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static ImeRegistrySnapshot Snapshot(params (string appId, int enforcementState, long? errorCode)[] apps)
|
||||
{
|
||||
var snapshot = new ImeRegistrySnapshot();
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Session afee7ae0 (2026-08-18) — Device Preparation structural-noise suppression.
|
||||
/// A nine-minute WDP session shipped 900+ occurrences of WIL error 1005 plus 400+
|
||||
/// duplicate-workload 417/418 records and "Autopilot policy not found" 100s — all
|
||||
/// structural on WDP (no deployment profile exists; the BootstrapperAgent's SLDM
|
||||
/// progress poll produces the 417/418 chatter). On WDP those IDs are suppressed
|
||||
/// entirely (<see cref="ModernDeploymentTracker.DevicePreparationNoiseEventIds"/>);
|
||||
/// genuine diagnostics (e.g. 408 provisioning warnings) and Critical records keep
|
||||
/// flowing, and Classic sessions keep the harmless-downgrade + rollup path.
|
||||
/// </summary>
|
||||
public sealed class ModernDeploymentTrackerDevicePrepTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 8, 18, 9, 10, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public ModernDeploymentTrackerDevicePrepTests()
|
||||
{
|
||||
_sink = new FakeSignalIngressSink();
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ModernDeploymentTracker BuildTracker(bool isDevicePreparation) =>
|
||||
new ModernDeploymentTracker(
|
||||
sessionId: "sess-wdp-noise",
|
||||
tenantId: "tenant-wdp-noise",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
logger: _logger,
|
||||
backfillEnabled: false,
|
||||
isDevicePreparation: isDevicePreparation);
|
||||
|
||||
private void Process(ModernDeploymentTracker tracker, int eventId, int level, string shortName, string channel)
|
||||
{
|
||||
tracker.ProcessEvent(
|
||||
eventId: eventId,
|
||||
level: level,
|
||||
levelDisplayName: level == 1 ? "Critical" : level == 2 ? "Error" : "Warning",
|
||||
providerName: "ModernDeployment-Diagnostics-Provider",
|
||||
timeCreatedUtc: At,
|
||||
formattedDescription: $"EventID {eventId} record.",
|
||||
shortName: shortName,
|
||||
channelName: channel,
|
||||
isBackfill: false);
|
||||
}
|
||||
|
||||
private List<FakeSignalIngressSink.PostedSignal> PostedModernDeployment() =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et.StartsWith("modern_deployment", StringComparison.Ordinal)).ToList();
|
||||
|
||||
[Theory]
|
||||
[InlineData(100, 3, "Autopilot")] // policy not found — no profile exists on WDP
|
||||
[InlineData(417, 3, "Autopilot")] // duplicate workload — SLDM poll chatter
|
||||
[InlineData(418, 3, "Autopilot")] // duplicate batch — SLDM poll chatter
|
||||
[InlineData(1005, 2, "ManagementService")] // WIL error storm
|
||||
public void WdpNoiseIds_AreFullySuppressed_OnDevicePreparation(int eventId, int level, string shortName)
|
||||
{
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
var channel = shortName == "Autopilot"
|
||||
? ModernDeploymentTracker.AutopilotChannel
|
||||
: ModernDeploymentTracker.ManagementChannel;
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
Process(tracker, eventId, level, shortName, channel);
|
||||
}
|
||||
|
||||
Assert.Empty(PostedModernDeployment());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenuineWdpWarning_408_KeepsFlowing_OnDevicePreparation()
|
||||
{
|
||||
// 408 "Autopilot Provisioning reported a warning" carries the real WDP session
|
||||
// context — it is NOT in the noise set and must keep flowing.
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
|
||||
Process(tracker, 408, level: 3, shortName: "Autopilot", channel: ModernDeploymentTracker.AutopilotChannel);
|
||||
|
||||
var posted = Assert.Single(PostedModernDeployment());
|
||||
Assert.Equal("modern_deployment_warning", posted.Payload![SignalPayloadKeys.EventType]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CriticalRecords_AreNeverSuppressed_EvenForNoiseIds()
|
||||
{
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
|
||||
Process(tracker, 1005, level: 1, shortName: "ManagementService", channel: ModernDeploymentTracker.ManagementChannel);
|
||||
|
||||
var posted = Assert.Single(PostedModernDeployment());
|
||||
Assert.Equal("modern_deployment_error", posted.Payload![SignalPayloadKeys.EventType]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassicSessions_KeepTheHarmlessDowngradePath_ForTheSameIds()
|
||||
{
|
||||
// Classic regression guard: without the WDP flag EventID 100 still flows through
|
||||
// the harmless-downgrade path (individual Debug emissions up to the rollup limit).
|
||||
var tracker = BuildTracker(isDevicePreparation: false);
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
Process(tracker, 100, level: 3, shortName: "Autopilot", channel: ModernDeploymentTracker.AutopilotChannel);
|
||||
}
|
||||
|
||||
Assert.Equal(2, PostedModernDeployment().Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-4
@@ -479,16 +479,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeTokenFailure_tracker_dedups_per_error_code()
|
||||
public void ImeTokenFailure_grace_window_emits_only_without_token_success()
|
||||
{
|
||||
// "Failed to get AAD token" appears routinely during the device phase (no user
|
||||
// token yet) — the Warning must fire only when NO success follows for the whole
|
||||
// grace window.
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var t0 = DateTime.UtcNow;
|
||||
|
||||
// Failure resolved by a token success: never emitted, not even after the window.
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line a");
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line b"); // same code — suppressed
|
||||
f.Tracker.HandleImeTokenFailureForTest("200", "line c"); // new code — emitted
|
||||
f.Tracker.NoteTokenSuccessForTest();
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + ImeLogTracker.TokenFailureGraceWindow + TimeSpan.FromMinutes(1));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
|
||||
Assert.Equal(2, f.InfoEvents(SharedEventTypes.ImeTokenFailure).Count);
|
||||
// Unresolved failure: silent before the window, one Warning after it.
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line b");
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + TimeSpan.FromMinutes(5));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + ImeLogTracker.TokenFailureGraceWindow + TimeSpan.FromHours(1));
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
|
||||
// Same code again: re-armed window expires but the per-code dedup suppresses.
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line c");
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + TimeSpan.FromHours(3));
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+111
-1
@@ -165,6 +165,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
public IReadOnlyCollection<string> StarvedAlreadyReportedOverride { get; set; } = Array.Empty<string>();
|
||||
public List<string> TerminationActionLog { get; } = new List<string>();
|
||||
public TimeSpan SpoolDrainPeriodOverride { get; set; } = TimeSpan.Zero;
|
||||
// Session bc803955 — DevicePrep app-wave settle wait. Zero (default) disables the
|
||||
// wait so the existing timing-sensitive tests stay untouched; the dedicated settle
|
||||
// tests opt in with a real budget.
|
||||
public TimeSpan DevicePrepAppSettleWaitOverride { get; set; } = TimeSpan.Zero;
|
||||
// WG Part-1 straggler-ordering fix — the production wiring passes
|
||||
// orchestrator.StopCollectorHosts, whose collectors emit their one-shot
|
||||
// stop-time stragglers (network_bandwidth_estimate, …) through Post during
|
||||
@@ -210,7 +214,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
stopPeripheralCollectors: StopPeripheralCollectorsHook,
|
||||
// Zero out the timing ceremony for tests — production paths are covered by
|
||||
// the dedicated V1-parity tests below which opt back in via their own Rig.
|
||||
lateEventGracePeriod: TimeSpan.Zero);
|
||||
lateEventGracePeriod: TimeSpan.Zero,
|
||||
devicePrepAppSettleWait: DevicePrepAppSettleWaitOverride);
|
||||
}
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
@@ -317,6 +322,91 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.True(File.Exists(statusPath), "final-status.json should be written even when ShowEnrollmentSummary=false");
|
||||
}
|
||||
|
||||
// ---- Session bc803955 — DevicePrep app-wave settle wait ----
|
||||
|
||||
private static DecisionState DevicePrepState(SessionStage stage) =>
|
||||
new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1"))
|
||||
{
|
||||
Stage = stage,
|
||||
ScenarioProfile = new EnrollmentScenarioProfile(
|
||||
mode: EnrollmentMode.DevicePreparation,
|
||||
joinMode: EnrollmentJoinMode.Unknown,
|
||||
espConfig: EspConfig.Unknown,
|
||||
preProvisioningSide: PreProvisioningSide.None,
|
||||
confidence: ProfileConfidence.Medium,
|
||||
evidenceOrdinal: 1,
|
||||
reason: "test"),
|
||||
}.Build();
|
||||
|
||||
[Fact]
|
||||
public void Handle_devicePrep_settle_wait_catches_late_app_wave_for_dialog()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = DevicePrepState(SessionStage.Completed);
|
||||
rig.DevicePrepAppSettleWaitOverride = TimeSpan.FromSeconds(10);
|
||||
|
||||
// Simulate the IME user-context sync landing AFTER termination handling began
|
||||
// (AppWorkload.log flush latency) — the wave arrives 500ms in, well before the
|
||||
// 10s budget; the settle loop must pick it up and the dialog must render it.
|
||||
var waveThread = new Thread(() =>
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
var app = new AppPackageState("app-late", 0);
|
||||
app.UpdateState(AppInstallationState.Downloading);
|
||||
TestHelpers.SetTargeted(app, AppTargeted.User);
|
||||
rig.Packages.Add(app);
|
||||
});
|
||||
waveThread.Start();
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
sw.Stop();
|
||||
waveThread.Join();
|
||||
|
||||
var statusJson = File.ReadAllText(Path.Combine(rig.StateDir, SummaryDialogLauncher.FinalStatusFileName));
|
||||
Assert.Contains("\"totalApps\": 1", statusJson);
|
||||
// Early exit: the wave arrived at ~500ms — the handler must not sit out the full 10s.
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(8),
|
||||
$"settle wait should exit early once packages arrive (took {sw.Elapsed.TotalSeconds:0.0}s)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_devicePrep_settle_wait_is_bounded_when_no_apps_arrive()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = DevicePrepState(SessionStage.Completed);
|
||||
rig.DevicePrepAppSettleWaitOverride = TimeSpan.FromMilliseconds(600);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
sw.Stop();
|
||||
|
||||
// Budget elapsed (>=600ms) and the dialog still rendered — just without app data.
|
||||
Assert.True(sw.Elapsed >= TimeSpan.FromMilliseconds(600),
|
||||
$"bounded wait should consume its budget when no data arrives (took {sw.Elapsed.TotalMilliseconds:0}ms)");
|
||||
var statusJson = File.ReadAllText(Path.Combine(rig.StateDir, SummaryDialogLauncher.FinalStatusFileName));
|
||||
Assert.Contains("\"totalApps\": 0", statusJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_non_devicePrep_does_not_wait_even_with_empty_packages()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
// Classic/unknown mode + empty package list — the settle wait must not engage.
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
|
||||
rig.DevicePrepAppSettleWaitOverride = TimeSpan.FromSeconds(10);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(8),
|
||||
$"non-DevicePrep termination must not settle-wait (took {sw.Elapsed.TotalSeconds:0.0}s)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_writes_enrollment_complete_marker_on_non_whiteglove_terminal()
|
||||
{
|
||||
@@ -1407,6 +1497,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.DoesNotContain("app_install_starved", rig.EmittedEventTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StarvedApps_skipped_on_device_preparation()
|
||||
{
|
||||
// afee7ae0: WDP has no ESP apps gate — post-desktop installs are the designed
|
||||
// order, so the sweep would report every pending app as noise (10+ events on a
|
||||
// normal session). The per-app data still travels via app_tracking_summary.
|
||||
using var rig = new Rig();
|
||||
rig.State = DevicePrepState(SessionStage.Completed);
|
||||
rig.StarvedAppsOverride = new[]
|
||||
{
|
||||
BuildStarvedApp("app-pending-1", "Contoso Backgrounds"),
|
||||
BuildStarvedApp("app-pending-2", "Contoso Tools"),
|
||||
};
|
||||
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
|
||||
Assert.DoesNotContain("app_install_starved", rig.EmittedEventTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromoteActiveInstalls_skipped_on_effect_infrastructure_failure()
|
||||
{
|
||||
|
||||
+117
@@ -1,8 +1,10 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Termination;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
@@ -508,6 +510,121 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
|
||||
Assert.Null(status.SignalTimestamps);
|
||||
}
|
||||
|
||||
// ---- Session bc803955 — synthetic Office row from the C2R detector's persisted state ----
|
||||
|
||||
private static FinalStatus BuildWithOffice(OfficeInstallStateData? officeState, IReadOnlyList<AppPackageState>? packages = null) =>
|
||||
FinalStatusBuilder.Build(StateWith(SessionStage.Completed),
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed),
|
||||
packageStates: packages, agentStartTimeUtc: StartUtc, officeState: officeState);
|
||||
|
||||
[Fact]
|
||||
public void Build_office_completed_adds_device_row_with_duration_and_counts()
|
||||
{
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateCompleted,
|
||||
StartedAtUtc = StartUtc.AddMinutes(1),
|
||||
CompletedAtUtc = StartUtc.AddMinutes(4).AddSeconds(22),
|
||||
VersionReached = "16.0.20228.20190",
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
var row = Assert.Single(status.PackageStatesByPhase["Device"]);
|
||||
Assert.Equal("Microsoft 365 Apps (16.0.20228.20190)", row.AppName);
|
||||
Assert.Equal("Installed", row.State);
|
||||
Assert.True(row.IsCompleted);
|
||||
Assert.False(row.IsError);
|
||||
Assert.Equal(202, row.DurationSeconds);
|
||||
Assert.Equal(office.StartedAtUtc.Value.ToString("o"), row.StartedAt);
|
||||
|
||||
Assert.Equal(1, status.AppSummary.TotalApps);
|
||||
Assert.Equal(1, status.AppSummary.CompletedApps);
|
||||
Assert.Equal(0, status.AppSummary.ErrorCount);
|
||||
Assert.Equal(1, status.AppSummary.AppsByPhase["Device"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_office_active_row_is_installing_without_completion()
|
||||
{
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateActive,
|
||||
StartedAtUtc = StartUtc.AddMinutes(1),
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
var row = Assert.Single(status.PackageStatesByPhase["Device"]);
|
||||
Assert.Equal("Microsoft 365 Apps", row.AppName);
|
||||
Assert.Equal("Installing", row.State);
|
||||
Assert.False(row.IsCompleted);
|
||||
Assert.Null(row.DurationSeconds);
|
||||
Assert.Equal(1, status.AppSummary.TotalApps);
|
||||
Assert.Equal(0, status.AppSummary.CompletedApps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_office_failed_row_counts_device_error()
|
||||
{
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateFailed,
|
||||
StartedAtUtc = StartUtc.AddMinutes(1),
|
||||
CompletedAtUtc = StartUtc.AddMinutes(2),
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
var row = Assert.Single(status.PackageStatesByPhase["Device"]);
|
||||
Assert.Equal("Error", row.State);
|
||||
Assert.True(row.IsError);
|
||||
Assert.True(row.IsCompleted); // Error is terminal — mirrors IsCompleted()
|
||||
Assert.Equal(1, status.AppSummary.ErrorCount);
|
||||
Assert.Equal(1, status.AppSummary.DeviceErrors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OfficeInstallStateData.StatePreinstalled)]
|
||||
[InlineData("garbage")]
|
||||
[InlineData(null)]
|
||||
public void Build_office_preinstalled_or_unknown_adds_no_row(string? state)
|
||||
{
|
||||
var office = state == null ? null : new OfficeInstallStateData { State = state };
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
Assert.Empty(status.PackageStatesByPhase);
|
||||
Assert.Equal(0, status.AppSummary.TotalApps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_office_row_is_prepended_before_ime_device_apps()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var list = new AppPackageStateList(logger);
|
||||
var imeApp = new AppPackageState("app-1", 0);
|
||||
imeApp.UpdateState(AppInstallationState.Installed);
|
||||
TestHelpers.SetTargeted(imeApp, AppTargeted.Device);
|
||||
list.Add(imeApp);
|
||||
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateCompleted,
|
||||
StartedAtUtc = StartUtc,
|
||||
CompletedAtUtc = StartUtc.AddMinutes(3),
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office, list);
|
||||
|
||||
Assert.Equal(2, status.PackageStatesByPhase["Device"].Count);
|
||||
Assert.StartsWith("Microsoft 365 Apps", status.PackageStatesByPhase["Device"][0].AppName);
|
||||
Assert.Equal(2, status.AppSummary.TotalApps);
|
||||
Assert.Equal(2, status.AppSummary.CompletedApps);
|
||||
Assert.Equal(2, status.AppSummary.AppsByPhase["Device"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mini helper for reflection-based field setters — AppPackageState.Targeted is a private-set property.</summary>
|
||||
|
||||
@@ -221,6 +221,81 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Equal(1, System.Threading.Volatile.Read(ref signalled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_tracks_enqueue_and_shrinks_to_zero_when_all_uploaded()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(0, spool.PendingBytes);
|
||||
|
||||
spool.Enqueue(EventDraft("a"));
|
||||
spool.Enqueue(EventDraft("b"));
|
||||
spool.Enqueue(EventDraft("c"));
|
||||
|
||||
// PendingBytes must equal the full file size while nothing is uploaded yet
|
||||
// (every line written by Enqueue is still pending).
|
||||
Assert.Equal(spool.SpoolFileSizeBytes, spool.PendingBytes);
|
||||
|
||||
spool.MarkUploaded(2);
|
||||
Assert.Equal(0, spool.PendingBytes);
|
||||
// File keeps its append-only size — the two measures MUST diverge here,
|
||||
// otherwise the pressure trigger regresses to the file-size false positive.
|
||||
Assert.True(spool.SpoolFileSizeBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_after_partial_upload_equals_remaining_tail_line_bytes()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
spool.Enqueue(EventDraft("a"));
|
||||
spool.Enqueue(EventDraft("b"));
|
||||
spool.Enqueue(EventDraft("c-longer-rowkey-for-distinct-length"));
|
||||
|
||||
spool.MarkUploaded(1);
|
||||
|
||||
// Expected = on-disk byte length of the last line incl. its trailing \n.
|
||||
var lines = File.ReadAllLines(Path.Combine(tmp.Path, "spool.jsonl"), Encoding.UTF8);
|
||||
var expected = Encoding.UTF8.GetByteCount(lines[2]) + 1;
|
||||
Assert.Equal(expected, spool.PendingBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_is_rehydrated_on_restart_from_pending_tail_only()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("a"));
|
||||
s1.Enqueue(EventDraft("b"));
|
||||
s1.Enqueue(EventDraft("c"));
|
||||
s1.MarkUploaded(1);
|
||||
var expectedPendingBytes = s1.PendingBytes;
|
||||
Assert.True(expectedPendingBytes > 0);
|
||||
|
||||
// Restart: rehydrated PendingBytes must match the pre-restart value exactly
|
||||
// (uploaded prefix excluded), not the full file size.
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(expectedPendingBytes, s2.PendingBytes);
|
||||
Assert.True(s2.PendingBytes < s2.SpoolFileSizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_rehydrate_ignores_corrupt_tail_line()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("a"));
|
||||
var cleanPendingBytes = s1.PendingBytes;
|
||||
|
||||
File.AppendAllText(
|
||||
Path.Combine(tmp.Path, "spool.jsonl"),
|
||||
"{\"Kind\":\"Event\",\"PartitionKey\":\"corrupt-no-",
|
||||
Encoding.UTF8);
|
||||
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(cleanPendingBytes, s2.PendingBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enqueue_without_RequiresImmediateFlush_does_not_raise_ImmediateFlushRequested()
|
||||
{
|
||||
|
||||
+52
-7
@@ -25,25 +25,70 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private DateTime _impersonationLastRollupUtc = DateTime.MinValue;
|
||||
private static readonly TimeSpan ImpersonationRollupInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
// IME retries token acquisition on every check-in — dedup per distinct error
|
||||
// code so an outage yields one Warning, not a timeline flood.
|
||||
// "Failed to get AAD token" is logged ROUTINELY during the device phase (no user
|
||||
// token exists yet — field-confirmed: the line appears en masse in every enrollment,
|
||||
// Oliver 2026-08-18). A failure is therefore only meaningful when NO token success
|
||||
// follows: the failure is held pending, any IME-TOKEN-SUCCESS match clears it, and
|
||||
// the Warning fires only after the grace window elapses without a success. Genuine
|
||||
// auth outages (WAM broken, conditional access, network) run long enough to hit it.
|
||||
// Not persisted across restarts — failures keep arriving, the window re-arms.
|
||||
internal static readonly TimeSpan TokenFailureGraceWindow = TimeSpan.FromMinutes(30);
|
||||
|
||||
private sealed class PendingTokenFailure
|
||||
{
|
||||
public string Code = "unknown";
|
||||
public string Excerpt = string.Empty;
|
||||
public DateTime FirstFailureUtc;
|
||||
}
|
||||
|
||||
private PendingTokenFailure _pendingTokenFailure;
|
||||
// Dedup per distinct error code so a persistent outage yields one Warning total.
|
||||
private readonly HashSet<string> _reportedTokenFailureCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private void HandleImeTokenFailure(string errorCode, string message)
|
||||
{
|
||||
var code = string.IsNullOrEmpty(errorCode) ? "unknown" : errorCode.Trim();
|
||||
if (!_reportedTokenFailureCodes.Add(code)) return;
|
||||
if (_pendingTokenFailure != null) return; // earliest unresolved failure wins
|
||||
|
||||
var code = string.IsNullOrEmpty(errorCode) ? "unknown" : errorCode.Trim();
|
||||
var excerpt = message ?? string.Empty;
|
||||
if (excerpt.Length > 300) excerpt = excerpt.Substring(0, 300);
|
||||
|
||||
_logger.Warning($"ImeLogTracker: IME token acquisition failure (errorCode={code})");
|
||||
OnImeTokenFailure?.Invoke(code, excerpt);
|
||||
_pendingTokenFailure = new PendingTokenFailure
|
||||
{
|
||||
Code = code,
|
||||
Excerpt = excerpt,
|
||||
// Source-line time so a late-starting agent replaying an ongoing outage
|
||||
// ages the window correctly; a success later in the same log still clears
|
||||
// before the expiry check runs (lines are drained first, expiry after).
|
||||
FirstFailureUtc = LastMatchedLogTimestamp ?? DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
// Test seam: drives HandleImeTokenFailure without the regex pipeline.
|
||||
private void ClearPendingTokenFailure()
|
||||
{
|
||||
if (_pendingTokenFailure == null) return;
|
||||
_logger.Debug($"ImeLogTracker: token success observed — pending token failure (errorCode={_pendingTokenFailure.Code}) cleared");
|
||||
_pendingTokenFailure = null;
|
||||
}
|
||||
|
||||
/// <summary>Called once per poll cycle after the log drain (and testable directly).</summary>
|
||||
internal void CheckPendingTokenFailure(DateTime nowUtc)
|
||||
{
|
||||
var pending = _pendingTokenFailure;
|
||||
if (pending == null) return;
|
||||
if (nowUtc - pending.FirstFailureUtc < TokenFailureGraceWindow) return;
|
||||
|
||||
_pendingTokenFailure = null; // next failure line re-arms a fresh window
|
||||
if (!_reportedTokenFailureCodes.Add(pending.Code)) return;
|
||||
|
||||
_logger.Warning($"ImeLogTracker: IME token acquisition failing for >= {TokenFailureGraceWindow.TotalMinutes:0} min without a success (errorCode={pending.Code})");
|
||||
OnImeTokenFailure?.Invoke(pending.Code, pending.Excerpt);
|
||||
}
|
||||
|
||||
// Test seams: drive the pending/clear/expiry cycle without the regex pipeline.
|
||||
internal void HandleImeTokenFailureForTest(string errorCode, string message)
|
||||
=> HandleImeTokenFailure(errorCode, message);
|
||||
internal void NoteTokenSuccessForTest() => ClearPendingTokenFailure();
|
||||
|
||||
private void HandleImeImpersonation(string user)
|
||||
{
|
||||
|
||||
+8
@@ -244,6 +244,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return;
|
||||
}
|
||||
|
||||
// A token success resolves any pending token failure (grace-window model, see
|
||||
// HandleImeTokenFailure). Matched on our own stable pattern ID — the line also
|
||||
// drives espPhaseDetected, whose handler must stay unaware of token semantics.
|
||||
// Placed after the staleness gate so a success replayed from a previous
|
||||
// enrollment's log cannot clear a genuine current outage.
|
||||
if (!isStaleReplayLine && string.Equals(pattern.PatternId, "IME-TOKEN-SUCCESS", StringComparison.OrdinalIgnoreCase))
|
||||
ClearPendingTokenFailure();
|
||||
|
||||
try
|
||||
{
|
||||
var id = match.Groups["id"]?.Value;
|
||||
|
||||
@@ -607,6 +607,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// line never arrived within the grace period. Runs on this same loop
|
||||
// thread (no locking) so it observes the buffer right after parsing.
|
||||
FlushStalePlatformScriptResults(DateTime.UtcNow);
|
||||
|
||||
// Token-failure grace window: fires only when the failure stayed
|
||||
// unresolved (no IME-TOKEN-SUCCESS) for the whole window. Runs after
|
||||
// the drain so a success later in the same batch clears first.
|
||||
CheckPendingTokenFailure(DateTime.UtcNow);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
|
||||
+29
-12
@@ -37,6 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly int _modernDeploymentBackfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly int[] _modernDeploymentHarmlessEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
private readonly Func<(bool? skipUser, bool? skipDevice)> _skipConfigProbe;
|
||||
private readonly Func<bool> _accountSetupActivityProbe;
|
||||
private readonly Func<bool> _userEspAppsSettledProbe;
|
||||
@@ -194,7 +195,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>
|
||||
/// True when DeviceSetup contains a SaveWhiteGloveSuccessResult subcategory with state "succeeded".
|
||||
/// This is a definitive WhiteGlove (Pre-Provisioning) confirmation signal from the ESP registry.
|
||||
/// NOT a WhiteGlove indicator on Win11 25H2+ — Windows writes it in every enrollment
|
||||
/// (see <see cref="ProvisioningStatusTracker.HasSaveWhiteGloveSuccessResult"/>). Observability only.
|
||||
/// </summary>
|
||||
public bool HasSaveWhiteGloveSuccessResult => _provisioningTracker?.HasSaveWhiteGloveSuccessResult ?? false;
|
||||
|
||||
@@ -210,6 +212,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
int modernDeploymentBackfillLookbackMinutes = 30,
|
||||
string stateDirectory = null,
|
||||
int[] modernDeploymentHarmlessEventIds = null,
|
||||
bool isDevicePreparation = false,
|
||||
Func<(bool? skipUser, bool? skipDevice)> skipConfigProbe = null,
|
||||
Func<bool> accountSetupActivityProbe = null,
|
||||
Func<bool> userEspAppsSettledProbe = null,
|
||||
@@ -227,6 +230,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_modernDeploymentBackfillLookbackMinutes = modernDeploymentBackfillLookbackMinutes;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_modernDeploymentHarmlessEventIds = modernDeploymentHarmlessEventIds;
|
||||
_isDevicePreparation = isDevicePreparation;
|
||||
// Test seam: allow injection of a fake skip-config reader; defaults to the real
|
||||
// registry probe so production never has to think about this parameter.
|
||||
_skipConfigProbe = skipConfigProbe ?? (() => EspSkipConfigurationProbe.Read(_logger));
|
||||
@@ -347,16 +351,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_shellCoreTracker.HelloWizardStarted += OnHelloWizardStarted;
|
||||
_shellCoreTracker.Start();
|
||||
|
||||
_provisioningTracker = new ProvisioningStatusTracker(
|
||||
_sessionId,
|
||||
_tenantId,
|
||||
_post,
|
||||
_logger,
|
||||
packageStatesProbe: _packageStatesProbe);
|
||||
_provisioningTracker.EspFailureDetected += OnProvisioningEspFailureDetected;
|
||||
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
|
||||
_provisioningTracker.AccountSetupProvisioningComplete += OnAccountSetupProvisioningComplete;
|
||||
_provisioningTracker.Start();
|
||||
// WDP gate (afee7ae0, 2026-08-18): Device Preparation writes its BootstrapperAgent
|
||||
// progress under the very AutopilotSettings key this tracker subtree-watches, so on
|
||||
// WDP the watcher woke up on every provisioning write only to read three ESP
|
||||
// category values that never exist there. The ESP categories are the tracker's
|
||||
// ONLY subject — skip it entirely. All consumers already null-guard the tracker.
|
||||
if (!_isDevicePreparation)
|
||||
{
|
||||
_provisioningTracker = new ProvisioningStatusTracker(
|
||||
_sessionId,
|
||||
_tenantId,
|
||||
_post,
|
||||
_logger,
|
||||
packageStatesProbe: _packageStatesProbe);
|
||||
_provisioningTracker.EspFailureDetected += OnProvisioningEspFailureDetected;
|
||||
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
|
||||
_provisioningTracker.AccountSetupProvisioningComplete += OnAccountSetupProvisioningComplete;
|
||||
_provisioningTracker.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("EspAndHelloTracker: Device Preparation session — ProvisioningStatusTracker (ESP categories) not started");
|
||||
}
|
||||
|
||||
if (_modernDeploymentWatcherEnabled)
|
||||
{
|
||||
@@ -369,7 +385,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_modernDeploymentBackfillEnabled,
|
||||
_modernDeploymentBackfillLookbackMinutes,
|
||||
_stateDirectory,
|
||||
_modernDeploymentHarmlessEventIds);
|
||||
_modernDeploymentHarmlessEventIds,
|
||||
_isDevicePreparation);
|
||||
_modernDeploymentTracker.Start();
|
||||
}
|
||||
}
|
||||
|
||||
+67
-1
@@ -119,9 +119,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
if (_last == null)
|
||||
{
|
||||
// Silent baseline — see class doc.
|
||||
// Silent baseline for the DIFF stream — see class doc. But the baseline
|
||||
// itself is valuable evidence: on WDP the DPP-policy apps install in
|
||||
// Batch 1 BEFORE the agent (bootstrap = Batch 2), so their terminal
|
||||
// states exist ONLY here. One capped summary event closes that
|
||||
// pre-agent gap without any log backfill (Oliver, 2026-08-18).
|
||||
_last = snapshot;
|
||||
_logger?.Info($"RegistryAppState: baseline captured ({snapshot.Entries.Count} app entries, trigger={reason})");
|
||||
if (snapshot.Entries.Count > 0)
|
||||
EmitBaselineSummary(snapshot, nowUtc);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -181,6 +187,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var entry = snapshot.GetOrAdd(userName, appId);
|
||||
entry.ExitCode = TryReadInt(appKey.GetValue("ExitCode"));
|
||||
entry.Intent = TryReadInt(appKey.GetValue("Intent"));
|
||||
entry.LastUpdatedUtc = appKey.GetValue("LastUpdatedTimeUtc") as string;
|
||||
|
||||
using var esm = appKey.OpenSubKey("EnforcementStateMessage");
|
||||
var json = esm?.GetValue("EnforcementStateMessage") as string;
|
||||
@@ -452,6 +459,64 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
occurredAtUtc: nowUtc);
|
||||
}
|
||||
|
||||
// Baseline entries in the summary's typed payload are capped — a long-lived device
|
||||
// with many historical Win32Apps entries must not produce an oversized event.
|
||||
internal const int MaxBaselineSummaryApps = 50;
|
||||
|
||||
private void EmitBaselineSummary(ImeRegistrySnapshot snapshot, DateTime nowUtc)
|
||||
{
|
||||
int success = 0, error = 0, inProgress = 0, other = 0;
|
||||
var apps = new List<Dictionary<string, string>>();
|
||||
foreach (var entry in snapshot.Entries.Values)
|
||||
{
|
||||
var cls = entry.EnforcementState is int es ? ClassifyEnforcementState(es) : null;
|
||||
switch (cls)
|
||||
{
|
||||
case "success": success++; break;
|
||||
case "error": error++; break;
|
||||
case "inProgress": inProgress++; break;
|
||||
default: other++; break;
|
||||
}
|
||||
|
||||
if (apps.Count >= MaxBaselineSummaryApps) continue;
|
||||
var app = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = entry.AppId,
|
||||
["userContext"] = entry.UserContext,
|
||||
};
|
||||
if (entry.EnforcementState is int state)
|
||||
{
|
||||
app["enforcementState"] = state.ToString(CultureInfo.InvariantCulture);
|
||||
app["enforcementClass"] = ClassifyEnforcementState(state);
|
||||
}
|
||||
if (entry.ErrorCode is long ec) app["errorCode"] = ec.ToString(CultureInfo.InvariantCulture);
|
||||
if (entry.ExitCode is int xc) app["exitCode"] = xc.ToString(CultureInfo.InvariantCulture);
|
||||
if (!string.IsNullOrEmpty(entry.LastUpdatedUtc)) app["lastUpdatedUtc"] = entry.LastUpdatedUtc!;
|
||||
if (entry.EspTracked) app["espTracked"] = "true";
|
||||
apps.Add(app);
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["totalApps"] = snapshot.Entries.Count.ToString(CultureInfo.InvariantCulture),
|
||||
["successCount"] = success.ToString(CultureInfo.InvariantCulture),
|
||||
["errorCount"] = error.ToString(CultureInfo.InvariantCulture),
|
||||
["inProgressCount"] = inProgress.ToString(CultureInfo.InvariantCulture),
|
||||
["otherCount"] = other.ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
if (snapshot.Entries.Count > MaxBaselineSummaryApps)
|
||||
data["appListTruncated"] = "true";
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.RegistryAppBaseline,
|
||||
source: SourceLabel,
|
||||
message: $"Registry app baseline at agent start: {snapshot.Entries.Count} apps ({success} success, {error} error, {inProgress} in progress) — state that predates this agent run",
|
||||
severity: EventSeverity.Info,
|
||||
data: data,
|
||||
occurredAtUtc: nowUtc,
|
||||
typedPayload: new { apps });
|
||||
}
|
||||
|
||||
private void TrackTerminalTransitions(ImeRegistrySnapshot snapshot, DateTime nowUtc)
|
||||
{
|
||||
foreach (var kv in snapshot.Entries)
|
||||
@@ -552,6 +617,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public string Key { get; }
|
||||
public string UserContext { get; }
|
||||
public string AppId { get; }
|
||||
public string? LastUpdatedUtc { get; set; }
|
||||
public int? EnforcementState { get; set; }
|
||||
public long? ErrorCode { get; set; }
|
||||
public int? ExitCode { get; set; }
|
||||
|
||||
+38
-2
@@ -66,6 +66,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
internal const string AutopilotErrorBackfillStateFileName = "autopilot-error-backfill.json";
|
||||
|
||||
/// <summary>
|
||||
/// EventIDs that are STRUCTURAL noise on a Device Preparation session and are
|
||||
/// suppressed there entirely (afee7ae0: 900+ occurrences of 1005 plus 400+ of
|
||||
/// 417/418 in a nine-minute session, all expected): 100 "Autopilot policy not
|
||||
/// found" (no deployment profile exists on WDP by design), 417/418 "duplicate
|
||||
/// deployment workload/batch" (BootstrapperAgent SLDM progress-poll chatter),
|
||||
/// 1005 ManagementService WIL error storm. Genuine WDP diagnostics (e.g. 408
|
||||
/// provisioning warnings) keep flowing; Critical (level 1) records are never
|
||||
/// suppressed. Classic sessions are unaffected — they keep the configurable
|
||||
/// harmless-downgrade + rollup path instead.
|
||||
/// </summary>
|
||||
internal static readonly HashSet<int> DevicePreparationNoiseEventIds = new HashSet<int> { 100, 417, 418, 1005 };
|
||||
|
||||
/// <summary>
|
||||
/// Per-EventId emission caps for harmless-downgraded events (session 8bc1180f,
|
||||
/// 2026-06-12). Windows can dump hundreds of identical EventID-100 "Autopilot policy
|
||||
@@ -93,6 +106,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly HashSet<int> _harmlessEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
|
||||
private EventLogWatcher _autopilotWatcher;
|
||||
private EventLogWatcher _managementWatcher;
|
||||
@@ -131,7 +145,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
bool backfillEnabled = true,
|
||||
int backfillLookbackMinutes = 30,
|
||||
string stateDirectory = null,
|
||||
int[] harmlessEventIds = null)
|
||||
int[] harmlessEventIds = null,
|
||||
bool isDevicePreparation = false)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -140,6 +155,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_logLevelMax = logLevelMax;
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_isDevicePreparation = isDevicePreparation;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_harmlessEventIds = harmlessEventIds != null && harmlessEventIds.Length > 0
|
||||
? new HashSet<int>(harmlessEventIds)
|
||||
@@ -166,7 +182,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (_backfillEnabled)
|
||||
{
|
||||
BackfillTargetedEvents();
|
||||
BackfillAutopilotErrors();
|
||||
// WDP gate (afee7ae0): the error backfill replays ZTD registration /
|
||||
// attestation errors (807/809/815/908) — on Device Preparation the 807
|
||||
// ZtdDeviceIsNotRegistered answer is the EXPECTED state, not a failure.
|
||||
if (!_isDevicePreparation)
|
||||
{
|
||||
BackfillAutopilotErrors();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("ModernDeployment: Device Preparation session — Autopilot error backfill skipped (ZTD errors are structural there)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -393,6 +419,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
var effectiveLevel = level ?? 4;
|
||||
|
||||
// WDP structural-noise gate — see DevicePreparationNoiseEventIds. Sits before
|
||||
// the severity mapping so suppressed records also skip FormatDescription().
|
||||
// Critical (1) and LogAlways (0) records always pass.
|
||||
if (_isDevicePreparation
|
||||
&& effectiveLevel >= 2
|
||||
&& DevicePreparationNoiseEventIds.Contains(eventId))
|
||||
{
|
||||
return EventVerdict.Suppress;
|
||||
}
|
||||
switch (effectiveLevel)
|
||||
{
|
||||
case 1:
|
||||
|
||||
+13
-8
@@ -107,7 +107,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private bool _deviceSetupProvisioningCompleteFired;
|
||||
// Fire-once guard for AccountSetupProvisioningComplete event (session 330f73f3 fix)
|
||||
private bool _accountSetupProvisioningCompleteFired;
|
||||
// WhiteGlove confirmation: DeviceSetup registry contains SaveWhiteGloveSuccessResult=succeeded
|
||||
// DeviceSetup registry contains SaveWhiteGloveSuccessResult=succeeded. NOT a WhiteGlove
|
||||
// indicator on Win11 25H2+ (see HasSaveWhiteGloveSuccessResult) — observability only.
|
||||
private bool _saveWhiteGloveSuccessResultSeen;
|
||||
// Track SaveWhiteGloveSuccessResult state transitions for observability (null → notStarted → succeeded)
|
||||
private string _lastSaveWhiteGloveState;
|
||||
@@ -226,8 +227,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>
|
||||
/// True when DeviceSetup registry JSON contains a SaveWhiteGloveSuccessResult property
|
||||
/// with subcategoryState=succeeded. This is a definitive WhiteGlove (Pre-Provisioning)
|
||||
/// confirmation signal — Windows only writes this property during White Glove flows.
|
||||
/// with subcategoryState=succeeded. NOT a WhiteGlove indicator: on Win11 25H2 (build
|
||||
/// 26200+) Windows writes this property as a trivially-succeeding step in EVERY
|
||||
/// enrollment (validated 2026-08-18 — user-driven sessions f475e697/c601a24b/4219203f
|
||||
/// all carry it). Kept for observability only (whiteglove_signal_* raw dumps); must not
|
||||
/// be used for classification. Reliable WhiteGlove anchors are ModernDeployment
|
||||
/// EventID 509 and the Shell-Core WhiteGlove-success events.
|
||||
/// </summary>
|
||||
public bool HasSaveWhiteGloveSuccessResult
|
||||
{
|
||||
@@ -891,10 +896,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string categoryStatusMessage = SafeGetString(root, "categoryStatusMessage");
|
||||
var subcategories = ParseSubcategories(root);
|
||||
|
||||
// Detect WhiteGlove signal in DeviceSetup category.
|
||||
// SaveWhiteGloveSuccessResult is NOT a *Subcategory-suffixed property, so
|
||||
// ParseSubcategories skips it. We scan the raw JSON explicitly.
|
||||
// Track state transitions for full observability (notStarted → succeeded).
|
||||
// Track the SaveWhiteGloveSuccessResult transitions in the DeviceSetup
|
||||
// category (observability only — NOT a WhiteGlove indicator on 25H2+, see
|
||||
// HasSaveWhiteGloveSuccessResult). It is NOT a *Subcategory-suffixed
|
||||
// property, so ParseSubcategories skips it; we scan the raw JSON explicitly.
|
||||
if (string.Equals(categoryLabel, "DeviceSetup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
@@ -919,7 +924,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
_saveWhiteGloveSuccessResultSeen = true;
|
||||
_logger.Info("ProvisioningStatusTracker: SaveWhiteGloveSuccessResult=succeeded " +
|
||||
"— WhiteGlove confirmation signal");
|
||||
"(observability only — not a WhiteGlove indicator on 25H2+)");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
+19
-1
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly HashSet<string> _sources;
|
||||
private readonly int _sessionStalledAfterProbeIndex;
|
||||
private readonly HashSet<int> _harmlessModernDeploymentEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
|
||||
private readonly object _stateLock = new object();
|
||||
private readonly HashSet<int> _firedProbeIndices = new HashSet<int>();
|
||||
@@ -151,7 +152,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
int[] traceIndices,
|
||||
string[] sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[] harmlessModernDeploymentEventIds = null)
|
||||
int[] harmlessModernDeploymentEventIds = null,
|
||||
bool isDevicePreparation = false)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -161,10 +163,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_traceIndices = new HashSet<int>(traceIndices ?? new int[0]);
|
||||
_sources = new HashSet<string>(sources ?? new string[0], StringComparer.OrdinalIgnoreCase);
|
||||
_sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex;
|
||||
_isDevicePreparation = isDevicePreparation;
|
||||
_harmlessModernDeploymentEventIds = new HashSet<int>(
|
||||
harmlessModernDeploymentEventIds != null && harmlessModernDeploymentEventIds.Length > 0
|
||||
? harmlessModernDeploymentEventIds
|
||||
: DefaultHarmlessModernDeploymentEventIds);
|
||||
|
||||
// WDP gate (afee7ae0): the structural WDP noise IDs (417/418 duplicate-workload
|
||||
// chatter, 1005 WIL storm, ...) must never count as stall anomalies on Device
|
||||
// Preparation, independent of what the tenant-configured harmless list contains.
|
||||
// The channels themselves stay scanned — WDP's provisioning stack logs genuine
|
||||
// diagnostics (e.g. 408) into the very same Autopilot channel.
|
||||
if (_isDevicePreparation)
|
||||
{
|
||||
_harmlessModernDeploymentEventIds.UnionWith(ModernDeploymentTracker.DevicePreparationNoiseEventIds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -368,6 +381,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
private void ScanProvisioningRegistry(ProbeResult result)
|
||||
{
|
||||
// Device Preparation writes no ESP category values — the three Status JSONs
|
||||
// below are ESP-only constructs, so the scan can never find anything there.
|
||||
if (_isDevicePreparation)
|
||||
return;
|
||||
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings", writable: false))
|
||||
{
|
||||
if (key == null)
|
||||
|
||||
+49
-12
@@ -275,14 +275,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// Include enrollment type in autopilot_profile event data
|
||||
data["enrollmentType"] = detectedType;
|
||||
|
||||
// Second diagnostic surface (independent of AutopilotPolicyCache): Microsoft's
|
||||
// documented Diagnostics\Autopilot key (IsAutopilotDisabled, TenantMatched,
|
||||
// CloudAssignedTenantDomain/Id, ...). See docs/agent/autopilot-ztd-diagnostics.md.
|
||||
var diagnosticsRegistry = ZtdEvidence.ReadDiagnosticsRegistry(_logger);
|
||||
if (diagnosticsRegistry != null)
|
||||
data["diagnosticsRegistry"] = diagnosticsRegistry;
|
||||
// Device Preparation (WDP) has no Autopilot deployment profile by design:
|
||||
// Windows still runs the ZTD policy download during OOBE and caches the 807
|
||||
// ZtdDeviceIsNotRegistered answer, so without this gate every WDP session
|
||||
// emits an autopilot_profile dump plus a misleading "no deployment profile
|
||||
// was assigned" warning and burns a ZTD event-log query + HTTP probe on
|
||||
// evidence that answers a question nobody asked (afee7ae0). Gated on the
|
||||
// deterministic WDP rule only — the CloudAssigned* fallback rules imply a
|
||||
// real profile (just ESP-less), where the dump stays meaningful. The
|
||||
// enrollment_type_detected event below still carries the rail decision.
|
||||
var isDevicePreparation = detectionRule ==
|
||||
AutopilotMonitor.Agent.V2.Core.Security.EnrollmentRegistryDetector.DevicePreparationExecutionContextRule;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data);
|
||||
Dictionary<string, object> diagnosticsRegistry = null;
|
||||
if (!isDevicePreparation)
|
||||
{
|
||||
// Second diagnostic surface (independent of AutopilotPolicyCache): Microsoft's
|
||||
// documented Diagnostics\Autopilot key (IsAutopilotDisabled, TenantMatched,
|
||||
// CloudAssignedTenantDomain/Id, ...). See docs/agent/autopilot-ztd-diagnostics.md.
|
||||
diagnosticsRegistry = ZtdEvidence.ReadDiagnosticsRegistry(_logger);
|
||||
if (diagnosticsRegistry != null)
|
||||
data["diagnosticsRegistry"] = diagnosticsRegistry;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: Device Preparation session — skipping autopilot_profile/ZTD evidence (no deployment profile exists on WDP)");
|
||||
}
|
||||
|
||||
// ProfileAvailable=0 means the ZTD service returned no profile for this device
|
||||
// during OOBE. Sessions only exist after the backend's device-registration
|
||||
@@ -293,7 +313,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// Autopilot session — surface the edge case explicitly, with a ZTD verdict
|
||||
// backed by the ModernDeployment Autopilot event log plus an endpoint probe
|
||||
// (see docs/agent/autopilot-ztd-diagnostics.md for the evidence model).
|
||||
if (IsAutopilotProfileMissing(data))
|
||||
if (!isDevicePreparation && IsAutopilotProfileMissing(data))
|
||||
{
|
||||
// On Windows 365 Cloud PCs a missing profile is the expected state — Cloud PCs
|
||||
// are provisioned by the Windows 365 service, not by Autopilot — so the event
|
||||
@@ -787,10 +807,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
data["espTrackedModernCount"] = tracking.ModernCount;
|
||||
}
|
||||
|
||||
var summary = BuildSummary(snapshot, tracking);
|
||||
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.EspConfigDetected, $"ESP configuration: {summary}", data);
|
||||
PostEspConfigDetectedSignal(snapshot);
|
||||
// No FirstSync value and no ESP tracking list means there is no ESP evidence at
|
||||
// all (Device Preparation, plain Entra join, pre-sync timing) — an event that
|
||||
// says nothing but "unknown/unknown" is noise, so skip it. Mirrors the null-gate
|
||||
// the orchestrator bootstrap already applies to the decision signal
|
||||
// (EnrollmentOrchestrator.PostEspConfigDetectedBootstrap).
|
||||
var hasAnyEspEvidence = snapshot.SkipUser.HasValue
|
||||
|| snapshot.SkipDevice.HasValue
|
||||
|| snapshot.BlockInStatusPage.HasValue
|
||||
|| snapshot.SyncFailureTimeoutMinutes.HasValue
|
||||
|| tracking.HasData;
|
||||
if (hasAnyEspEvidence)
|
||||
{
|
||||
var summary = BuildSummary(snapshot, tracking);
|
||||
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.EspConfigDetected, $"ESP configuration: {summary}", data);
|
||||
PostEspConfigDetectedSignal(snapshot);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("EnrollmentTracker: no ESP configuration evidence (FirstSync empty, no tracking lists) — esp_config_detected suppressed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+8
-3
@@ -300,7 +300,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_state = DetectorState.Terminal;
|
||||
EmitLifecycle(Constants.EventTypes.OfficeInstallCompleted, snap, EventSeverity.Info, "Completed", isTerminal: true, coreBinariesPresent: true);
|
||||
PersistState(OfficeInstallStateData.StateCompleted);
|
||||
PersistState(OfficeInstallStateData.StateCompleted, snap);
|
||||
return CompletionOutcome.Completed;
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
if (_state == DetectorState.Terminal) return;
|
||||
_state = DetectorState.Terminal;
|
||||
EmitLifecycle(Constants.EventTypes.OfficeInstallFailed, snap, EventSeverity.Error, "Failed", isTerminal: true);
|
||||
PersistState(OfficeInstallStateData.StateFailed);
|
||||
PersistState(OfficeInstallStateData.StateFailed, snap);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -444,15 +444,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// (the resume is what delivers a completion missed across a mid-install reboot).
|
||||
/// Caller holds the lock.
|
||||
/// </summary>
|
||||
private void PersistState(string state)
|
||||
private void PersistState(string state, OfficeC2RSnapshot? snap = null)
|
||||
{
|
||||
if (_statePersistence == null) return;
|
||||
_lastStatePersistUtc = _clock.UtcNow;
|
||||
var isTerminal = state == OfficeInstallStateData.StateCompleted
|
||||
|| state == OfficeInstallStateData.StateFailed;
|
||||
_statePersistence.Save(new OfficeInstallStateData
|
||||
{
|
||||
State = state,
|
||||
StartedAtUtc = _startedAtUtc,
|
||||
StartedTrigger = _startedTrigger,
|
||||
// Terminal-only metadata for the summary dialog's Office row (duration + version).
|
||||
CompletedAtUtc = isTerminal ? _clock.UtcNow : (DateTime?)null,
|
||||
VersionReached = isTerminal ? snap?.VersionToReport : null,
|
||||
PeakDo = OfficeDoPeakData.FromSample(_peakDo),
|
||||
});
|
||||
}
|
||||
|
||||
+9
@@ -105,6 +105,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
public DateTime? StartedAtUtc { get; set; }
|
||||
public string? StartedTrigger { get; set; }
|
||||
|
||||
/// <summary>Stamped only on the two terminal persists (Completed/Failed) — basis for the
|
||||
/// summary dialog's Office row duration. Null on Active/Preinstalled and on state files
|
||||
/// written by older agents (fail-soft deserialization).</summary>
|
||||
public DateTime? CompletedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Office build reached at the terminal transition (snapshot's VersionToReport).
|
||||
/// Null when the snapshot had none or on older state files.</summary>
|
||||
public string? VersionReached { get; set; }
|
||||
|
||||
/// <summary>Highest-bytes DO sample seen — basis for the doSummary on a resumed completion.</summary>
|
||||
public OfficeDoPeakData? PeakDo { get; set; }
|
||||
|
||||
|
||||
+25
-19
@@ -22,11 +22,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// Pressure thresholds — once either is crossed within a session, a one-shot
|
||||
// `spool_pressure_detected` event is emitted with RequiresImmediateFlush=true
|
||||
// so the backend can surface the condition without waiting for the next snapshot
|
||||
// to be queried out of Storage. Sized so a healthy 6h provisioning session never
|
||||
// trips them; tripping is a strong "upload backlog is growing or downstream stalled"
|
||||
// signal worth investigating in the field.
|
||||
// to be queried out of Storage. Both measure the PENDING backlog (items not yet
|
||||
// marked uploaded), never the append-only spool file size — a healthy long session
|
||||
// grows the file past any fixed size while its backlog stays near zero (field data
|
||||
// 2026-08: 99/100 pressure events were file-size-only false positives). Tripping is
|
||||
// therefore a genuine "upload is stalled or falling behind" signal.
|
||||
internal const int PressurePendingItemThreshold = 2000;
|
||||
internal const long PressureFileSizeBytesThreshold = 5L * 1024 * 1024; // 5 MB
|
||||
internal const long PressurePendingBytesThreshold = 5L * 1024 * 1024; // 5 MB
|
||||
|
||||
private readonly string _agentVersion;
|
||||
private readonly NetworkMetrics _networkMetrics;
|
||||
@@ -77,11 +79,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
// Full path writes 19 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (4) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=19 →
|
||||
// HashHelpers.GetPrime(19)=23 buckets → no resize on the 19th key.
|
||||
var data = new Dictionary<string, object>(capacity: 19, StringComparer.Ordinal)
|
||||
// Full path writes 20 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (5) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=20 →
|
||||
// HashHelpers.GetPrime(20)=23 buckets → no resize on the 20th key.
|
||||
var data = new Dictionary<string, object>(capacity: 20, StringComparer.Ordinal)
|
||||
{
|
||||
{ "agent_version", _agentVersion }
|
||||
};
|
||||
@@ -126,17 +128,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// EnrollmentOrchestrator.Start into IComponentFactory.CreateCollectorHosts
|
||||
// (ARCH-F4). Null only on test fakes that don't construct a real spool.
|
||||
int pendingItemCount = 0;
|
||||
long pendingBytes = 0L;
|
||||
long spoolFileSizeBytes = 0L;
|
||||
if (_telemetrySpool != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
pendingItemCount = _telemetrySpool.PendingItemCount;
|
||||
pendingBytes = _telemetrySpool.PendingBytes;
|
||||
spoolFileSizeBytes = _telemetrySpool.SpoolFileSizeBytes;
|
||||
var peakPending = _telemetrySpool.PeakPendingItemCount;
|
||||
var totalEnqueued = _telemetrySpool.LastAssignedItemId + 1; // -1 sentinel → 0
|
||||
|
||||
data["spool_pending_item_count"] = pendingItemCount;
|
||||
data["spool_pending_bytes"] = pendingBytes;
|
||||
data["spool_peak_pending_item_count"] = peakPending;
|
||||
data["spool_file_size_bytes"] = spoolFileSizeBytes;
|
||||
data["spool_total_enqueued_count"] = totalEnqueued;
|
||||
@@ -194,20 +199,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
});
|
||||
}
|
||||
|
||||
// One-shot pressure event \u2014 fires once per session when the spool grows past
|
||||
// either threshold. ImmediateUpload=true so it shows up promptly on the
|
||||
// backend without waiting for the next batch drain.
|
||||
// One-shot pressure event \u2014 fires once per session when the pending backlog
|
||||
// grows past either threshold. ImmediateUpload=true so it shows up promptly on
|
||||
// the backend without waiting for the next batch drain.
|
||||
if (_telemetrySpool != null
|
||||
&& (pendingItemCount > PressurePendingItemThreshold
|
||||
|| spoolFileSizeBytes > PressureFileSizeBytesThreshold)
|
||||
|| pendingBytes > PressurePendingBytesThreshold)
|
||||
&& Interlocked.CompareExchange(ref _pressureEmitted, 1, 0) == 0)
|
||||
{
|
||||
var pressureData = new Dictionary<string, object>(capacity: 8, StringComparer.Ordinal)
|
||||
{
|
||||
{ "pendingItemCount", pendingItemCount },
|
||||
{ "fileSizeBytes", spoolFileSizeBytes },
|
||||
{ "pendingBytes", pendingBytes },
|
||||
{ "fileSizeBytes", spoolFileSizeBytes }, // informational only, not a trigger
|
||||
{ "pendingThreshold", PressurePendingItemThreshold },
|
||||
{ "fileSizeThresholdBytes", PressureFileSizeBytesThreshold },
|
||||
{ "pendingBytesThreshold", PressurePendingBytesThreshold },
|
||||
{ "totalEnqueuedCount", _telemetrySpool.LastAssignedItemId + 1 },
|
||||
{ "lastUploadedItemId", _telemetrySpool.LastUploadedItemId },
|
||||
{ "ImmediateUpload", true }
|
||||
@@ -222,15 +228,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "AgentSelfMetricsCollector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Telemetry spool pressure detected: pending={pendingItemCount}, " +
|
||||
$"fileBytes={spoolFileSizeBytes} \u2014 upload may be stalled or session " +
|
||||
$"is unusually long.",
|
||||
Message = $"Telemetry spool pressure detected: pending={pendingItemCount} items / " +
|
||||
$"{pendingBytes} bytes not yet uploaded \u2014 upload is stalled or " +
|
||||
$"falling behind.",
|
||||
Data = pressureData
|
||||
});
|
||||
|
||||
Logger.Warning(
|
||||
$"AgentSelfMetricsCollector: spool pressure detected " +
|
||||
$"(pending={pendingItemCount}, fileBytes={spoolFileSizeBytes}). " +
|
||||
$"(pending={pendingItemCount}, pendingBytes={pendingBytes}). " +
|
||||
$"Emitted spool_pressure_detected (one-shot per session).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,12 +404,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
if (state.Stage.IsTerminal()) return false;
|
||||
if (state.Stage == SessionStage.Unknown || state.Stage == SessionStage.SessionStarted) return false;
|
||||
if (state.AccountSetupEnteredUtc == null) return false;
|
||||
|
||||
// WDP (afee7ae0): Device Preparation has no ESP, so neither the AccountSetup
|
||||
// anchor nor an ESP final exit is guaranteed to exist — its dead-end zone
|
||||
// begins at the real-user desktop instead (from there only Hello or a deadline
|
||||
// can move the session). Normally the DevicePrepCompletion backstop armed at
|
||||
// desktop arrival satisfies the invariant; this branch makes a regression visible.
|
||||
var isDevicePreparation = state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation;
|
||||
if (state.AccountSetupEnteredUtc == null && !isDevicePreparation) return false;
|
||||
|
||||
var deadEndZoneEntered =
|
||||
state.EspAdvisoryFailureRecordedUtc != null
|
||||
|| (state.EspFinalExitUtc != null
|
||||
&& state.EspFinalExitUtc.Value >= state.AccountSetupEnteredUtc.Value);
|
||||
|| (state.AccountSetupEnteredUtc != null
|
||||
&& state.EspFinalExitUtc != null
|
||||
&& state.EspFinalExitUtc.Value >= state.AccountSetupEnteredUtc.Value)
|
||||
|| (isDevicePreparation && state.DesktopArrivedUtc != null);
|
||||
if (!deadEndZoneEntered) return false;
|
||||
|
||||
foreach (var deadline in state.Deadlines)
|
||||
@@ -431,8 +440,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
["stage"] = state.Stage.ToString(),
|
||||
["stepIndex"] = state.StepIndex.ToString(CultureInfo.InvariantCulture),
|
||||
["signalOrdinal"] = signal.SessionSignalOrdinal.ToString(CultureInfo.InvariantCulture),
|
||||
["accountSetupEnteredUtc"] = state.AccountSetupEnteredUtc!.Value
|
||||
.ToString("o", CultureInfo.InvariantCulture),
|
||||
// Null on the WDP branch of IsParkedWithoutDeadline (no ESP anchor there).
|
||||
["accountSetupEnteredUtc"] = state.AccountSetupEnteredUtc?.Value
|
||||
.ToString("o", CultureInfo.InvariantCulture) ?? "",
|
||||
["signalsSeen"] = string.Join(",", census.SignalsSeen),
|
||||
["armedDeadlines"] = string.Join(",", state.Deadlines.Select(d => d.Name)),
|
||||
["dwellSeconds"] = _parkedTripwireDwell.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture),
|
||||
|
||||
@@ -109,6 +109,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var hosts = new List<ICollectorHost>();
|
||||
var collectors = _remoteConfig.Collectors ?? CollectorConfiguration.CreateDefault();
|
||||
|
||||
// WDP scenario gate (afee7ae0, 2026-08-18): Device Preparation has no ESP, so the
|
||||
// ESP-only machinery below (provisioning-status registry watcher, ESP policy-provider
|
||||
// stall tripwire, Autopilot-channel noise) burns cycles on registry keys that never
|
||||
// exist there — or worse, wakes up on every WDP progress write. Gated on the
|
||||
// DETERMINISTIC WDP marker only (BootstrapperAgent ExecutionContext, present before
|
||||
// the agent starts); the CloudAssigned* fallback rules keep full Classic behavior.
|
||||
var isDevicePreparation = Security.EnrollmentRegistryDetector.IsDeterministicDevicePreparation();
|
||||
if (isDevicePreparation)
|
||||
{
|
||||
logger.Info("DefaultComponentFactory: Device Preparation session (deterministic marker) — ESP-only hosts and Autopilot-channel noise are gated");
|
||||
}
|
||||
|
||||
// ----- Kernel hosts (always-on; they produce decision signals) --------------------
|
||||
|
||||
// Single-rail refactor (plan §5.8) — DeviceInfoCollector existed in V2.Core but had
|
||||
@@ -151,6 +163,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
modernDeploymentBackfillLookbackMinutes: collectors.ModernDeploymentBackfillLookbackMinutes,
|
||||
modernDeploymentHarmlessEventIds: collectors.ModernDeploymentHarmlessEventIds,
|
||||
stateDirectory: _stateDirectory,
|
||||
isDevicePreparation: isDevicePreparation,
|
||||
userEspAppsSettledProbe: () => imeLogHostRef?.AreUserEspAppsSettled() == true,
|
||||
// Liveness plan PR3: starved-apps probe over the same lazily-assigned IME host.
|
||||
starvedUserEspAppsProbe: () =>
|
||||
@@ -308,7 +321,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
traceIndices: collectors.StallProbeTraceIndices,
|
||||
sources: collectors.StallProbeSources,
|
||||
sessionStalledAfterProbeIndex: collectors.SessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds));
|
||||
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds,
|
||||
isDevicePreparation: isDevicePreparation));
|
||||
}
|
||||
|
||||
// ESP policy-provider stall tripwire — always-on kernel host (no config gate, like
|
||||
@@ -317,14 +331,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// TrackingPoliciesCreated=1 the ESP ignores foreign provider names), parks the user
|
||||
// ESP at "Apps (Identifying)" WITHOUT any OS timeout. Wall-clock dwell, deliberately
|
||||
// outside the StallProbe gate: the idle clock there resets on any session activity,
|
||||
// and the stall must accrue regardless of it.
|
||||
hosts.Add(new EspPolicyProviderStallHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
startupGate: _startupEventGate));
|
||||
// and the stall must accrue regardless of it. Not created on Device Preparation —
|
||||
// the EnrollmentStatusTracking key it polls every 60s is an ESP-only construct.
|
||||
if (!isDevicePreparation)
|
||||
{
|
||||
hosts.Add(new EspPolicyProviderStallHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
startupGate: _startupEventGate));
|
||||
}
|
||||
|
||||
// Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from
|
||||
// HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage,
|
||||
|
||||
@@ -70,6 +70,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int modernDeploymentBackfillLookbackMinutes,
|
||||
int[]? modernDeploymentHarmlessEventIds,
|
||||
string stateDirectory,
|
||||
bool isDevicePreparation = false,
|
||||
Func<bool>? userEspAppsSettledProbe = null,
|
||||
Func<System.Collections.Generic.IReadOnlyList<Monitoring.Enrollment.Ime.AppPackageState>>? starvedUserEspAppsProbe = null,
|
||||
Func<System.Collections.Generic.IReadOnlyList<Monitoring.Enrollment.Ime.AppPackageState>>? packageStatesProbe = null)
|
||||
@@ -89,6 +90,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds,
|
||||
isDevicePreparation: isDevicePreparation,
|
||||
userEspAppsSettledProbe: userEspAppsSettledProbe,
|
||||
starvedUserEspAppsProbe: starvedUserEspAppsProbe,
|
||||
packageStatesProbe: packageStatesProbe);
|
||||
|
||||
@@ -57,7 +57,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int[]? traceIndices,
|
||||
string[]? sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[]? harmlessModernDeploymentEventIds)
|
||||
int[]? harmlessModernDeploymentEventIds,
|
||||
bool isDevicePreparation = false)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -72,7 +73,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
traceIndices: traceIndices ?? new[] { 2 },
|
||||
sources: sources ?? new[] { "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log" },
|
||||
sessionStalledAfterProbeIndex: sessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds);
|
||||
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds,
|
||||
isDevicePreparation: isDevicePreparation);
|
||||
|
||||
_adapter = new StallProbeCollectorAdapter(_collector, ingress, clock);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
|
||||
@@ -188,6 +188,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// </summary>
|
||||
public static string DetectEnrollmentType() => DetectEnrollmentTypeWithRule().enrollmentType;
|
||||
|
||||
/// <summary>
|
||||
/// Rule string returned when the deterministic Device Preparation marker fired
|
||||
/// (the WDP policy orchestration tree — see <see cref="DetectEnrollmentTypeWithRule"/>).
|
||||
/// </summary>
|
||||
public const string DevicePreparationExecutionContextRule =
|
||||
@"DevicePreparation\BootstrapperAgent\ExecutionContext present";
|
||||
|
||||
/// <summary>
|
||||
/// True only when the deterministic Device Preparation marker (BootstrapperAgent
|
||||
/// ExecutionContext) is present. Scenario gates that DISABLE Autopilot/ESP-only
|
||||
/// machinery must use this instead of the "v2" string: the CloudAssigned* fallback
|
||||
/// rules describe a real Autopilot profile (just without ESP) where that machinery
|
||||
/// still has data to observe — gates fail toward Classic behavior by design.
|
||||
/// </summary>
|
||||
public static bool IsDeterministicDevicePreparation()
|
||||
{
|
||||
var (enrollmentType, detectionRule) = DetectEnrollmentTypeWithRule();
|
||||
return enrollmentType == "v2" && detectionRule == DevicePreparationExecutionContextRule;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="DetectEnrollmentType"/> but also returns WHICH rule fired, so the
|
||||
/// enrollment_type_detected event can name its evidence instead of re-implementing the
|
||||
@@ -203,7 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
var executionContext = wdpKey?.GetValue("ExecutionContext")?.ToString();
|
||||
if (!string.IsNullOrEmpty(executionContext))
|
||||
return ("v2", @"DevicePreparation\BootstrapperAgent\ExecutionContext present");
|
||||
return ("v2", DevicePreparationExecutionContextRule);
|
||||
}
|
||||
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotSettingsKey))
|
||||
|
||||
+92
-4
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
@@ -56,6 +57,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
public sealed class EnrollmentTerminationHandler
|
||||
{
|
||||
private static readonly TimeSpan DefaultLateEventGrace = TimeSpan.FromMilliseconds(2000);
|
||||
|
||||
/// <summary>
|
||||
/// Session bc803955 — on DevicePreparation the IME user-context app sync starts only
|
||||
/// after desktop arrival and its log lines reach disk with 10-20s flush latency, so the
|
||||
/// dialog snapshot raced it and rendered an empty Applications section 9 seconds before
|
||||
/// the tracker learned about 12 pending apps. Bounded settle wait before the summary
|
||||
/// build; DevicePrep-only and only while the tracker has zero packages, so Classic (data
|
||||
/// long settled) and populated DevicePrep sessions pay nothing. Installs are never
|
||||
/// delayed — this only holds the dialog snapshot.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan DefaultDevicePrepAppSettleWait = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan AppSettlePollInterval = TimeSpan.FromMilliseconds(250);
|
||||
// Option 1 (WG Part 1 graceful-exit hardening, 2026-04-30): poll cadence for the
|
||||
// active spool-empty drain. 50ms is short enough that we exit the bounded wait
|
||||
// within ~one cadence after the last upload is acknowledged, but long enough that
|
||||
@@ -90,6 +103,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
private readonly AgentAnalyzerManager _analyzerManager;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly TimeSpan _lateEventGracePeriod;
|
||||
private readonly TimeSpan _devicePrepAppSettleWait;
|
||||
|
||||
// L4 (delta review 2026-07-02): epoch anchor for boot-time derivation. Captured as a
|
||||
// (monotonic tick, wall clock) pair at construction so the boot timestamp used by the
|
||||
@@ -113,7 +127,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
AgentAnalyzerManager analyzerManager = null,
|
||||
InformationalEventPost post = null,
|
||||
Action stopPeripheralCollectors = null,
|
||||
TimeSpan? lateEventGracePeriod = null)
|
||||
TimeSpan? lateEventGracePeriod = null,
|
||||
TimeSpan? devicePrepAppSettleWait = null)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_appTracking = appTracking ?? throw new ArgumentNullException(nameof(appTracking));
|
||||
@@ -126,6 +141,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
_post = post;
|
||||
_stopPeripheralCollectors = stopPeripheralCollectors;
|
||||
_lateEventGracePeriod = lateEventGracePeriod ?? DefaultLateEventGrace;
|
||||
_devicePrepAppSettleWait = devicePrepAppSettleWait ?? DefaultDevicePrepAppSettleWait;
|
||||
|
||||
_configuration = session.Configuration;
|
||||
_stateDirectory = session.StateDirectory;
|
||||
@@ -182,6 +198,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
else
|
||||
{
|
||||
// Session bc803955 — give the IME user-context app wave a bounded chance
|
||||
// to land BEFORE the dialog snapshot, the starved sweep and the
|
||||
// app_tracking_summary below all read package states. DevicePrep-only,
|
||||
// no-op when the tracker already has data.
|
||||
WaitForImeAppSettleIfDevicePrep(state);
|
||||
RunBuildAndLaunchDialog(state, args);
|
||||
}
|
||||
}
|
||||
@@ -207,7 +228,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// Runs on every outcome; the live (esp_exited) path's dedupe set is
|
||||
// consulted so no app is reported twice. Promoted likely-stuck apps are
|
||||
// Error by now and excluded by the probe itself.
|
||||
EmitStarvedUserEspApps(args);
|
||||
EmitStarvedUserEspApps(state, args);
|
||||
|
||||
// Session 6b4993e5 / fc48c71a — on an ESP terminal Apps-subcategory failure,
|
||||
// name the in-flight device app(s) (e.g. stuck Downloading at 0%) as the likely
|
||||
@@ -378,6 +399,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded settle wait for the IME app wave on DevicePreparation sessions (see
|
||||
/// <see cref="DefaultDevicePrepAppSettleWait"/>). Returns immediately for every other
|
||||
/// scenario mode and whenever the tracker already reports packages. Poll is cheap
|
||||
/// (in-memory read-model access), so a fine 250ms interval keeps the early exit tight.
|
||||
/// </summary>
|
||||
private void WaitForImeAppSettleIfDevicePrep(DecisionState state)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (state == null || _devicePrepAppSettleWait <= TimeSpan.Zero) return;
|
||||
if (state.ScenarioProfile.Mode != EnrollmentMode.DevicePreparation) return;
|
||||
|
||||
var packages = TryGetPackageStates();
|
||||
if (packages != null && packages.Count > 0) return;
|
||||
|
||||
_logger.Info(
|
||||
$"EnrollmentTerminationHandler: DevicePreparation session with no IME app data yet — waiting up to " +
|
||||
$"{_devicePrepAppSettleWait.TotalSeconds:0}s for the user-context app wave before building the summary " +
|
||||
"(installs are not delayed; this only holds the dialog snapshot).");
|
||||
|
||||
var deadline = DateTime.UtcNow + _devicePrepAppSettleWait;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
Thread.Sleep(AppSettlePollInterval);
|
||||
packages = TryGetPackageStates();
|
||||
if (packages != null && packages.Count > 0)
|
||||
{
|
||||
_logger.Info($"EnrollmentTerminationHandler: IME app data arrived during settle wait ({packages.Count} package(s)).");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info("EnrollmentTerminationHandler: app settle wait elapsed without IME app data — summary reflects Office/CSP state only.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTerminationHandler: app settle wait failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-soft read of the Office C2R lifecycle state persisted by
|
||||
/// <see cref="OfficeInstallDetector"/> — feeds the summary dialog's synthetic
|
||||
/// "Microsoft 365 Apps" row (Office CSP installs never flow through the IME Win32
|
||||
/// pipeline, so the package-state list cannot represent them).
|
||||
/// </summary>
|
||||
private OfficeInstallStateData TryLoadOfficeInstallState()
|
||||
{
|
||||
try { return new OfficeInstallStatePersistence(_stateDirectory, _logger).Load(); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private void RunBuildAndLaunchDialog(DecisionState state, EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
try
|
||||
@@ -385,7 +459,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
var packages = TryGetPackageStates();
|
||||
var timings = TryGetAppTimings();
|
||||
var status = FinalStatusBuilder.Build(state, args, packages, _agentStartTimeUtc, timings,
|
||||
deviceBootUtc: ObservationCoverage.DeviceBootUtc(_bootAnchorTickMs, _bootAnchorUtc));
|
||||
deviceBootUtc: ObservationCoverage.DeviceBootUtc(_bootAnchorTickMs, _bootAnchorUtc),
|
||||
officeState: TryLoadOfficeInstallState());
|
||||
SummaryDialogLauncher.WriteAndLaunch(status, _configuration, _stateDirectory, _logger, _dialogExePathOverride);
|
||||
|
||||
if (_configuration.ShowEnrollmentSummary)
|
||||
@@ -482,7 +557,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
/// wording; Failed/TimedOut keep the Warning + "starved" verdict.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void EmitStarvedUserEspApps(EnrollmentTerminatedEventArgs args)
|
||||
private void EmitStarvedUserEspApps(DecisionState state, EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
if (_post == null) return;
|
||||
|
||||
@@ -491,6 +566,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
var starved = _appTracking.GetStarvedUserEspApps();
|
||||
if (starved == null || starved.Count == 0) return;
|
||||
|
||||
// Device Preparation has no ESP apps gate — installing after the desktop is
|
||||
// the DESIGNED order there (only DPP-policy apps run during provisioning, the
|
||||
// rest follow via IME). Reporting every pending app as "starved" turned a
|
||||
// normal WDP session into 10+ noise events (afee7ae0); the same per-app data
|
||||
// still reaches the backend via app_tracking_summary and final-status.json.
|
||||
if (state?.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation)
|
||||
{
|
||||
_logger.Info(
|
||||
$"EnrollmentTerminationHandler: {starved.Count} app(s) pending at termination on a " +
|
||||
"DevicePreparation session — expected post-desktop install order, app_install_starved sweep skipped.");
|
||||
return;
|
||||
}
|
||||
|
||||
var succeeded = args.Outcome == EnrollmentTerminationOutcome.Succeeded;
|
||||
|
||||
foreach (var app in starved)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
@@ -30,7 +31,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
IReadOnlyList<AppPackageState>? packageStates,
|
||||
DateTime agentStartTimeUtc,
|
||||
IReadOnlyDictionary<string, AppInstallTiming>? appTimings = null,
|
||||
DateTime? deviceBootUtc = null)
|
||||
DateTime? deviceBootUtc = null,
|
||||
OfficeInstallStateData? officeState = null)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
if (terminated == null) throw new ArgumentNullException(nameof(terminated));
|
||||
@@ -78,9 +80,85 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
PackageStatesByPhase = BuildPackageStatesByPhase(packageStates, timings),
|
||||
};
|
||||
|
||||
AddOfficeRow(status, officeState);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session bc803955 — the Office C2R install (Office CSP app type / M365 "CSP" package)
|
||||
/// never flows through the IME Win32 pipeline, so <see cref="BuildAppSummary"/> cannot see
|
||||
/// it and a DevicePreparation session whose only app activity is Office rendered
|
||||
/// "No application data available". Synthesize one package row from the
|
||||
/// <see cref="OfficeInstallDetector"/>'s persisted lifecycle state instead. Preinstalled
|
||||
/// is deliberately skipped — that state marks resident OEM/consumer Office, not an
|
||||
/// enrollment install.
|
||||
/// </summary>
|
||||
private static void AddOfficeRow(FinalStatus status, OfficeInstallStateData? officeState)
|
||||
{
|
||||
if (officeState == null) return;
|
||||
|
||||
string rowState;
|
||||
bool isCompleted = false, isError = false;
|
||||
switch (officeState.State)
|
||||
{
|
||||
case OfficeInstallStateData.StateActive:
|
||||
rowState = AppInstallationState.Installing.ToString();
|
||||
break;
|
||||
case OfficeInstallStateData.StateCompleted:
|
||||
rowState = AppInstallationState.Installed.ToString();
|
||||
isCompleted = true;
|
||||
break;
|
||||
case OfficeInstallStateData.StateFailed:
|
||||
rowState = AppInstallationState.Error.ToString();
|
||||
isError = true;
|
||||
isCompleted = true; // mirrors IsCompleted(): Error counts as terminal
|
||||
break;
|
||||
default:
|
||||
return; // Preinstalled / unknown — not an enrollment install
|
||||
}
|
||||
|
||||
double? duration = officeState.StartedAtUtc.HasValue && officeState.CompletedAtUtc.HasValue
|
||||
? Math.Max(0, (officeState.CompletedAtUtc.Value - officeState.StartedAtUtc.Value).TotalSeconds)
|
||||
: (double?)null;
|
||||
|
||||
var name = string.IsNullOrEmpty(officeState.VersionReached)
|
||||
? "Microsoft 365 Apps"
|
||||
: $"Microsoft 365 Apps ({officeState.VersionReached})";
|
||||
|
||||
var row = new FinalStatusPackageInfo
|
||||
{
|
||||
AppName = name,
|
||||
State = rowState,
|
||||
IsError = isError,
|
||||
IsCompleted = isCompleted,
|
||||
Targeted = AppTargeted.Device.ToString(),
|
||||
StartedAt = officeState.StartedAtUtc?.ToString("o"),
|
||||
CompletedAt = officeState.CompletedAtUtc?.ToString("o"),
|
||||
DurationSeconds = duration,
|
||||
};
|
||||
|
||||
var phaseKey = AppTargeted.Device.ToString();
|
||||
if (!status.PackageStatesByPhase.TryGetValue(phaseKey, out var bucket))
|
||||
{
|
||||
bucket = new List<FinalStatusPackageInfo>();
|
||||
status.PackageStatesByPhase[phaseKey] = bucket;
|
||||
}
|
||||
// Office installs during device prep / device ESP — before any IME-tracked app.
|
||||
bucket.Insert(0, row);
|
||||
|
||||
var summary = status.AppSummary;
|
||||
summary.TotalApps++;
|
||||
if (isCompleted) summary.CompletedApps++;
|
||||
if (isError)
|
||||
{
|
||||
summary.ErrorCount++;
|
||||
summary.DeviceErrors++;
|
||||
}
|
||||
if (!summary.AppsByPhase.TryGetValue(phaseKey, out var cnt)) cnt = 0;
|
||||
summary.AppsByPhase[phaseKey] = cnt + 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schema 2 — derives a human-readable explanation rendered as the dialog's failure
|
||||
/// banner. Returns <c>null</c> for successful terminal outcomes (succeeded /
|
||||
|
||||
@@ -59,10 +59,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// </summary>
|
||||
int PeakPendingItemCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Serialized on-disk byte size of the pending (not-yet-uploaded) tail — the actual
|
||||
/// upload backlog. Unlike <see cref="SpoolFileSizeBytes"/>, which grows append-only
|
||||
/// over the whole session regardless of upload progress, this shrinks back to 0 as
|
||||
/// <see cref="MarkUploaded"/> advances the cursor, so it is the correct input for
|
||||
/// back-pressure thresholds.
|
||||
/// </summary>
|
||||
long PendingBytes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current size of the on-disk <c>spool.jsonl</c> file in bytes. Returns 0 when the
|
||||
/// file does not yet exist. Cheap (single <c>FileInfo.Length</c> call) and safe to
|
||||
/// poll from periodic collectors.
|
||||
/// poll from periodic collectors. Append-only cumulative — informational only, NOT a
|
||||
/// backlog measure (use <see cref="PendingBytes"/> for that).
|
||||
/// </summary>
|
||||
long SpoolFileSizeBytes { get; }
|
||||
|
||||
|
||||
@@ -56,9 +56,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
// Populated from disk in the ctor and kept in sync by Enqueue / MarkUploaded. Peek
|
||||
// reads from here so the periodic drain loop is O(batchSize) instead of O(N) per
|
||||
// call. Disk JSONL stays the source of truth — the cache is rebuilt on every
|
||||
// process start.
|
||||
private readonly LinkedList<TelemetryItem> _pending = new LinkedList<TelemetryItem>();
|
||||
// process start. Each entry carries its on-disk line length (incl. trailing \n) so
|
||||
// MarkUploaded can decrement _pendingBytes exactly.
|
||||
private readonly LinkedList<PendingEntry> _pending = new LinkedList<PendingEntry>();
|
||||
private int _peakPendingCount;
|
||||
private long _pendingBytes;
|
||||
|
||||
private readonly struct PendingEntry
|
||||
{
|
||||
public PendingEntry(TelemetryItem item, int byteLength)
|
||||
{
|
||||
Item = item;
|
||||
ByteLength = byteLength;
|
||||
}
|
||||
|
||||
public TelemetryItem Item { get; }
|
||||
public int ByteLength { get; }
|
||||
}
|
||||
|
||||
public TelemetrySpool(string directoryPath, IClock clock, Logging.AgentLogger? logger = null)
|
||||
{
|
||||
@@ -105,6 +119,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
get { lock (_lock) return _peakPendingCount; }
|
||||
}
|
||||
|
||||
public long PendingBytes
|
||||
{
|
||||
get { lock (_lock) return _pendingBytes; }
|
||||
}
|
||||
|
||||
public long SpoolFileSizeBytes
|
||||
{
|
||||
get
|
||||
@@ -171,7 +190,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
}
|
||||
|
||||
_lastAssignedItemId = itemId;
|
||||
_pending.AddLast(item);
|
||||
_pending.AddLast(new PendingEntry(item, bytes.Length));
|
||||
_pendingBytes += bytes.Length;
|
||||
if (_pending.Count > _peakPendingCount)
|
||||
_peakPendingCount = _pending.Count;
|
||||
|
||||
@@ -209,7 +229,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
var node = _pending.First;
|
||||
while (node != null && batch.Count < max)
|
||||
{
|
||||
batch.Add(node.Value);
|
||||
batch.Add(node.Value.Item);
|
||||
node = node.Next;
|
||||
}
|
||||
return batch;
|
||||
@@ -233,25 +253,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
// Trim the in-memory tail. The list is in TelemetryItemId-monotonic order
|
||||
// (Enqueue only appends), so we can drop from the front until the first
|
||||
// remaining item is past the cursor.
|
||||
while (_pending.First != null && _pending.First.Value.TelemetryItemId <= upToItemIdInclusive)
|
||||
while (_pending.First != null && _pending.First.Value.Item.TelemetryItemId <= upToItemIdInclusive)
|
||||
{
|
||||
_pendingBytes -= _pending.First.Value.ByteLength;
|
||||
_pending.RemoveFirst();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-pass scan of <c>spool.jsonl</c>: returns the highest <c>TelemetryItemId</c>
|
||||
/// found AND populates <see cref="_pending"/> with every item past
|
||||
/// <see cref="_lastUploadedItemId"/>. Called once from the ctor — replaces the
|
||||
/// previous per-Peek <c>File.ReadAllLines</c> hot path.
|
||||
/// Single-pass streaming scan of <c>spool.jsonl</c> (<c>File.ReadLines</c> — never
|
||||
/// materializes the whole file): returns the highest <c>TelemetryItemId</c> found AND
|
||||
/// populates <see cref="_pending"/> / <see cref="_pendingBytes"/> with every item past
|
||||
/// <see cref="_lastUploadedItemId"/>. Called once from the ctor.
|
||||
/// </summary>
|
||||
private long ScanSpoolAndRehydratePending()
|
||||
{
|
||||
if (!File.Exists(_spoolPath)) return -1;
|
||||
|
||||
long highest = -1;
|
||||
foreach (var line in File.ReadAllLines(_spoolPath, Encoding.UTF8))
|
||||
foreach (var line in File.ReadLines(_spoolPath, Encoding.UTF8))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
TelemetryItem item;
|
||||
@@ -270,7 +291,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
if (item.TelemetryItemId > highest) highest = item.TelemetryItemId;
|
||||
if (item.TelemetryItemId > _lastUploadedItemId)
|
||||
{
|
||||
_pending.AddLast(item);
|
||||
// +1 = the trailing \n Enqueue writes after every line, so the rehydrated
|
||||
// byte count matches what Enqueue would have accumulated.
|
||||
var byteLength = Encoding.UTF8.GetByteCount(line) + 1;
|
||||
_pending.AddLast(new PendingEntry(item, byteLength));
|
||||
_pendingBytes += byteLength;
|
||||
}
|
||||
}
|
||||
return highest;
|
||||
|
||||
@@ -306,7 +306,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{
|
||||
try
|
||||
{
|
||||
var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType();
|
||||
var (enrollmentType, detectionRule) = EnrollmentRegistryDetector.DetectEnrollmentTypeWithRule();
|
||||
// High-confidence WDP seed only for the deterministic marker — the
|
||||
// CloudAssigned* fallback rules keep the Medium seed (see the payload
|
||||
// key's doc on SignalPayloadKeys.EnrollmentTypeDeterministic).
|
||||
var deterministicWdp = enrollmentType == "v2"
|
||||
&& detectionRule == EnrollmentRegistryDetector.DevicePreparationExecutionContextRule;
|
||||
var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin();
|
||||
var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile();
|
||||
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
|
||||
@@ -314,6 +319,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["enrollmentType"] = enrollmentType,
|
||||
["enrollmentTypeDeterministic"] = deterministicWdp ? "true" : "false",
|
||||
["isHybridJoin"] = isHybridJoin ? "true" : "false",
|
||||
["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false",
|
||||
["isCloudPc"] = isCloudPc ? "true" : "false",
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Device Preparation (WDP) engine semantics — afee7ae0 audit (2026-08-18).
|
||||
/// WDP has no ESP, so the ESP-derived arms/gates/deadlines can never fire there.
|
||||
/// Coverage:
|
||||
/// <list type="bullet">
|
||||
/// <item>Profile seeding: the deterministic WDP marker seeds High confidence; the
|
||||
/// CloudAssigned* fallback rules stay Medium.</item>
|
||||
/// <item><c>completion_waiting</c> never lists <c>account_setup_provisioning_complete</c>
|
||||
/// on WDP (unsatisfiable by construction — no ESP categories).</item>
|
||||
/// <item>Desktop-first arming of the <see cref="DeadlineNames.DevicePrepCompletion"/>
|
||||
/// backstop, its fire semantics (synthetic Hello timeout + Finalizing), and the
|
||||
/// no-rearm/no-classic-leak guards.</item>
|
||||
/// <item>Arm D: the Hello-disabled fast-path completes at desktop arrival on WDP.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class DevicePreparationEngineTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 18, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null)
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
|
||||
|
||||
private static List<DecisionEffect> CompletionWaitingEffects(DecisionStep step) =>
|
||||
step.Effects.Where(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == "completion_waiting").ToList();
|
||||
|
||||
/// <summary>Session seeded as WDP via the EnrollmentFactsObserved signal rail.</summary>
|
||||
private static DecisionState SeedWdpSession(DecisionEngine engine, bool deterministic = true)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-wdp", "tenant-wdp", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
1, DecisionSignalKind.EnrollmentFactsObserved, T0,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v2",
|
||||
[SignalPayloadKeys.EnrollmentTypeDeterministic] = deterministic ? "true" : "false",
|
||||
})).NewState;
|
||||
return state;
|
||||
}
|
||||
|
||||
// ===================================================== profile seeding
|
||||
|
||||
[Fact]
|
||||
public void DeterministicV2Seed_SetsDevicePreparationAtHighConfidence()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine, deterministic: true);
|
||||
|
||||
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
|
||||
Assert.Equal(ProfileConfidence.High, state.ScenarioProfile.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallbackV2Seed_StaysAtMediumConfidence()
|
||||
{
|
||||
// CloudAssigned* fallback rules (no deterministic marker) — Medium, as before
|
||||
// d0ab3eee: an ESP-less classic profile could in principle trip them.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine, deterministic: false);
|
||||
|
||||
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
|
||||
Assert.Equal(ProfileConfidence.Medium, state.ScenarioProfile.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyV2Payload_WithoutDeterministicKey_StaysAtMediumConfidence()
|
||||
{
|
||||
// Older agents post enrollmentType=v2 without the new key — must behave like
|
||||
// the fallback (Medium), not throw or over-promote.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("sess-legacy", "tenant-wdp", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
1, DecisionSignalKind.EnrollmentFactsObserved, T0,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EnrollmentType] = "v2" })).NewState;
|
||||
|
||||
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
|
||||
Assert.Equal(ProfileConfidence.Medium, state.ScenarioProfile.Confidence);
|
||||
}
|
||||
|
||||
// ===================================================== desktop-first backstop
|
||||
|
||||
[Fact]
|
||||
public void DesktopFirst_OnWdp_ArmsDevicePrepCompletionBackstop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
// Hello policy unknown → no fast-path; stage parked, backstop armed.
|
||||
var backstop = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
Assert.Equal(T0.AddMinutes(9).AddMinutes(30), backstop.DueAtUtc);
|
||||
Assert.Contains(step.Effects,
|
||||
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.DevicePrepCompletion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesktopFirst_OnWdp_WaitingListsOnlyHello_NeverTheEspProvisioningGate()
|
||||
{
|
||||
// account_setup_provisioning_complete is unsatisfiable on WDP (no ESP categories)
|
||||
// and must not appear as a missing prerequisite (afee7ae0 listed it).
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
var waiting = Assert.Single(CompletionWaitingEffects(step));
|
||||
Assert.Equal("hello_resolution", waiting.Parameters!["missingPrerequisites"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateDesktopSignal_DoesNotRearmTheBackstop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
|
||||
|
||||
var second = engine.Reduce(state, MakeSignal(11, DecisionSignalKind.DesktopArrived, T0.AddMinutes(10)));
|
||||
|
||||
Assert.Single(second.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
Assert.DoesNotContain(second.Effects,
|
||||
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.DevicePrepCompletion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassicDesktopFirst_DoesNotArmTheBackstop()
|
||||
{
|
||||
// Classic regression guard: a non-WDP session parked desktop-first keeps the
|
||||
// pre-change deadline picture (nothing armed by this handler).
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("sess-classic", "tenant-classic", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
}
|
||||
|
||||
// ===================================================== backstop fire semantics
|
||||
|
||||
[Fact]
|
||||
public void BackstopFired_WithoutHello_SynthesizesTimeoutAndCompletesThroughFinalizing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
|
||||
|
||||
var fired = engine.Reduce(state, DeadlineFired(20, T0.AddMinutes(39), DeadlineNames.DevicePrepCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, fired.NewState.Stage);
|
||||
Assert.NotNull(fired.NewState.HelloResolvedUtc);
|
||||
Assert.Equal("Timeout", fired.NewState.HelloOutcome!.Value);
|
||||
Assert.DoesNotContain(fired.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
Assert.Contains(fired.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
|
||||
|
||||
// FinalizingGrace completes the session — the full WDP no-Hello resolution chain.
|
||||
var completed = engine.Reduce(fired.NewState,
|
||||
DeadlineFired(21, T0.AddMinutes(39).AddSeconds(5), DeadlineNames.FinalizingGrace));
|
||||
Assert.Equal(SessionStage.Completed, completed.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloResolved_BeforeBackstopFires_CompletesNormally()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
|
||||
|
||||
var resolved = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.HelloResolved, T0.AddMinutes(10),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "completed" }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, resolved.NewState.Stage);
|
||||
Assert.Equal("completed", resolved.NewState.HelloOutcome!.Value);
|
||||
}
|
||||
|
||||
// ===================================================== arm D fast-path
|
||||
|
||||
[Fact]
|
||||
public void HelloDisabledFastPath_OnWdp_CompletesAtDesktopArrival()
|
||||
{
|
||||
// Arm D: no ESP facts exist on WDP, yet a disabled Hello policy + real-user
|
||||
// desktop is a legitimate completion — previously arms A–C all failed and the
|
||||
// session parked despite Hello being disabled.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("Skipped", step.NewState.HelloOutcome!.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Queues;
|
||||
using Azure.Storage.Queues.Models;
|
||||
using AutopilotMonitor.Functions.Services.Ime;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Producer tests for the ime-msi-archive queue (daily review 2026-08-18: previously
|
||||
/// untested). Contract: the caller is the ingest path's fire-and-forget continuation, so
|
||||
/// NOTHING may throw — version-less envelopes are skipped, send failures are swallowed,
|
||||
/// and a failed CreateIfNotExists is retried on the next enqueue instead of latching.
|
||||
/// </summary>
|
||||
public class AzureQueueImeMsiArchiveProducerTests
|
||||
{
|
||||
private const string Version = "1.104.102.0";
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_sends_envelope_json_and_ensures_queue_once()
|
||||
{
|
||||
var (producer, queue, sentBodies) = BuildSut();
|
||||
|
||||
await producer.EnqueueAsync(Envelope());
|
||||
await producer.EnqueueAsync(Envelope());
|
||||
|
||||
Assert.Equal(2, sentBodies.Count);
|
||||
var roundTripped = JsonConvert.DeserializeObject<ImeMsiArchiveEnvelope>(sentBodies[0])!;
|
||||
Assert.Equal(Version, roundTripped.Version);
|
||||
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, roundTripped.MsiDownloadUrl);
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", roundTripped.TenantId);
|
||||
// Queue existence is ensured once, then latched — not re-checked per message.
|
||||
queue.Verify(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_skips_envelope_without_version()
|
||||
{
|
||||
var (producer, queue, sentBodies) = BuildSut();
|
||||
|
||||
await producer.EnqueueAsync(new ImeMsiArchiveEnvelope { Version = string.Empty });
|
||||
await producer.EnqueueAsync(null!);
|
||||
|
||||
Assert.Empty(sentBodies);
|
||||
queue.Verify(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_send_failure_is_swallowed()
|
||||
{
|
||||
var (producer, queue, _) = BuildSut();
|
||||
queue.Setup(q => q.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "queue busy"));
|
||||
|
||||
// Fire-and-forget continuation on the ingest path — must never throw.
|
||||
await producer.EnqueueAsync(Envelope());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_retries_queue_creation_after_failure()
|
||||
{
|
||||
var (producer, queue, sentBodies) = BuildSut();
|
||||
queue.SetupSequence(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(500, "storage down"))
|
||||
.ReturnsAsync((Response?)null);
|
||||
|
||||
await producer.EnqueueAsync(Envelope()); // ensure fails — not latched
|
||||
await producer.EnqueueAsync(Envelope()); // ensure retried and succeeds
|
||||
|
||||
queue.Verify(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Exactly(2));
|
||||
// Both messages were still attempted (fail-soft send after a failed ensure).
|
||||
Assert.Equal(2, sentBodies.Count);
|
||||
}
|
||||
|
||||
// ============================================================ Helpers ====
|
||||
|
||||
private static ImeMsiArchiveEnvelope Envelope() => new()
|
||||
{
|
||||
Version = Version,
|
||||
MsiDownloadUrl = ImeMsiArchiver.CanonicalMsiUrl,
|
||||
MsiMatchedBy = "productVersion",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static (AzureQueueImeMsiArchiveProducer Producer, Mock<QueueClient> Queue, List<string> SentBodies) BuildSut()
|
||||
{
|
||||
var queue = new Mock<QueueClient>();
|
||||
var sentBodies = new List<string>();
|
||||
|
||||
queue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
queue.Setup(q => q.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, CancellationToken>((body, _) =>
|
||||
{
|
||||
sentBodies.Add(body);
|
||||
var receipt = QueuesModelFactory.SendReceipt(
|
||||
"msg", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(7),
|
||||
"pop", DateTimeOffset.UtcNow);
|
||||
return Task.FromResult(Response.FromValue(receipt, new Mock<Response>().Object));
|
||||
});
|
||||
|
||||
var producer = new AzureQueueImeMsiArchiveProducer(
|
||||
queue.Object, NullLogger<AzureQueueImeMsiArchiveProducer>.Instance);
|
||||
return (producer, queue, sentBodies);
|
||||
}
|
||||
}
|
||||
@@ -247,4 +247,64 @@ public class CorporateIdentifierValidatorTests
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.IsTransient);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "Virtual Machine", Serial)]
|
||||
[InlineData("Microsoft Corporation", null, Serial)]
|
||||
[InlineData("Microsoft Corporation", "Virtual Machine", null)]
|
||||
[InlineData("", "Virtual Machine", Serial)]
|
||||
[InlineData("Microsoft Corporation", "", Serial)]
|
||||
[InlineData("Microsoft Corporation", "Virtual Machine", "")]
|
||||
[InlineData(" ", "Virtual Machine", Serial)]
|
||||
[InlineData("Microsoft Corporation", "Virtual Machine", " ")]
|
||||
public async Task ValidateAsync_missingHeaderComponent_isDefinitive_withoutGraphCall(
|
||||
string? manufacturer, string? model, string? serial)
|
||||
{
|
||||
// Agents that fail to read a hardware property send the header empty — that is a
|
||||
// definitive rejection (retries cannot conjure the value), and Graph must not be hit.
|
||||
var (sut, handler) = BuildSut(Json("""{"value":[]}"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, manufacturer, model, serial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
Assert.Contains("not provided", result.ErrorMessage);
|
||||
Assert.Empty(handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_specialCharsInSerial_neverReachOdataFilter()
|
||||
{
|
||||
// The contains() narrowing uses the NORMALIZED (alphanumeric-only) serial, so an
|
||||
// OData-breaking quote in a raw serial can neither corrupt nor inject into the filter.
|
||||
const string hostileSerial = "O'Brien-123'&$top=9999";
|
||||
var (sut, handler) = BuildSut(Json("""{"value":[]}"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", hostileSerial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
var url = Uri.UnescapeDataString(Assert.Single(handler.Requests).RequestUri!.ToString());
|
||||
Assert.Contains("contains(importedDeviceIdentifier,'OBRIEN123TOP9999')", url);
|
||||
Assert.DoesNotContain("O'Brien", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_pageCapExceeded_isTransient_andNotCached()
|
||||
{
|
||||
// Stopping mid-list must NOT be classified "not found" — a cached false negative would
|
||||
// block a legitimately registered device for the negative-cache TTL.
|
||||
var (sut, handler) = BuildSut(Json("""
|
||||
{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"DELL,XPS,OTHER1"}],
|
||||
"@odata.nextLink":"https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities?$skiptoken=more"}
|
||||
"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.IsTransient);
|
||||
Assert.Contains("page budget", result.ErrorMessage);
|
||||
// Filtered scan pages up to its cap of 5, and the in-call retry runs a second sweep —
|
||||
// proof the transient path went through the retry loop instead of caching.
|
||||
Assert.Equal(10, handler.Requests.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Queues;
|
||||
using Azure.Storage.Queues.Models;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Ime;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Worker-level tests for the ime-msi-archive queue consumer (daily review 2026-08-18:
|
||||
/// previously untested). The worker's contract: merge the archiver's outcome onto the
|
||||
/// ImeVersionHistory row on EVERY attempt (also failures, so operators see a stuck
|
||||
/// download), delete the message for terminal outcomes, rethrow for retryable ones
|
||||
/// (visibility-timeout ladder), pause — not drop — while ImeMsiArchivingEnabled is off,
|
||||
/// and drop version-less envelopes without invoking the archiver.
|
||||
/// </summary>
|
||||
public class ImeMsiArchiveQueueWorkerTests
|
||||
{
|
||||
private const string Version = "1.104.102.0";
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_success_merges_row_and_deletes_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
true, ImeMsiArchiver.Statuses.Archived, Retryable: false,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.CanonicalMsiUrl);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.Archived,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.CanonicalMsiUrl),
|
||||
Times.Once);
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_retryable_failure_merges_row_but_leaves_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedDownload, Retryable: true,
|
||||
null, null, null, ImeMsiArchiver.CanonicalMsiUrl);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.Archiver.Calls > 0);
|
||||
|
||||
// Row always tells the truth — the failure status IS merged...
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedDownload,
|
||||
null, null, null, ImeMsiArchiver.CanonicalMsiUrl),
|
||||
Times.AtLeastOnce);
|
||||
// ...but the message stays for the visibility-timeout retry → poison ladder.
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_permanent_failure_merges_row_and_completes_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedTooLarge, Retryable: false,
|
||||
null, null, 999L, ImeMsiArchiver.CanonicalMsiUrl);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedTooLarge,
|
||||
null, null, 999L, ImeMsiArchiver.CanonicalMsiUrl),
|
||||
Times.Once);
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_pauses_while_archiving_disabled()
|
||||
{
|
||||
var harness = new Harness(archivingEnabled: false);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// Pause, not drop: no receive, no archive attempt — the message parks in the queue.
|
||||
harness.MainQueue.Verify(q => q.ReceiveMessagesAsync(
|
||||
It.IsAny<int>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
Assert.Equal(0, harness.Archiver.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_drops_envelope_without_version_before_archiver()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(new ImeMsiArchiveEnvelope
|
||||
{
|
||||
Version = string.Empty,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
}));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
Assert.Equal(0, harness.Archiver.Calls);
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(),
|
||||
It.IsAny<string?>(), It.IsAny<long?>(), It.IsAny<string?>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
// ============================================================ Helpers ====
|
||||
|
||||
private static ImeMsiArchiveEnvelope Envelope() => new()
|
||||
{
|
||||
Version = Version,
|
||||
MsiDownloadUrl = ImeMsiArchiver.CanonicalMsiUrl,
|
||||
MsiMatchedBy = "productVersion",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
/// <summary>Scripted archiver — the worker only consumes the classified result.</summary>
|
||||
private sealed class StubArchiver : ImeMsiArchiver
|
||||
{
|
||||
private int _calls;
|
||||
|
||||
public ImeMsiArchiveResult Result { get; set; } = new(
|
||||
true, Statuses.Archived, Retryable: false, null, null, null, null);
|
||||
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
public override Task<ImeMsiArchiveResult> ArchiveAsync(
|
||||
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mock-queue poll-loop harness, same shape as SessionDeletionWorkerTests.</summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<QueueClient> MainQueue { get; }
|
||||
public Mock<QueueClient> PoisonQueue { get; }
|
||||
public StubArchiver Archiver { get; } = new();
|
||||
public Mock<ISessionRepository> Repo { get; } = new();
|
||||
public ImeMsiArchiveQueueWorker Sut { get; }
|
||||
|
||||
private readonly Queue<QueueMessage> _pendingMessages = new();
|
||||
|
||||
public Harness(bool archivingEnabled = true)
|
||||
{
|
||||
MainQueue = new Mock<QueueClient>();
|
||||
PoisonQueue = new Mock<QueueClient>();
|
||||
|
||||
MainQueue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
PoisonQueue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
|
||||
MainQueue.Setup(q => q.ReceiveMessagesAsync(
|
||||
It.IsAny<int>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<int, TimeSpan?, CancellationToken>((maxMessages, _, _) =>
|
||||
{
|
||||
var batch = new List<QueueMessage>();
|
||||
lock (_pendingMessages)
|
||||
{
|
||||
while (batch.Count < maxMessages && _pendingMessages.Count > 0)
|
||||
batch.Add(_pendingMessages.Dequeue());
|
||||
}
|
||||
return Task.FromResult(Response.FromValue(batch.ToArray(), new Mock<Response>().Object));
|
||||
});
|
||||
|
||||
MainQueue.Setup(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Mock<Response>().Object);
|
||||
|
||||
MainQueue.Setup(q => q.UpdateMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, string, TimeSpan, CancellationToken>((_, _, _, vis, _) =>
|
||||
{
|
||||
var updated = QueuesModelFactory.UpdateReceipt(
|
||||
"pop-extended-" + Guid.NewGuid().ToString("N"),
|
||||
DateTimeOffset.UtcNow.Add(vis));
|
||||
return Task.FromResult(Response.FromValue(updated, new Mock<Response>().Object));
|
||||
});
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { ImeMsiArchivingEnabled = archivingEnabled });
|
||||
|
||||
Sut = new ImeMsiArchiveQueueWorker(
|
||||
MainQueue.Object, PoisonQueue.Object,
|
||||
Archiver, Repo.Object, adminConfig.Object,
|
||||
NullLogger<ImeMsiArchiveQueueWorker>.Instance,
|
||||
pollInterval: TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
public void EnqueueMessage(string body)
|
||||
{
|
||||
var msg = QueuesModelFactory.QueueMessage(
|
||||
messageId: "msg-" + Guid.NewGuid().ToString("N"),
|
||||
popReceipt: "pop-" + Guid.NewGuid().ToString("N"),
|
||||
body: new BinaryData(body),
|
||||
dequeueCount: 1);
|
||||
lock (_pendingMessages) _pendingMessages.Enqueue(msg);
|
||||
}
|
||||
|
||||
public async Task RunForAsync(TimeSpan duration)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(duration);
|
||||
try { await Sut.StartAsync(cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
try { await Task.Delay(duration, cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
try { await Sut.StopAsync(CancellationToken.None); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
public async Task RunUntilAsync(Func<bool> condition, TimeSpan? timeout = null)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
|
||||
using var cts = new CancellationTokenSource();
|
||||
try { await Sut.StartAsync(cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(25);
|
||||
cts.Cancel();
|
||||
try { await Sut.StopAsync(CancellationToken.None); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
public bool MainQueueDeleted() =>
|
||||
MainQueue.Invocations.Any(i => i.Method.Name == nameof(QueueClient.DeleteMessageAsync));
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,7 @@ public class ImeMsiArchiverTests
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
UploadException = new RequestFailedException(409, "BlobAlreadyExists"),
|
||||
ProvenanceExists = true,
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
@@ -202,6 +203,101 @@ public class ImeMsiArchiverTests
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", result.BlobPath);
|
||||
// Sidecar already there — the re-delivery must not rewrite it.
|
||||
Assert.Equal(0, archiver.ProvenanceCalls);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Provenance-write gap (daily review 2026-08-18): MSI upload is write-once,
|
||||
// so a first attempt that dies between the MSI and the provenance upload
|
||||
// funnels every retry into the 409 path — which must heal the sidecar.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_ProvenanceUploadFails_RetryableError()
|
||||
{
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
ProvenanceException = new RequestFailedException(500, "storage hiccup"),
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedError, result.Status);
|
||||
Assert.True(result.Retryable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_RedeliveryWithMissingProvenance_HealsSidecarFromArchivedBytes()
|
||||
{
|
||||
// The archived blob's content deliberately differs from this attempt's re-download —
|
||||
// the healed provenance must describe the ARCHIVED bytes, not the fresh download.
|
||||
var archivedPayload = new byte[1024];
|
||||
new Random(7).NextBytes(archivedPayload);
|
||||
var expectedSha = Convert.ToHexString(SHA256.HashData(archivedPayload)).ToLowerInvariant();
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
UploadException = new RequestFailedException(409, "BlobAlreadyExists"),
|
||||
ProvenanceExists = false,
|
||||
ArchivedPayload = archivedPayload,
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal(expectedSha, result.Sha256);
|
||||
Assert.Equal(archivedPayload.Length, result.SizeBytes);
|
||||
Assert.Equal(1, archiver.ProvenanceCalls);
|
||||
Assert.Equal($"{Version}/provenance.json", archiver.ProvenanceLastPath);
|
||||
Assert.Contains(expectedSha, archiver.ProvenanceLastJson);
|
||||
Assert.Contains(Version, archiver.ProvenanceLastJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_ProvenanceFailure_ThenRedelivery_EndsHealed()
|
||||
{
|
||||
// THE regression scenario end to end on one instance: attempt 1 uploads the MSI but
|
||||
// the provenance write dies; attempt 2 (queue re-delivery) gets 409 on the write-once
|
||||
// MSI and must still produce the sidecar.
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
ProvenanceException = new RequestFailedException(500, "storage hiccup"),
|
||||
};
|
||||
var first = await archiver.ArchiveAsync(Envelope());
|
||||
Assert.False(first.Success);
|
||||
Assert.True(first.Retryable);
|
||||
|
||||
archiver.ProvenanceException = null;
|
||||
archiver.UploadException = new RequestFailedException(409, "BlobAlreadyExists");
|
||||
archiver.ProvenanceExists = false; // attempt 1 never landed the sidecar
|
||||
var second = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.True(second.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.Archived, second.Status);
|
||||
Assert.Equal($"{Version}/provenance.json", archiver.ProvenanceLastPath);
|
||||
Assert.NotNull(second.Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_HealFails_RetryableError()
|
||||
{
|
||||
// Heal errors ride the normal retry ladder — the next re-delivery tries again.
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
UploadException = new RequestFailedException(409, "BlobAlreadyExists"),
|
||||
ProvenanceExists = false,
|
||||
ArchivedOpenException = new RequestFailedException(500, "read failed"),
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedError, result.Status);
|
||||
Assert.True(result.Retryable);
|
||||
Assert.Equal(0, archiver.ProvenanceCalls);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -288,6 +384,10 @@ public class ImeMsiArchiverTests
|
||||
public int MaxDownloadSizeMB { get; set; } = 250;
|
||||
public Exception? DownloadException { get; set; }
|
||||
public Exception? UploadException { get; set; }
|
||||
public Exception? ProvenanceException { get; set; }
|
||||
public bool ProvenanceExists { get; set; } = true;
|
||||
public byte[]? ArchivedPayload { get; set; }
|
||||
public Exception? ArchivedOpenException { get; set; }
|
||||
|
||||
public int DownloadCalls { get; private set; }
|
||||
public string? DownloadLastUrl { get; private set; }
|
||||
@@ -326,10 +426,20 @@ public class ImeMsiArchiverTests
|
||||
|
||||
protected override Task UploadProvenanceAsync(string blobPath, string json, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ProvenanceException is not null) throw ProvenanceException;
|
||||
ProvenanceCalls++;
|
||||
ProvenanceLastPath = blobPath;
|
||||
ProvenanceLastJson = json;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<bool> ProvenanceExistsAsync(string blobPath, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(ProvenanceExists);
|
||||
|
||||
protected override Task<Stream> OpenArchivedMsiAsync(string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ArchivedOpenException is not null) throw ArchivedOpenException;
|
||||
return Task.FromResult<Stream>(new MemoryStream(ArchivedPayload ?? Payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,53 @@ public class TimeAttributionCalculatorTests
|
||||
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.PartialObservation));
|
||||
}
|
||||
|
||||
// ── prior-enrollment residue ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HistoricImeReplay_SetsPriorEnrollmentResidue_AndExcludesFromFleetStats()
|
||||
{
|
||||
// Re-enrollment without a wipe (session f475e697): the surviving IME log pulls the
|
||||
// phase anchors ahead of the real ESP page — the segments must be flagged as unfit.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddSeconds(2), "historic_ime_replay_detected"),
|
||||
Evt(T0.AddMinutes(1), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(T0.AddMinutes(10), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.PriorEnrollmentResidue));
|
||||
Assert.True(TimeAttributionFlagQuality.ExcludesFromFleetStats(b.QualityFlags));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegistryAppBaseline_Alone_DoesNotSetResidueFlag()
|
||||
{
|
||||
// A registry_app_baseline with successes is by-design normal for WDP (DPP Batch-1
|
||||
// apps install before the agent exists) — treating it as residue would starve the
|
||||
// device_preparation class's fleet aggregates. Only the IME-log replay signal counts.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddSeconds(2), "registry_app_baseline", data: new Dictionary<string, object>
|
||||
{
|
||||
["totalApps"] = "3", ["successCount"] = "3",
|
||||
["errorCount"] = "0", ["inProgressCount"] = "0",
|
||||
}),
|
||||
Evt(T0.AddMinutes(10), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.False(b.QualityFlags.HasFlag(TimeAttributionFlags.PriorEnrollmentResidue));
|
||||
}
|
||||
|
||||
// ── WhiteGlove: two windows in ONE session, pause never attributed ──────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -70,7 +70,9 @@ public sealed class TimeAttributionInput
|
||||
public static class TimeAttributionCalculator
|
||||
{
|
||||
/// <summary>Bump on any semantic change so aggregates never mix definitions (truthfulness rule 8).</summary>
|
||||
public const int CurrentVersion = 1;
|
||||
// v2: PriorEnrollmentResidue flag — sessions whose phase anchors are distorted by
|
||||
// pre-enrollment state on disk are now flagged and excluded from fleet segment stats.
|
||||
public const int CurrentVersion = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Enrollment class for fleet aggregation — classes are NEVER mixed in one aggregate (a
|
||||
@@ -164,6 +166,15 @@ public static class TimeAttributionCalculator
|
||||
if (events.Any(e => e.EventType == Constants.EventTypes.AgentLateStart))
|
||||
flags |= TimeAttributionFlags.PartialObservation;
|
||||
|
||||
// Prior-enrollment residue (session f475e697): an IME log predating this enrollment
|
||||
// means the disk was not wiped — pre-installed apps complete as instant detections and
|
||||
// pull the phase anchors ahead of the real ESP page, so the segment durations lie.
|
||||
// historic_ime_replay_detected is the ONLY trigger on purpose: registry_app_baseline
|
||||
// with successes is by-design normal for WDP (DPP Batch-1 apps install before the
|
||||
// agent exists) and would starve that class's aggregates (see DurationCriticalFlags).
|
||||
if (events.Any(e => e.EventType == Constants.EventTypes.HistoricImeReplayDetected))
|
||||
flags |= TimeAttributionFlags.PriorEnrollmentResidue;
|
||||
|
||||
return new SessionTimeBreakdown
|
||||
{
|
||||
TenantId = input.TenantId,
|
||||
|
||||
+12
@@ -37,6 +37,18 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
_queueClient = queueFactory.Create(Constants.QueueNames.ImeMsiArchive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: construct directly with a mock <see cref="QueueClient"/>. Mirrors
|
||||
/// <see cref="Deletion.SessionDeletionProducer"/>'s internal test ctor.
|
||||
/// </summary>
|
||||
internal AzureQueueImeMsiArchiveProducer(
|
||||
QueueClient queueClient,
|
||||
ILogger<AzureQueueImeMsiArchiveProducer> logger)
|
||||
{
|
||||
_queueClient = queueClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task EnqueueAsync(ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (envelope is null) return;
|
||||
|
||||
@@ -39,6 +39,26 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
_adminConfigService = adminConfigService ?? throw new ArgumentNullException(nameof(adminConfigService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: construct directly with mock <see cref="Azure.Storage.Queues.QueueClient"/>
|
||||
/// instances so the poll loop can be exercised without Azurite. Mirrors
|
||||
/// <see cref="Deletion.SessionDeletionWorker"/>'s internal test ctor.
|
||||
/// </summary>
|
||||
internal ImeMsiArchiveQueueWorker(
|
||||
Azure.Storage.Queues.QueueClient mainQueue,
|
||||
Azure.Storage.Queues.QueueClient poisonQueue,
|
||||
ImeMsiArchiver archiver,
|
||||
ISessionRepository sessionRepo,
|
||||
AdminConfigurationService adminConfigService,
|
||||
ILogger<ImeMsiArchiveQueueWorker> logger,
|
||||
TimeSpan? pollInterval = null)
|
||||
: base(mainQueue, poisonQueue, logger, pollInterval)
|
||||
{
|
||||
_archiver = archiver ?? throw new ArgumentNullException(nameof(archiver));
|
||||
_sessionRepo = sessionRepo ?? throw new ArgumentNullException(nameof(sessionRepo));
|
||||
_adminConfigService = adminConfigService ?? throw new ArgumentNullException(nameof(adminConfigService));
|
||||
}
|
||||
|
||||
/// <summary>Download can legitimately take minutes — extend visibility while working.</summary>
|
||||
protected override bool UseHeartbeat => true;
|
||||
|
||||
|
||||
@@ -34,7 +34,9 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
/// <c>IntuneWindowsAgent.msi</c> are accepted; anything else falls back to the canonical
|
||||
/// versionless CDN URL — safe for a NEW version, because "new" is by definition what
|
||||
/// that URL currently serves. The blob write uses If-None-Match:* so a queue re-delivery
|
||||
/// can never overwrite an existing archive entry.
|
||||
/// can never overwrite an existing archive entry; the 409 path additionally heals a
|
||||
/// provenance sidecar the first attempt failed to write (see
|
||||
/// <see cref="CompleteExistingArchiveAsync"/>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ImeMsiArchiver
|
||||
@@ -179,34 +181,18 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
// Queue re-delivery after a successful upload: the archive already has
|
||||
// this version — that IS the desired end state.
|
||||
_logger.LogInformation(
|
||||
"ImeMsiArchiver: blob {BlobPath} already exists — treating re-delivery as archived",
|
||||
blobPath);
|
||||
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
|
||||
blobPath, null, null, sourceUrl);
|
||||
// this version — that IS the desired end state. But if the first
|
||||
// attempt died between the MSI and the provenance upload, the sidecar
|
||||
// is missing and this 409 pre-empts the happy path's provenance write
|
||||
// on every future retry — so it must be healed here.
|
||||
return await CompleteExistingArchiveAsync(
|
||||
envelope, version, blobPath, sourceUrl, linked.Token);
|
||||
}
|
||||
sha256 = guarded.Sha256Hex;
|
||||
totalBytes = guarded.TotalBytes;
|
||||
}
|
||||
|
||||
var provenance = new
|
||||
{
|
||||
version,
|
||||
url = sourceUrl,
|
||||
urlFromEvent = sourceUrl != CanonicalMsiUrl,
|
||||
msiMatchedBy = envelope.MsiMatchedBy,
|
||||
sha256,
|
||||
msiBytes = totalBytes,
|
||||
firstSeenTenantId = envelope.TenantId,
|
||||
firstSeenSessionId = envelope.SessionId,
|
||||
archivedAtUtc = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
};
|
||||
await UploadProvenanceAsync(
|
||||
$"{version}/provenance.json",
|
||||
System.Text.Json.JsonSerializer.Serialize(provenance,
|
||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
|
||||
linked.Token);
|
||||
await WriteProvenanceAsync(envelope, version, sourceUrl, sha256, totalBytes, linked.Token);
|
||||
|
||||
_logger.LogInformation(
|
||||
"ImeMsiArchiver: archived {Version} ({SizeBytes} bytes, sha256 {Sha256}) from {SourceUrl}",
|
||||
@@ -247,6 +233,72 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-delivery landed on an already-archived version (409 on the write-once MSI
|
||||
/// upload). Normally the provenance sidecar exists too and there is nothing to do —
|
||||
/// but when the first attempt died between the MSI and the provenance upload
|
||||
/// (transient storage failure, host shutdown), the sidecar would otherwise be lost
|
||||
/// FOREVER: the 409 short-circuit pre-empts the happy path's provenance write on
|
||||
/// every retry. Hash and size are recomputed from the ARCHIVED bytes, not this
|
||||
/// attempt's re-download, so the provenance describes the blob it sits next to.
|
||||
/// Failures propagate into the caller's catch ladder (retryable), so the queue
|
||||
/// retries the heal until it lands.
|
||||
/// </summary>
|
||||
private async Task<ImeMsiArchiveResult> CompleteExistingArchiveAsync(
|
||||
ImeMsiArchiveEnvelope envelope, string version, string blobPath, string sourceUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await ProvenanceExistsAsync($"{version}/provenance.json", cancellationToken))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ImeMsiArchiver: blob {BlobPath} already exists — treating re-delivery as archived",
|
||||
blobPath);
|
||||
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
|
||||
blobPath, null, null, sourceUrl);
|
||||
}
|
||||
|
||||
string sha256;
|
||||
long totalBytes;
|
||||
using (var archived = await OpenArchivedMsiAsync(blobPath, cancellationToken))
|
||||
using (var hashing = new HashingCappedReadStream(archived, maxBytes: 0))
|
||||
{
|
||||
await hashing.CopyToAsync(Stream.Null, cancellationToken);
|
||||
sha256 = hashing.Sha256Hex;
|
||||
totalBytes = hashing.TotalBytes;
|
||||
}
|
||||
|
||||
await WriteProvenanceAsync(envelope, version, sourceUrl, sha256, totalBytes, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"ImeMsiArchiver: healed missing provenance for already-archived {Version} ({SizeBytes} bytes, sha256 {Sha256})",
|
||||
version, totalBytes, sha256);
|
||||
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
|
||||
blobPath, sha256, totalBytes, sourceUrl);
|
||||
}
|
||||
|
||||
private async Task WriteProvenanceAsync(
|
||||
ImeMsiArchiveEnvelope envelope, string version, string sourceUrl,
|
||||
string sha256, long totalBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
var provenance = new
|
||||
{
|
||||
version,
|
||||
url = sourceUrl,
|
||||
urlFromEvent = sourceUrl != CanonicalMsiUrl,
|
||||
msiMatchedBy = envelope.MsiMatchedBy,
|
||||
sha256,
|
||||
msiBytes = totalBytes,
|
||||
firstSeenTenantId = envelope.TenantId,
|
||||
firstSeenSessionId = envelope.SessionId,
|
||||
archivedAtUtc = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
};
|
||||
await UploadProvenanceAsync(
|
||||
$"{version}/provenance.json",
|
||||
System.Text.Json.JsonSerializer.Serialize(provenance,
|
||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// -------- Test seams (production defaults hit Azure / the network) --------
|
||||
|
||||
/// <summary>Fetches the platform admin configuration (size cap).</summary>
|
||||
@@ -280,6 +332,20 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>True when the provenance sidecar already exists in the archive container.</summary>
|
||||
protected virtual async Task<bool> ProvenanceExistsAsync(string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var containerClient = _blobStorage.GetContainerClient(Constants.BlobContainers.ImeArchive);
|
||||
return (await containerClient.GetBlobClient(blobPath).ExistsAsync(cancellationToken)).Value;
|
||||
}
|
||||
|
||||
/// <summary>Opens the already-archived installer blob for the provenance-heal rehash.</summary>
|
||||
protected virtual async Task<Stream> OpenArchivedMsiAsync(string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var containerClient = _blobStorage.GetContainerClient(Constants.BlobContainers.ImeArchive);
|
||||
return await containerClient.GetBlobClient(blobPath).OpenReadAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Uploads the provenance sidecar (overwrite allowed — it is derived data).</summary>
|
||||
protected virtual async Task UploadProvenanceAsync(string blobPath, string json, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
+4
-4
@@ -24,7 +24,7 @@
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.1",
|
||||
"tsx": "^4.23.11",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
@@ -3823,9 +3823,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.11",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz",
|
||||
"integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==",
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.1",
|
||||
"tsx": "^4.23.11",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
|
||||
@@ -148,6 +148,7 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'ime_agent_version',
|
||||
'ime_token_failure',
|
||||
'registry_app_state',
|
||||
'registry_app_baseline',
|
||||
'app_state_reconciliation',
|
||||
'ime_user_session_completed',
|
||||
'ime_session_change',
|
||||
|
||||
@@ -64,6 +64,18 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// of failing.
|
||||
/// </summary>
|
||||
public const string AdvisoryCompletion = "advisory_completion";
|
||||
|
||||
/// <summary>
|
||||
/// Device Preparation completion backstop (afee7ae0, 2026-08-18). WDP has no ESP,
|
||||
/// so none of the ESP-derived resolution deadlines (HelloSafety, AdvisoryCompletion)
|
||||
/// can ever arm there — a WDP session whose Hello never resolves would park until
|
||||
/// the agent max-lifetime watchdog with no verdict. Armed by
|
||||
/// <c>HandleDesktopArrivedV1</c> when the desktop arrives on a DevicePreparation
|
||||
/// session without completing in the same step; on fire the session resolves like
|
||||
/// the HelloSafety timeout (synthetic <c>HelloOutcome=Timeout</c> + completion —
|
||||
/// the desktop is present by construction of the arming site).
|
||||
/// </summary>
|
||||
public const string DevicePrepCompletion = "device_prep_completion";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -130,6 +142,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// <summary>On <c>SessionStarted</c>: literal "v1" / "v2" from <c>EnrollmentRegistryDetector.DetectEnrollmentType()</c>.</summary>
|
||||
public const string EnrollmentType = "enrollmentType";
|
||||
|
||||
/// <summary>
|
||||
/// On <c>EnrollmentFactsObserved</c>: "true" when the v2 verdict came from the
|
||||
/// deterministic Device Preparation marker (BootstrapperAgent ExecutionContext)
|
||||
/// rather than the CloudAssigned* fallback rules. Lifts the DevicePreparation
|
||||
/// profile seed to High confidence — the fallback rules stay Medium because they
|
||||
/// can in principle fire on ESP-less classic profiles.
|
||||
/// </summary>
|
||||
public const string EnrollmentTypeDeterministic = "enrollmentTypeDeterministic";
|
||||
|
||||
/// <summary>On <c>SessionStarted</c>: "true" / "false" from <c>EnrollmentRegistryDetector.DetectHybridJoin()</c>.</summary>
|
||||
public const string IsHybridJoin = "isHybridJoin";
|
||||
|
||||
|
||||
@@ -657,12 +657,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// HelloResolved arrives.
|
||||
builder.WithStage(state.Stage);
|
||||
|
||||
// WDP completion backstop (afee7ae0): no ESP → HelloSafety / AdvisoryCompletion can
|
||||
// never arm on Device Preparation, so a Hello that never resolves would park the
|
||||
// session until the max-lifetime watchdog. Desktop is here, Hello is not — bound
|
||||
// the wait. Armed once (name-keyed dedupe is belt; the explicit check keeps the
|
||||
// audit trail free of re-arm noise on duplicate desktop signals).
|
||||
var effects = new List<DecisionEffect>(2);
|
||||
if (state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation
|
||||
&& !HasDevicePrepCompletionDeadline(state))
|
||||
{
|
||||
var backstop = BuildDevicePrepCompletionDeadline(state, signal);
|
||||
builder.AddDeadline(backstop);
|
||||
effects.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: backstop));
|
||||
}
|
||||
|
||||
// Liveness plan PR2: Desktop is in but completion is still blocked (Hello pending,
|
||||
// or the Hello-disabled fast-path's strong gate is not satisfied) — surface the
|
||||
// missing prerequisites. Covers both the gate-false fast-path and the plain
|
||||
// desktop-first ordering; the fingerprint dedupes against earlier emissions.
|
||||
var waitingEffect = BuildCompletionWaitingEffect(
|
||||
state, builder, signal, trigger: nameof(DecisionSignalKind.DesktopArrived));
|
||||
if (waitingEffect != null) effects.Add(waitingEffect);
|
||||
|
||||
var newState = builder.Build();
|
||||
|
||||
@@ -676,7 +691,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
return new DecisionStep(
|
||||
newState,
|
||||
transition,
|
||||
waitingEffect != null ? new[] { waitingEffect } : Array.Empty<DecisionEffect>());
|
||||
effects.Count > 0 ? effects.ToArray() : Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -226,7 +226,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
state.AccountSetupEnteredUtc,
|
||||
state.EspFinalExitUtc,
|
||||
state.ImeUserSessionCompletedUtc,
|
||||
state.DesktopArrivedUtc));
|
||||
state.DesktopArrivedUtc),
|
||||
devicePreparation: state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation);
|
||||
|
||||
/// <summary>
|
||||
/// Arm C of <c>ShouldTransitionToAwaitingHello</c> (session a4537c36) restated over raw
|
||||
@@ -257,10 +258,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
bool helloPolicyDisabled,
|
||||
bool desktopArrived,
|
||||
bool realmJoinGateOpen,
|
||||
bool postEspUserSessionEvidence = false)
|
||||
bool postEspUserSessionEvidence = false,
|
||||
bool devicePreparation = false)
|
||||
{
|
||||
var missing = new List<string>(4);
|
||||
if (!accountSetupProvisioned && !skipUserEsp && !postEspUserSessionEvidence)
|
||||
// Device Preparation has no ESP: AccountSetupCategory.Status is never written
|
||||
// there, so the provisioning gate is unsatisfiable by construction and must not
|
||||
// be reported as a missing prerequisite (afee7ae0 — mirrors the skipUserEsp
|
||||
// exemption; completion on WDP rides on Hello + Desktop, see arm D of
|
||||
// ShouldTransitionToAwaitingHello and the DevicePrepCompletion backstop).
|
||||
if (!accountSetupProvisioned && !skipUserEsp && !postEspUserSessionEvidence && !devicePreparation)
|
||||
missing.Add(CompletionPrerequisites.AccountSetupProvisioningComplete);
|
||||
if (!helloResolved && !helloPolicyDisabled)
|
||||
missing.Add(CompletionPrerequisites.HelloResolution);
|
||||
@@ -302,7 +309,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
builder.AccountSetupEnteredUtc,
|
||||
builder.EspFinalExitUtc,
|
||||
builder.ImeUserSessionCompletedUtc,
|
||||
builder.DesktopArrivedUtc));
|
||||
builder.DesktopArrivedUtc),
|
||||
devicePreparation: builder.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation);
|
||||
if (missing.Count == 0) return null;
|
||||
|
||||
var fingerprint = string.Join(",", missing);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
// Device Preparation (WDP) — completion backstop (afee7ae0, 2026-08-18).
|
||||
//
|
||||
// WDP has no ESP, so every ESP-derived resolution deadline is structurally unarmable
|
||||
// there: HelloSafety arms only on EspExiting / AccountSetupProvisioningComplete / arm C,
|
||||
// AdvisoryCompletion only on an EspTerminalFailure or a guard-blocked post-AccountSetup
|
||||
// esp_exiting — none of which exist on Device Preparation. A WDP session whose Hello
|
||||
// never resolves therefore used to park until the agent max-lifetime watchdog with no
|
||||
// verdict. The engine otherwise deliberately rides the Classic rails for WDP (the
|
||||
// Hello + Desktop conjunction and arm D of ShouldTransitionToAwaitingHello); this
|
||||
// partial adds only the WDP-specific bounded wait.
|
||||
public sealed partial class DecisionEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolution window from real-user desktop arrival on a Device Preparation session.
|
||||
/// Deliberately the same order of magnitude as <c>AdvisoryCompletion</c> (30 min),
|
||||
/// not <c>HelloSafety</c> (300 s): the user may legitimately still be walking
|
||||
/// through a configured Hello wizard, and the tracker's own Hello completion
|
||||
/// timers normally resolve long before this fires — it is the last net, not the
|
||||
/// expected path.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan s_devicePrepCompletionWindow = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>True when <see cref="DeadlineNames.DevicePrepCompletion"/> is armed in <paramref name="state"/>.</summary>
|
||||
private static bool HasDevicePrepCompletionDeadline(DecisionState state)
|
||||
{
|
||||
foreach (var d in state.Deadlines)
|
||||
{
|
||||
if (d.Name == DeadlineNames.DevicePrepCompletion) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the <see cref="DeadlineNames.DevicePrepCompletion"/> backstop deadline.
|
||||
/// Replay-safe via <see cref="EffectiveDeadlineBase"/> (floored at AgentBootUtc).
|
||||
/// </summary>
|
||||
internal static ActiveDeadline BuildDevicePrepCompletionDeadline(DecisionState state, DecisionSignal signal) =>
|
||||
new ActiveDeadline(
|
||||
name: DeadlineNames.DevicePrepCompletion,
|
||||
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(s_devicePrepCompletionWindow),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.DevicePrepCompletion,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="DeadlineNames.DevicePrepCompletion"/> fired: the WDP session reached
|
||||
/// the real-user desktop but Hello never resolved within the window. Mirror of
|
||||
/// <see cref="HandleHelloSafetyDeadlineFired"/>: record the synthetic
|
||||
/// <see cref="SyntheticHelloOutcomeTimeout"/> (unless a real resolution raced the
|
||||
/// timer) and complete through Finalizing. Desktop is present by construction of
|
||||
/// the arming site (<c>HandleDesktopArrivedV1</c>); the AwaitingDesktop fallback
|
||||
/// only keeps the handler total. A timer surviving into a terminal stage is
|
||||
/// swallowed by the post-terminal guard in <see cref="Dispatch"/> — no explicit
|
||||
/// cancel wiring at the completion sites is needed for correctness.
|
||||
/// </summary>
|
||||
private DecisionStep HandleDevicePrepCompletionDeadlineFired(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||
.CancelDeadline(DeadlineNames.DevicePrepCompletion);
|
||||
|
||||
if (state.HelloResolvedUtc == null)
|
||||
{
|
||||
builder.HelloResolvedUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||
builder.HelloOutcome = new SignalFact<string>(SyntheticHelloOutcomeTimeout, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
if (state.DesktopArrivedUtc != null)
|
||||
{
|
||||
return CompleteThroughFinalizingOrDefer(
|
||||
state: state,
|
||||
signal: signal,
|
||||
preparedBuilder: builder,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: $"DeadlineFired:{DeadlineNames.DevicePrepCompletion}");
|
||||
}
|
||||
|
||||
builder.WithStage(SessionStage.AwaitingDesktop);
|
||||
|
||||
var waitingEffect = BuildCompletionWaitingEffect(
|
||||
state, builder, signal, trigger: $"DeadlineFired:{DeadlineNames.DevicePrepCompletion}");
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: SessionStage.AwaitingDesktop,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: $"DeadlineFired:{DeadlineNames.DevicePrepCompletion}");
|
||||
|
||||
return new DecisionStep(
|
||||
newState,
|
||||
transition,
|
||||
waitingEffect != null ? new[] { waitingEffect } : Array.Empty<DecisionEffect>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,6 +362,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
return HandleRealmJoinTimeoutDeadlineFired(state, signal);
|
||||
case DeadlineNames.AdvisoryCompletion:
|
||||
return HandleAdvisoryCompletionDeadlineFired(state, signal);
|
||||
case DeadlineNames.DevicePrepCompletion:
|
||||
return HandleDevicePrepCompletionDeadlineFired(state, signal);
|
||||
default:
|
||||
// Deadline name not recognized in this sub-milestone. Cancel it from state
|
||||
// and record a neutral taken transition — M3.3+ adds handlers for
|
||||
@@ -784,6 +786,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Arm D (afee7ae0, 2026-08-18) — Device Preparation: there is no ESP, so arms A–C
|
||||
// are unsatisfiable by construction (no provisioning categories, no Shell-Core
|
||||
// final exit, IME anchors are incidental pattern hits). The DAD-validated
|
||||
// real-user desktop IS the end of WDP provisioning — the DPP page has closed and
|
||||
// the user is signed in; only the Hello gate remains after this promotion.
|
||||
if (state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation
|
||||
&& (desktopArrivedInFlight || state.DesktopArrivedUtc != null))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,8 +99,19 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
|
||||
if (string.Equals(rawType, "v2", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Deterministic WDP marker (BootstrapperAgent ExecutionContext) seeds
|
||||
// High — the marker is as registry-deterministic as the self-deploying
|
||||
// OobeConfig bits below, and a High seed lets the generic monotonic
|
||||
// guard protect the mode instead of relying solely on the explicit
|
||||
// DevicePreparation checks in the downstream promotion handlers.
|
||||
// The CloudAssigned* fallback rules stay Medium (ESP-less classic
|
||||
// profiles could in principle trip them).
|
||||
var deterministic = signal.Payload.TryGetValue(SignalPayloadKeys.EnrollmentTypeDeterministic, out var rawDeterministic)
|
||||
&& bool.TryParse(rawDeterministic, out var isDeterministic)
|
||||
&& isDeterministic;
|
||||
|
||||
mode = EnrollmentMode.DevicePreparation;
|
||||
confidence = Max(confidence, ProfileConfidence.Medium);
|
||||
confidence = Max(confidence, deterministic ? ProfileConfidence.High : ProfileConfidence.Medium);
|
||||
reason = newReason;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -421,6 +421,7 @@ namespace AutopilotMonitor.Shared
|
||||
// per-app state read straight from HKLM\...\IntuneManagementExtension. Observability only —
|
||||
// never consumed by the DecisionEngine.
|
||||
public const string RegistryAppState = "registry_app_state"; // Info, state-change-only (snapshot-diff vs silent startup baseline; capped 200/session, cap announced once as Warning): per-app EnforcementState/ErrorCode/ExitCode/StatusService status/ESP-tracking from the registry.
|
||||
public const string RegistryAppBaseline = "registry_app_baseline"; // Info, one-shot at agent start (only when non-empty): summary of the Win32Apps state that PREDATES this agent run. Closes the WDP pre-agent gap — DPP-policy apps install in Batch 1 before the bootstrap-script agent exists, so their outcomes live only in this baseline (typed payload capped at 50 apps).
|
||||
public const string AppStateReconciliation = "app_state_reconciliation"; // Warning, once per app, immediate upload: registry terminal outcome (settled >= 90 s) diverges from the IME-log-derived app state — the built-in log-pattern drift alarm (registry = truth, logs = narrative).
|
||||
public const string ImeUserSessionCompleted = "ime_user_session_completed";
|
||||
public const string DoTelemetry = "do_telemetry";
|
||||
|
||||
@@ -61,7 +61,17 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// event, missing ResumedAt, or a derived part-1 length that contradicts the stored
|
||||
/// combined duration) — the two-window split is best-effort rather than event-anchored.
|
||||
/// </summary>
|
||||
WhiteGloveAnchorsIncomplete = 16
|
||||
WhiteGloveAnchorsIncomplete = 16,
|
||||
|
||||
/// <summary>
|
||||
/// The device carries state from a previous enrollment (<c>historic_ime_replay_detected</c>:
|
||||
/// the IME log on disk predates this enrollment by more than the staleness threshold — the
|
||||
/// machine was re-enrolled without a wipe). Pre-installed apps then complete as instant
|
||||
/// detections, which pulls the phase anchors far ahead of the real ESP page (session
|
||||
/// f475e697: the AccountSetup anchor fired 6.5 min before device setup actually finished),
|
||||
/// so the segment durations misrepresent the enrollment.
|
||||
/// </summary>
|
||||
PriorEnrollmentResidue = 32
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -74,7 +84,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Flags that make the SEGMENT DURATIONS themselves untrustworthy — dropped anchors,
|
||||
/// underobserved early phases, best-effort WhiteGlove window boundaries. Only these
|
||||
/// underobserved early phases, best-effort WhiteGlove window boundaries, phase anchors
|
||||
/// distorted by prior-enrollment residue. Only these
|
||||
/// exclude a breakdown from fleet segment statistics. The blocking-set flags
|
||||
/// (<see cref="TimeAttributionFlags.BlockingSetUnknown"/> / BlockingSetTruncated) say
|
||||
/// nothing about the measured spans — they only limit per-app blocking evidence, which
|
||||
@@ -85,7 +96,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public const TimeAttributionFlags DurationCriticalFlags =
|
||||
TimeAttributionFlags.ClockSkewDropped |
|
||||
TimeAttributionFlags.PartialObservation |
|
||||
TimeAttributionFlags.WhiteGloveAnchorsIncomplete;
|
||||
TimeAttributionFlags.WhiteGloveAnchorsIncomplete |
|
||||
TimeAttributionFlags.PriorEnrollmentResidue;
|
||||
|
||||
/// <summary>True when the breakdown's segment durations are unfit for fleet statistics.</summary>
|
||||
public static bool ExcludesFromFleetStats(TimeAttributionFlags flags)
|
||||
|
||||
@@ -226,7 +226,7 @@ export default function AboutPage() {
|
||||
items: [
|
||||
".NET binary — lightweight, low-overhead monitoring",
|
||||
"Runs via scheduled task (no Windows service — easy, residue-free removal)",
|
||||
"Deployed via an Intune platform script (PowerShell bootstrapper)",
|
||||
"Deployed via an Intune platform script (PowerShell bootstrapper); for Autopilot Device Preparation additionally as a thin MSI line-of-business app",
|
||||
"Mutual TLS using the existing Intune MDM device certificate",
|
||||
"Self-destruct on enrollment completion (on by default — removes task and files)",
|
||||
],
|
||||
|
||||
@@ -180,8 +180,12 @@ export default function EventTimeline({
|
||||
<div className="bg-white shadow rounded-lg p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Raw Events ({sortedBySequence.length})</h2>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{sortedBySequence.map((ev) => (
|
||||
<RawEventRow key={ev.eventId || `${ev.sessionId}-${ev.sequence}`} event={ev} />
|
||||
{sortedBySequence.map((ev, i) => (
|
||||
<RawEventRow
|
||||
key={ev.eventId || `${ev.sessionId}-${ev.sequence}`}
|
||||
event={ev}
|
||||
prevDisplayTime={i > 0 ? getDisplayTime(sortedBySequence[i - 1]) : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,11 +345,12 @@ function PhaseSection({
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-3">
|
||||
{events.map((event) => (
|
||||
{events.map((event, i) => (
|
||||
<EventRow
|
||||
key={event.eventId || `${event.sessionId}-${event.sequence}`}
|
||||
event={event}
|
||||
showScriptOutput={showScriptOutput}
|
||||
prevDisplayTime={i > 0 ? getDisplayTime(events[i - 1]) : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -372,7 +377,32 @@ const BACKFILL_TOOLTIP =
|
||||
"If enrollment progressed afterwards, this error was already resolved by a retry. " +
|
||||
"The timestamp shown is the original event-log time.";
|
||||
|
||||
function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScriptOutput?: boolean }) {
|
||||
// Display timestamp of a row: backfilled events show the original event-log time.
|
||||
function getDisplayTime(event: EnrollmentEvent): Date {
|
||||
return getBackfillInfo(event).recordedAt ?? new Date(event.timestamp);
|
||||
}
|
||||
|
||||
// Rows render time-of-day only, so a multi-hour/multi-day gap to the previous row
|
||||
// (and the date change it implies) is invisible without expanding event details.
|
||||
// Shown on the row AFTER the gap; hover reveals the full date.
|
||||
function GapBadge({ prevTime, eventTime }: { prevTime: Date | null; eventTime: Date }) {
|
||||
if (!prevTime) return null;
|
||||
const gapMs = eventTime.getTime() - prevTime.getTime();
|
||||
if (gapMs < 60 * 60 * 1000) return null;
|
||||
const label = gapMs >= 48 * 60 * 60 * 1000
|
||||
? `+${Math.round(gapMs / (24 * 60 * 60 * 1000))}d`
|
||||
: `+${Math.round(gapMs / (60 * 60 * 1000))}h`;
|
||||
return (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-xs font-medium bg-amber-50 text-amber-700 whitespace-nowrap flex-shrink-0"
|
||||
title={`${label} after the previous event — ${eventTime.toLocaleString()}`}
|
||||
>
|
||||
⏱ {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ event, showScriptOutput, prevDisplayTime }: { event: EnrollmentEvent; showScriptOutput?: boolean; prevDisplayTime?: Date | null }) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -458,6 +488,7 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
|
||||
<span className="text-gray-400 font-sans"> (reported {new Date(event.timestamp).toLocaleTimeString()})</span>
|
||||
)}
|
||||
</span>
|
||||
<GapBadge prevTime={prevDisplayTime ?? null} eventTime={recordedAt ?? new Date(event.timestamp)} />
|
||||
<SeverityBadge severity={event.severity} />
|
||||
{isBackfilled && (
|
||||
<span
|
||||
@@ -786,7 +817,7 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
|
||||
);
|
||||
}
|
||||
|
||||
function RawEventRow({ event }: { event: EnrollmentEvent }) {
|
||||
function RawEventRow({ event, prevDisplayTime }: { event: EnrollmentEvent; prevDisplayTime?: Date | null }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const detailData = useMemo(() => normalizeEventDataForDisplay(event.data), [event.data]);
|
||||
const hasDetails = detailData && Object.keys(detailData).length > 0;
|
||||
@@ -806,6 +837,7 @@ function RawEventRow({ event }: { event: EnrollmentEvent }) {
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-gray-400 w-8 text-right flex-shrink-0">{event.sequence}</span>
|
||||
<span className="text-gray-500 flex-shrink-0">{(recordedAt ?? new Date(event.timestamp)).toLocaleTimeString()}</span>
|
||||
<GapBadge prevTime={prevDisplayTime ?? null} eventTime={recordedAt ?? new Date(event.timestamp)} />
|
||||
<span className={`flex-shrink-0 w-14 ${sevColor[event.severity] || "text-gray-500"}`}>{event.severity}</span>
|
||||
{isBackfilled && (
|
||||
<span className="text-slate-500 flex-shrink-0" title={BACKFILL_TOOLTIP}>⟲</span>
|
||||
|
||||
@@ -64,6 +64,7 @@ const FLAG_LABELS: Record<string, string> = {
|
||||
BlockingSetUnknown: "ESP blocking set unknown",
|
||||
BlockingSetTruncated: "ESP blocking list truncated",
|
||||
WhiteGloveAnchorsIncomplete: "Pre-provisioning window estimated",
|
||||
PriorEnrollmentResidue: "Device re-enrolled without wipe — phase timing unreliable",
|
||||
};
|
||||
|
||||
function formatDuration(totalSeconds: number): string {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { HeroShots } from "./HeroShots";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const SCENARIOS = [
|
||||
"User-driven",
|
||||
"Pre-provisioning",
|
||||
"Self-deploying",
|
||||
"Device Preparation",
|
||||
"Windows 365",
|
||||
"Entra ID & Hybrid join",
|
||||
];
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<header className="pt-16 sm:pt-20 pb-0 px-6">
|
||||
@@ -42,6 +51,27 @@ export function Hero() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scenario coverage — quiet chips, no competition with the CTAs */}
|
||||
<div className="mt-7 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
{SCENARIOS.map((scenario) => (
|
||||
<span
|
||||
key={scenario}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] text-[var(--lp-ink-soft)]"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-[var(--lp-accent-ink)] shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{scenario}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The portal, front and center */}
|
||||
<div className="mt-12 sm:mt-14 pb-16 sm:pb-20">
|
||||
<HeroShots />
|
||||
|
||||
+64
-64
@@ -13,25 +13,25 @@
|
||||
"@microsoft/applicationinsights-web": "^3.4.3",
|
||||
"@microsoft/signalr": "^10.0.11",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"@xyflow/react": "^12.11.3",
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.3.0",
|
||||
"next": "^16.3.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^16.3.0",
|
||||
"@next/bundle-analyzer": "^16.3.1",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.3.0",
|
||||
"eslint-config-next": "^16.3.1",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
@@ -1358,9 +1358,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/bundle-analyzer": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.3.0.tgz",
|
||||
"integrity": "sha512-o8OiXKIATcSC6kHm5BIEmaDEDTFUDhpy9POQIbzAQTlb8NWwFhEQheTLFSbyQXxH2ywhjZ/e8EUlbOjj2k22Yg==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.3.1.tgz",
|
||||
"integrity": "sha512-/6XQeYPHM6jF1gTeJ82Mu6yXOHQIFVxzAn3DkPtHDp5v6SDXoCicBMTHloN+2h4WKFCQujSLCgLZHItJ3jN1uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1368,15 +1368,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz",
|
||||
"integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.1.tgz",
|
||||
"integrity": "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.0.tgz",
|
||||
"integrity": "sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.1.tgz",
|
||||
"integrity": "sha512-B4SznlXwVpaLDa7Tbi6zLuueria2d/PmFDhXyDymPGrk2r1n/RMJmcn5FZq1L64k+Jsyte1lKvOr7lP9Xo80mQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1385,9 +1385,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz",
|
||||
"integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.1.tgz",
|
||||
"integrity": "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1401,9 +1401,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz",
|
||||
"integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.1.tgz",
|
||||
"integrity": "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1417,9 +1417,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz",
|
||||
"integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.1.tgz",
|
||||
"integrity": "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1436,9 +1436,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz",
|
||||
"integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.1.tgz",
|
||||
"integrity": "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1455,9 +1455,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz",
|
||||
"integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.1.tgz",
|
||||
"integrity": "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1474,9 +1474,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz",
|
||||
"integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.1.tgz",
|
||||
"integrity": "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1493,9 +1493,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz",
|
||||
"integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.1.tgz",
|
||||
"integrity": "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1890,9 +1890,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
@@ -2762,12 +2762,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz",
|
||||
"integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==",
|
||||
"version": "12.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.3.tgz",
|
||||
"integrity": "sha512-G3jogHz2GWUtIOkhavUGno2YzY9u6fILIJBttfsBendb0/HWB90JG+sOTAvlIMEwyvq9zgy9V9ZQSwyQjR5QzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.79",
|
||||
"@xyflow/system": "0.0.80",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
@@ -2787,9 +2787,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.79",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz",
|
||||
"integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==",
|
||||
"version": "0.0.80",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.80.tgz",
|
||||
"integrity": "sha512-ywc3ZqG91brzWrH1WlwMdIX4goOfrpBy6AbLdVSaof/Xx9l138ijIKRExM6EkMro2F+OImGmSiA/WKcXvKVcfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
@@ -4216,13 +4216,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.0.tgz",
|
||||
"integrity": "sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.1.tgz",
|
||||
"integrity": "sha512-0vtrpwFVHFEkycUgV/DyrG29OS+HSRdah5Yu8YuZoiBMtlAT6NIiWzaLwDkJZxr2kGfx+9LIvfQ7KHAlEs0VsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.3.0",
|
||||
"@next/eslint-plugin-next": "16.3.1",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -6308,13 +6308,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz",
|
||||
"integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz",
|
||||
"integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.3.0",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"@next/env": "16.3.1",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.5.23",
|
||||
@@ -6327,14 +6327,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.3.0",
|
||||
"@next/swc-darwin-x64": "16.3.0",
|
||||
"@next/swc-linux-arm64-gnu": "16.3.0",
|
||||
"@next/swc-linux-arm64-musl": "16.3.0",
|
||||
"@next/swc-linux-x64-gnu": "16.3.0",
|
||||
"@next/swc-linux-x64-musl": "16.3.0",
|
||||
"@next/swc-win32-arm64-msvc": "16.3.0",
|
||||
"@next/swc-win32-x64-msvc": "16.3.0",
|
||||
"@next/swc-darwin-arm64": "16.3.1",
|
||||
"@next/swc-darwin-x64": "16.3.1",
|
||||
"@next/swc-linux-arm64-gnu": "16.3.1",
|
||||
"@next/swc-linux-arm64-musl": "16.3.1",
|
||||
"@next/swc-linux-x64-gnu": "16.3.1",
|
||||
"@next/swc-linux-x64-musl": "16.3.1",
|
||||
"@next/swc-win32-arm64-msvc": "16.3.1",
|
||||
"@next/swc-win32-x64-msvc": "16.3.1",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -6361,9 +6361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz",
|
||||
"integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.1.tgz",
|
||||
"integrity": "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -20,25 +20,25 @@
|
||||
"@microsoft/applicationinsights-web": "^3.4.3",
|
||||
"@microsoft/signalr": "^10.0.11",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"@xyflow/react": "^12.11.3",
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.3.0",
|
||||
"next": "^16.3.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^16.3.0",
|
||||
"@next/bundle-analyzer": "^16.3.1",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.3.0",
|
||||
"eslint-config-next": "^16.3.1",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@@ -604,6 +604,7 @@ export const SHARED_MANIFEST = {
|
||||
"realmjoin_resolved",
|
||||
"realmjoin_timeout",
|
||||
"reboot_triggered",
|
||||
"registry_app_baseline",
|
||||
"registry_app_state",
|
||||
"remote_config_fetch_failed",
|
||||
"script_completed",
|
||||
|
||||
@@ -600,6 +600,7 @@
|
||||
"realmjoin_resolved",
|
||||
"realmjoin_timeout",
|
||||
"reboot_triggered",
|
||||
"registry_app_baseline",
|
||||
"registry_app_state",
|
||||
"remote_config_fetch_failed",
|
||||
"script_completed",
|
||||
|
||||
Reference in New Issue
Block a user